Files
goodwe-addon/goodwe_controller/app/main.py
T
glenn schrooyenandClaude 2bbf88862a
checks / unit (pull_request) Successful in 25m56s
infra-pin / fresh (pull_request) Successful in 1s
MAINT-20 part 2: main.py keeps the FCST-04 windowing caveat a test pins
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 04:00:00 +01:00

5742 lines
300 KiB
Python

"""GoodWe RS485 Controller add-on - entry point and orchestration.
It reads the house's net grid power from Home Assistant, decides how hard the
battery should charge or discharge to hold that at zero, and writes the figure
to an ESP32 that puts it on the inverter's RS485 EMS port. Monthly it runs a
full low->full cycle so the BMS can balance cells and recalibrate.
THE ONE FACT BEHIND EVERY FAILSAFE: the inverter holds the last command it
understood FOREVER (measured: 5 kW of discharge held for 113 s after a
controller died). So when in doubt this process stops writing and the hardware
takes the battery to zero. Never "hold the last value to be safe".
layer 1 the ESP32 watchdog: no refresh for ~30 s -> 0 W, and it KEEPS
WRITING it. This add-on's heartbeat feeds it.
layer 2 wind-down before a firmware update, in the ESP32.
layer 3 the optional RS485 e-stop: 0 W after 30 s of bus silence; the only
cover for this add-on's host dying.
Per-section reasoning: DECISIONS/MAIN-orchestration.md."""
import asyncio
import contextlib
import dataclasses
import functools
import json
import logging
import math
import os
import signal
import time
from datetime import datetime, timedelta, timezone
import aiohttp
from . import arbitrage
from .arbiter import (
ACTUATOR_SITE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK,
P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve,
)
from . import allocator
from . import dayplan
from . import modetrade
from . import packmode
from . import priceruns
from . import solarfc
from . import control as _ctl
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .delivery import (
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
account as delivery_account, delinquent,
)
from .circulation import (
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
circulating_w, opposing,
)
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog,
clamp_to_rating, delivery_short, is_commandable, loop_clamp_note,
shortfall_note, write_clamp_note,
)
from . import freshness
from . import dayview, homeview
from .daycurve import SolarToday
from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .hass import CORE_API, OUTAGE, HomeAssistant
# `LINK_STALE_S` is re-exported deliberately: `live_units()` and two checks
# read `app.main.LINK_STALE_S`.
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
FAIL_BACKOFF_DAYS, MaintResult, arbitrate as maint_arbitrate, site_zone,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
MqttPublisher, announced_units, mode_configs,
optional_keys, parse_command, unit_slugs, unit_state_key)
from . import peakguard
from . import packshare
from . import pv as pv_read
from . import shadow
from .p1 import P1Ingest, build_source, is_enabled
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
from .price import required_spread as price_required_spread
from .price import uncertainty_of as price_uncertainty_of
from .price_chain import (PriceWatch, fixed_tariff_beside_service, gate_for,
uses_chain)
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_gated, static_status
from .tariff import PeakStore, PeakTracker, next_quarter
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
validate_startup_config, validate_unit_ranges)
from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
from . import setuponly
from . import web
OPTIONS_PATH = "/data/options.json"
INGRESS_PORT = 8099
_LOG = logging.getLogger("goodwe")
# `UNIT_A` lives in app/inverter.py and is re-exported here because the roster
# does and four checks read `app.main.UNIT_A`. `build_roster()` is the one
# function CFG-04 changes.
__all__ = ["UNIT_A", "Controller", "amain", "main"]
# DEV-02: the per-write budget. Every write to every inverter is dispatched
# concurrently, each bounded by this, so one hung `hass.call()` cannot eat a
# sibling's layer-1 budget.
# DERIVED, NOT CHOSEN: `heartbeat_s` max is 25 s and layer 1's veto is
# `validate.FIRMWARE_INGEST_VETO_S` (30 s), so 25 + 4 < 30. test_inverter.py
# grades the inequality, not the literal. It is below hass.py's 10 s HTTP
# timeout on purpose: abandon and re-issue next tick (`last_write` moves only
# on an ACCEPTED write). DECISIONS/MAIN-orchestration.md.
WRITE_BUDGET_S = 4.0
# SAFETY-34: a Core-only restart wipes persistent_notification without
# restarting this add-on, so a latched alarm is re-asserted this often.
REANNOUNCE_S = 300.0
# UX-11: a quiet meter logs at most one WARNING this often (on `ha_signed` a
# healthy meter can go 42-97 s between changes).
METER_QUIET_REPEAT_S = 600.0
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
# OPT-04: the background tariff refresh. `static_tariff.resolve_tariff()` may
# do an HTTPS fetch, and a supplier's TLS timeout on the control tick could
# starve the ESP32 heartbeat into layer 1. So the tick NEVER awaits a fetch: it
# reads `self.tariff_plan`, which this task assigns.
# The cadence is not about how often prices change (day-ahead publishes once,
# 14-16 h CET; 30 min picks it up inside the hour with 48 fetches a day).
# DECISIONS/MAIN-orchestration.md.
TARIFF_REFRESH_S = 1800.0
# The age bound is a different question from the refresh: a failed refresh
# still stores a plan (the 11.4 static fallback, provisional). The bound covers
# the refresh task dying and a stale plan standing as current. Three missed
# refreshes, for `p1.max_age_s`'s reason.
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
def tariff_refresh_wanted(opts: dict) -> bool:
"""Does this install need the background tariff refresh? (TARIFF-10,
TARIFF-24)
Yes with a price feed, trading armed, or battery modes on. It decides
whether prices are looked up, never whether energy moves.
DECISIONS/TARIFF-10-fetching-is-not-trading.md and
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md."""
return (provider_configured(opts) or arbitrage.enabled(opts)
or packmode.enabled(opts))
def load_options() -> dict:
try:
with open(OPTIONS_PATH, "r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError) as err:
_LOG.error("cannot read %s (%s) - using defaults", OPTIONS_PATH, err)
return {}
# OPT-11 P3: how long the last cheap run stays the price stored energy is
# valued at: the rest of that day.
REF_CHEAP_KEEP = timedelta(hours=12)
# OPT-15: the longest a run edge holds the loop at E waiting for the meter to
# show the step (control.PLANT_SETTLE_S).
EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
# OPT-11 P4: store key of the house profile, and how often the forecast sensors
# are read (hourly at most).
HOUSE_PROFILE_KEY = "house_profile"
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
# evening restart keeps the night plan's figure.
FC_HOLD_KEY = "solar_fc_hold"
# OPT-34: the level of a night grid charge that has started, so it does not
# drop when the forecast goes (`Controller._latch_charge()`).
CHARGE_LATCH_KEY = "night_charge_latch"
# OPT-34: a latch outlives its last cheap run by one price slot, since the next
# run is planned at the slot edge.
CHARGE_LATCH_GRACE_S = 900.0
FORECAST_READ_S = 60.0
def _load_latch(data, now=None):
"""`(until, {name: level %}, born)` from the store, or None if it is not a
well-formed latch or has ended (OPT-34)."""
try:
until = datetime.fromisoformat(data["until"])
born = datetime.fromisoformat(data.get("born") or data["until"])
levels = {str(n): float(v) for n, v in data["levels"].items()}
except (TypeError, KeyError, ValueError, AttributeError):
return None
if until.tzinfo is None or born.tzinfo is None or not levels or not all(
math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()):
return None
if now is not None and now >= until:
return None
return until, levels, born
def _night_limit(born):
"""OPT-34: a run that starts after this cannot carry the latch on: the
first bell start (`solarfc.BELL_FROM_H`, local) after `born`."""
d0, d1 = solarfc._bounds(born)
bell = solarfc._bell_start(d0)
return bell if bell > born else solarfc._bell_start(d1)
def why_raised(resolution, claims) -> bool:
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
ABOVE what the winner asked (the peak floor asking for more power)? Then
the Why line must not say "held back". False when nothing bound it."""
if not resolution.bound_by or resolution.contradiction:
return False
asked = next((c.value for c in sorted(
(c for c in claims if c.kind == SET
and c.actuator == ACTUATOR_SITE and c.source == resolution.winner),
key=lambda c: c.priority, reverse=True)), None)
return asked is not None and resolution.target_w > float(asked)
def pack_energy_of(inv):
"""OPT-09. ONE roster entry as the view `packshare` consumes.
One builder for two callers (`pack_energy` needs the roster, `cycle()` one
entry): written twice they would drift. A module function so inverter.py
need not import `packshare`."""
return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor,
inv.capacity_kwh, inv.max_w)
# SAFETY-73: the evidence must also span this long, not only SETTLE_CYCLES
# cycles: a 1 s meter makes 12 cycles only 11 s, shorter than a healthy battery
# reading's lag, and both inverters were refused after ordinary load steps.
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
# SAFETY-94: evidence restarts when the reading has moved this far toward the
# ask. A refusing battery reads flat; live readings wander under 25 W at rest;
# a first-order lag gains at most 0.4 tau x 100 W across the band, so the
# evidence cannot span REFUSED_SPAN_S unless the lag is over 54 s.
REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
# SAFETY-85: a battery another owner set is graded against the least it was
# told over the plant's settle time, so a battery still settling is not short.
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
# SAFETY-119/121: after a hand-back the loop's bound is hidden for
# HANDBACK_CYCLES / SHORT_SPAN_S, and across hand-backs on at most
# ROLLING_BOUND_CYCLES cycles per ROLLING_S
# (DECISIONS/SAFETY-121-bound-every-quarter.md). Without a monotonic clock the
# budget never refills.
HANDBACK_CYCLES = 4
ROLLING_S = 900.0
ROLLING_BOUND_CYCLES = 11
def _refused_interval(sign: int) -> tuple:
"""SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way."""
return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf)
def why_key(winner: str, claims) -> str:
"""The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and
"failsafe" stand for several causes, so their own claim reason is the
key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when
this module refuses to split the power)."""
if winner == "loop":
# SAFETY-52: a loop holding a refused direction says so.
return next((c.reason for c in claims if c.source == "loop"
and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner)
if winner in ("safety", "failsafe"):
return next((c.reason for c in claims
if c.source == winner and c.kind == "set"),
"no claim was made" if not any(
c.kind == "set" for c in claims) else "unsplit")
return winner
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
provider: bool, max_age_s: float) -> str:
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan
(TARIFF-11).
Two sources, two clocks, and the row says so: `tick_words` is regenerated
on this I/O-free tick; `plan` is the refresh's resolution, stamped with its
own age from `tariff_at` (not moved on a kept plan, TARIFF-12), up to
TARIFF_MAX_AGE_S (5400 s) before `fresh_tariff_plan()` retires it. `age_s`
is the caller's measurement: a clock read here would disagree with the one
that judged freshness. "No plan" is said aloud on a site that configured a
provider (a soak read 'no static profile' at all 802 samples).
DECISIONS/MAIN-orchestration.md."""
# UX-08: plain words, every figure kept. The tick's sentence is "now"; the
# plan carries its own age.
if plan is None:
if not provider:
return tick_words
return (f"{tick_words}. No price plan: none yet, or the last one is "
f"over {max_age_s:.0f} s old")
return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for "
f"{horizon_h:.0f} hours ahead: {plan.words}")
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
why: str) -> str:
"""The *Arbitrage* status row: three states (TARIFF-11): off (not armed),
armed with no decision this cycle (`why` is the loop's reason), or a
decision with its age.
`on` is read from the options, never inferred from `decision is None`:
`cycle()` returns at the input hold before `self.arb = None`, so the old
row named an option that was ON. The age is always printed, even 0.
DECISIONS/MAIN-orchestration.md."""
# UX-08: plain words; the setting is named by its label.
if not on:
return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not "
f"switched on")
if decided_at is None:
return (f"On, but no decision yet, because control has not got that "
f"far. Control says: {why or 'no reason recorded'}")
age = (now - decided_at).total_seconds()
stale = cycle_at is not None and decided_at < cycle_at
head = (f"On, no decision this cycle ({why or 'no reason recorded'}), "
f"the last one {age:.0f} s ago: " if stale else
f"On, decided {age:.0f} s ago: ")
if decision is None:
return (f"On, no decision this cycle ({why or 'no reason recorded'}) "
f"and none before")
return head + decision.explain()
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db",
price_history_path: str = "/data/price_history.json",
site_tz=None):
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
# builds no controller.
if getattr(hass, "read_only", False) is True:
raise ValueError("a controller cannot run on a read-only "
"HomeAssistant (setup-only mode)")
self.o = opts
self.hass = hass
self.store = store
self.mqtt = mqtt_pub
# REQUIRED and keyword-only: a default `stop=None` made a Controller
# that cannot stop itself (inert but looks healthy on the late-refusal
# path in run_control()). DECISIONS/MAIN-orchestration.md.
self.stop_event = stop
self.refused: list[str] | None = None
# First, because the store migration below can log to it: 15's action
# log is how an operator learns a latch was re-armed, and an
# AttributeError here would stop the add-on booting.
self.events: list[str] = []
# UX-08: the last event in plain words, for the page. See log_event().
self.hint = ""
# DEV-02: the roster, built before anything reads a rating. Every
# per-device value lives on these objects and nowhere else
# (app/inverter.py); a `Tuning` and an integrator are deliberately NOT
# per device (6.2: one integrator on P1 above the split). The store
# migration runs here, BEFORE the first `Inverter` reads its SAFETY-02
# latch: else an existing tripped install comes up ARMED with a green
# banner. SAFETY-02's pair is the site comparison's pair (SAFETY-30),
# identical at N=1. DECISIONS/MAIN-orchestration.md.
self.sign_cfg = sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("sign_disagree_w", 300)),
hold_s=float(opts.get("sign_disagree_s", 180)),
)
# SAFETY-30: the per-unit comparison's own pair, re-derived at two
# inverters, not carried across (a command against one unit's own power
# is a different quantity). DECISIONS/SAFETY-30-per-unit-sign.md;
# watchdog.SIGN_OPTION_DEFAULTS.
self.unit_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("unit_sign_disagree_w",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])),
hold_s=float(opts.get("unit_sign_disagree_s",
SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])),
)
# SAFETY-30: the site comparison's own pair, used only at N>=2. The 300
# W threshold is carried (meter noise and deadband); the window is not
# (110 s at one inverter, 215 s at two, against a 180 s hold). See
# watchdog.SIGN_OPTION_DEFAULTS.
self.site_sign_cfg = SignWatchdogConfig(
min_w=float(opts.get("site_sign_disagree_w",
SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])),
hold_s=float(opts.get("site_sign_disagree_s",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
)
# SAFETY-42: the window a unit may be short of its share before it is
# named. No second key: the watts half is `Tuning.saturation_w`, read
# at runtime (`delivery.MIN_W_IS_SATURATION_W`, CFG-03).
self.delivery_cfg = delivery_cfg = DeliveryConfig(
hold_s=float(opts.get("delivery_hold_s",
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
)
# SAFETY-44: hand the migration the names the machines read:
# `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the
# flat latch landed under `unit/inverter-a/`, on no roster, and stamped
# the marker). `roster_names()` returns `[UNIT_A]` on the flat shape,
# so today's upgrade path is byte-identical. `misdirected` is
# unreachable from THIS call site; the `roster` argument stays for new
# callers and is graded in test_maintenance.py.
# DECISIONS/MAIN-orchestration.md.
_roster_names = roster_names(opts)
self.store_migration = migrate_unit_keys(
store, _roster_names[0], _roster_names)
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
# CFG-04: say which shape is in force. A declared `inverters:` roster
# ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which
# the UI still shows; validate.py rule 9(e) refuses only the dangerous
# one, so the rest are DISCLOSED in one action-log line at every start.
if roster_entries(opts):
self.log_event(
"roster: %d inverters from `inverters:` - %s. The flat "
"soc_entity / batt_entity / batt_invert / max_w keys are NOT "
"read in this shape; each entry carries its own."
% (len(self.inverters),
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)),
hint=messages.hint("roster", n=len(self.inverters)))
self.tuning = Tuning(
gain=float(opts.get("gain", 0.6)),
# The SITE bound is summed from the roster, not `opts["max_w"]`: it
# is the integrator bound and rail of the site loop, split across
# inverters. Identical to `max_w` at N=1; a per-unit `max_w` still
# binds each share through its `supervised` claim.
max_w=self.site_max_w,
# SAFETY-39: the fallback is 0, meaning "follow the rail"
# (config.yaml and `Tuning`). It was 1000, a stale third copy.
# `control.slew_limit_w()` is the one reader.
slew_w=float(opts.get("slew_w", 0) or 0),
deadband_w=float(opts.get("deadband_w", 15)),
target_grid_w=float(opts.get("target_grid_w", 0)),
step_w=int(opts.get("step_w", 10)),
saturation_w=float(opts.get("saturation_w", 500)),
saturation_cycles=int(opts.get("saturation_cycles", 3)),
# 0 / unset means "follow max_w", the recommended value. Read the
# note in control.py before raising it above max_w: every watt
# above the rail is unwind latency.
integrator_max_w=(float(opts["integrator_max_w"])
if opts.get("integrator_max_w") else None),
)
# CFG-03: no `opts.get(key, <literal>)` for maintenance here;
# `MaintConfig.from_options()` reads them next to
# `maintenance.MAINT_OPTION_DEFAULTS` (two literals once disagreed with
# config.yaml). A new key goes in config.yaml `options:`/`schema:` and
# that dict only.
# MAINT-05: one `Maintenance` per roster entry. The history migration
# runs first (else a pack believes it was never recalibrated), and
# targets `self.inverters[0].name`, NOT `UNIT_A`: the flat keys
# belonged to the first roster entry. The roster is passed, not a
# count, so `migrate_maint_keys()` grades the namespace it wrote
# (test_maintenance.py sections 9 and 11 use `["garage", "shed"]`).
# DECISIONS/MAIN-orchestration.md.
self.maint_migration = migrate_maint_keys(
store, self.inverters[0].name, [inv.name for inv in self.inverters])
_LOG.info("maintenance history: %s", self.maint_migration.explain())
if not self.maint_migration.ok:
# Not a disarmed guard (history, not a latch), but it is the
# difference between a pack recalibrated on schedule and one
# silently not (12), so it is said where an operator looks.
self.log_event("maintenance history did not move over cleanly: "
+ self.maint_migration.explain(),
hint=messages.hint("maint_history"))
# MAINT-05: one config, N machines: the `maintenance_*` options
# describe the PROCEDURE (same on every pack); the SCHEDULE and HISTORY
# are per pack, in the store. A per-entry config block would be a
# second shape for CFG-04 and a fifth place for `maintenance_charge_w`
# to disagree with config.yaml; two procedures would be a roster-schema
# card.
_maint_cfg = MaintConfig.from_options(opts)
_one_pack = len(self.inverters) <= 1
self.maints = [
Maintenance(
_maint_cfg, store,
# `None` at a roster of one is the FLAT layout, not a missing
# name (6.2 identity): it keeps a one-inverter state file
# byte-identical (`maintenance.UnitHistory`,
# `store.migrate_maint_keys()`).
unit=None if _one_pack else inv.name,
name=inv.name,
# The rollback view, and only ever the first pack's: the flat
# keys mean `inverters[0]` on every install that has them.
mirror_flat=(not _one_pack and i == 0),
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
for i, inv in enumerate(self.inverters)]
# SAFETY-02: one watchdog per actuator, each restored from ITS OWN
# namespaced store keys (a trip on B latches B, A keeps commanding).
# Restored, because a latch held only in memory is cleared by the
# restart an operator staring at a FAILSAFE banner tries first. Graded
# on every start: a latch that failed to move namespace, or was taken
# by an older add-on during a rollback, is a disarmed guard, so a
# disagreement with the flat value re-arms by hand and shouts (the
# fail-safe direction is ON). DECISIONS/MAIN-orchestration.md.
self._grade_store_migration(store)
for inv in self.inverters:
if inv.sign_wd.tripped:
# UX-11: the plain sentence and the code; the saved reason with
# figures is on the DEBUG line.
_LOG.error("%s stays at 0 W after the restart because its "
"power went the wrong way, so find out why before "
"you start control: %s.", inv.name,
messages.reason_code(inv.sign_wd.reason))
_LOG.debug("Evidence for %s: %s.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# UX-11: `events` starts empty and DEBUG is off by default, so
# the saved figures go on the action log too.
saved = inv.sign_wd.reason or messages.NO_REASON_SAVED
self.log_event(f"FAILSAFE {inv.name}, saved before the "
f"restart: {saved}",
hint=messages.hint("stop_restored",
unit=inv.name))
# SAFETY-42 + SAFETY-34: a latch that does NOT act, unlike SAFETY-02's
# and SAFETY-31's (which command 0 W). It is restored so the ALARM
# survives (2026-09-02: an outage unseen for 6 h 25 m with the detector
# correct); `delivery.py` argues reporting rather than acting.
# `_delivery_announced` is per process (as `_circ_announced`): a
# restart clears the flag and keeps the latch.
self._delivery_announced = set()
for inv in self.inverters:
if inv.delivery_wd.tripped:
_LOG.error(
"%s is still marked as not delivering after the restart "
"while control keeps running, and only stopping and "
"starting control clears this: %s.",
inv.name, messages.reason_code(inv.delivery_wd.reason))
_LOG.debug("Evidence for %s: %s.", inv.name,
inv.delivery_wd.reason or "(reason not recorded)")
saved = inv.delivery_wd.reason or messages.NO_REASON_SAVED
self.log_event(f"NOT DELIVERING {inv.name}, saved before the "
f"restart: {saved}",
hint=messages.hint("not_delivering",
unit=inv.name))
# SAFETY-30: the SITE half of the sign check, one per site, armed only
# at N>=2: the SUM of the standing commands against P1. At a roster of
# one it is constructed and never evaluated (arming it would duplicate
# `inverters[0].sign_wd`: two latches, two banners, a false-positive
# record spent twice); `cycle()` enforces it. Restored from the store
# with FLAT keys (store.py). DECISIONS/SAFETY-30-per-unit-sign.md.
self.site_sign_wd = SignWatchdog(
self.site_sign_cfg,
tripped=bool(store.data.get("site_sign_tripped", False)),
reason=str(store.data.get("site_sign_reason", "") or ""))
if self.site_sign_wd.tripped:
_LOG.error(
"After the restart every inverter stays at 0 W, as the total "
"power went the wrong way, so find out why before starting "
"control: %s.", messages.reason_code(self.site_sign_wd.reason))
_LOG.debug("Evidence for the site: %s.",
self.site_sign_wd.reason or "(reason not recorded)")
self.log_event("FAILSAFE site, saved before the restart: "
+ (self.site_sign_wd.reason
or messages.NO_REASON_SAVED),
hint=messages.hint("site_direction"))
# The per-unit detectors are told which question they ask here as well
# as in `cycle()`: `checks()` and the status page read
# `inv.sign_wd.cfg` before the first cycle.
self._arm_sign_watchdogs()
# SAFETY-18: the frozen-but-fresh meter detector (freeze.py). NOT
# restored from the store: it does not act, so it does not latch; a
# restart re-derives it within one `meter_freeze_s`.
self.freeze_wd = FreezeWatchdog(
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
# SAFETY-31: one per site, RESTORED (unlike SAFETY-18): a trip files a
# P_SAFETY SET that stops every inverter, and a cleared latch would
# restart straight into the loop it stopped. SITE-level keys, not
# namespaced: circulating power is defined over the roster (store.py).
self.circ_wd = CirculationWatchdog(
CirculationConfig(
min_w=float(opts.get("circulation_w",
CIRC_OPTION_DEFAULTS["circulation_w"])),
hold_s=float(opts.get("circulation_hold_s",
CIRC_OPTION_DEFAULTS["circulation_hold_s"]))),
tripped=bool(store.data.get("circ_tripped", False)),
reason=str(store.data.get("circ_reason", "") or ""))
# SAFETY-34: has the durable alarm been put back in front of the
# operator since this process started? `persistent_notification` does
# not survive the HA restart the store does; `run_control()` re-raises
# it once before the first cycle.
self._circ_announced = False
# SAFETY-34: when each latched alarm was last put in front of HA, by
# notification_id (REANNOUNCE_S).
self._announced_mono = {}
if self.circ_wd.tripped:
_LOG.error(
"After the restart every inverter stays at 0 W, as the "
"inverters worked against each other, so find out why before "
"starting control: %s.",
messages.reason_code(self.circ_wd.reason))
_LOG.debug("Evidence for the site: %s.",
self.circ_wd.reason or "(reason not recorded)")
self.log_event("CIRCULATING POWER, saved before the restart: "
+ (self.circ_wd.reason
or messages.NO_REASON_SAVED),
hint=messages.hint("circulating"))
# SAFETY-03: the output clamp's alarm (clamp.py). No config of its own:
# the bound is `max_w` (already validated against the device rating),
# and a second key would be another place a limit must be kept in step
# (CFG-03). The anti-flood cadence is a constant, like
# freeze.RAIL_FRAC.
self.clamp_wd = ClampWatchdog()
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
# single-entity `meter_entity` path, so an existing install is
# unchanged until it opts in.
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
max_age_s=float(opts.get("meter_max_age_s", 30)))
self.p1_enabled = is_enabled(opts)
# TARIFF-01: the capacity-tariff peak, measured from the meter, banked
# in SQLite, restored across a restart. An INSTRUMENT: it files no
# claim and clamps nothing (SAFETY-07 reads it). No new option
# (`peak_cap_w`) and no second peak entity: `peak_forecast_entity`
# stays the input to maintenance_charge_floor(), and repointing it
# would change what moves energy in a maintenance charge.
# DECISIONS/SAFETY-07-peak-guard.md.
self.peak_store = PeakStore(peak_db_path)
self.peak = PeakTracker(self.peak_store,
cap_w=float(opts.get("peak_cap_w", 3500)))
self.peak.restore(self.p1.averager)
_LOG.info("Month peak store: %s", self.peak.restore_state)
# FCST-04: the 8.3 carry-forward store (published prices of the last
# few Brussels days) so unprinted hours can be filled from yesterday
# and FLAGGED. An instrument's backing file, not a strategy. Carrying
# widens what the optimiser SEES, never what it may act on (carried
# hours are provisional). The windowing DOES move energy: hours
# ALREADY ELAPSED leave the spread, so a past cheap night no longer
# sets the day's cheapest hour; this caveat must always travel with the
# guarantee (`resolve_tariff()`, DOCS.md, CHANGELOG). A broken or
# absent store answers `no_data`; `PriceHistory._load()` drops
# unreadable records (the constructor and `.words` are unguarded).
# DECISIONS/FCST-04-the-day-that-is-carried.md.
self.price_history = PriceHistory(price_history_path)
_LOG.info("Price history: %s", self.price_history.words)
# SAFETY-07: the guard is stateless (peakguard.py); the only state here
# is what an EDGE needs, so the log and notification fire on
# transitions. The ceiling has no config of its own (`peak_cap_w`).
# SAFETY-24's floor does: exactly two keys, defaults from
# `peakguard.PEAK_OPTION_DEFAULTS` (no literals here, as a literal
# repeated the 2000-vs-2500 `maintenance_charge_w` divergence of
# validate.py rule 6; closed by `maintenance.MAINT_OPTION_DEFAULTS`).
# The rule: no numeric fallback in main.py that config.yaml has not
# shipped. DECISIONS/SAFETY-07-peak-guard.md.
self.peak_guard = None
# SAFETY-79: the guard aims this far under `peak_cap_w`; the bill and
# every report keep the real cap (`self.peak.cap_w`).
self.peak_margin_w = float(opts.get(
"peak_margin_w", peakguard.PEAK_OPTION_DEFAULTS["peak_margin_w"]))
self._peak_engaged = False
self._peak_saturated = False
# SAFETY-81: what the alarm is graded on, beside the guard's decision.
self._peak_proj = None
self._peak_batt_prev = None
# SAFETY-90: what a meter gap is counted at (`_fill_peak_gaps()`).
self._gap_seen_w = None
self._gap_w = None
self._gap_block = None
self._gap_filled_s = 0.0
self._gap_held_ws = 0.0
self._gap_pre = None
self._unseen = None
# A held sample TARIFF-01 restored from before this restart: the
# averager credits it max_hold_s into the restart, at 0 W batteries.
_held = self.p1.averager.held_since
self._run_pre = (None if _held is None
else (_held, self.p1.averager.held_net_w))
self._unseen_held_ws = 0.0
self.peak_gap_s = 0.0
self.peak_unmeasured_s = 0.0
self._peak_alarm_block = None
self._peak_high_since = None
self._peak_rolled = False # OPT-15
self._peak_floor_limited = False
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
self._reserve_batt_prev = None
self._reserve_high_since = None
# SAFETY-126: `_peak_landing()`'s last battery reading and latch.
self._land_batt_prev = None
self._peak_needed = False
self._maint_shape_suspended = False
# OPT-11: battery modes, off by default (SAFETY-10 section 1). Each
# battery's mode and on/off switch live in the store, not in options,
# so a change applies at once (`pack_use`, `pack_mode`).
self.modes_on = packmode.enabled(opts)
# The units whose mode fault is logged already (edge-logged).
self._mode_faults = set()
# OPT-11 P3: the price runs (`priceruns.RunPlan` or None), the key they
# were built for, this tick's roles (`modetrade.Tick`), batteries that
# left a run keyed by the run, and whether last tick filed a mode trade
# (a run edge reseeds).
self.mode_runs = None
self._runs_key = None
# TARIFF-24: the end of the run that was under way while a forecast
# was read; until then losing the forecast does not widen the gap.
self._wx_free_to = None
self.mode_tick = None
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
# it; nothing in control does.
self.mode_gate = None
self._run_done = {}
self._mode_trade_was = False
self._charge_blocked_q = None
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
self._ref_cheap = None
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
# last battery reading).
self._edge = None
# OPT-11 P4/OPT-17: the solar forecast and day plan, only with battery
# modes on AND a forecast for today set; each day may name several
# entities, summed (`solarfc.entities()`); `fc_entity` is the first,
# None means no forecast.
self.fc_entities = (solarfc.entities(opts) if self.modes_on
else ())
self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True)
self.fc_entity = self.fc_entities[0] if self.fc_entities else None
# OPT-17: the members that are not usable, as `solarfc.read_sum()`
# gives them, for the status page and the action log.
self.solar_fc_gaps = ()
self.solar_trust_pct = opts.get("solar_trust_pct",
dayplan.DEFAULT_TRUST_PCT)
self.solar_fc = None
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
else solarfc.MISSING)
self._fc_read_mono = None
self.fc_hold = solarfc.Hold(
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
self.day_plan = None
self._plan_targets = None
# OPT-34: `(until, {name: level %})` of a night charge under way.
self._charge_latch = _load_latch(
(getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY),
datetime.now(timezone.utc))
self.house_profile = None
if self.fc_entity is not None:
self.house_profile = dayplan.HouseProfile(
(getattr(store, "data", None) or {}).get(HOUSE_PROFILE_KEY))
# OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
self._mqtt_refused = set()
self._mqtt_refused_more = 0
# [(pack name, why)] for packs due and waiting their turn. Empty at a
# roster of one.
self._maint_deferred = []
# SAFETY-78: names of draining packs that cannot give power now, so the
# "waits" line logs once.
self._drain_waits = set()
# SAFETY-91: last cycle's held-back batteries (`_held_back()`).
self._held_was = frozenset()
# SAFETY-93: the batteries placed in their maintenance charge last
# cycle, and those whose charge ended since the loop last ran.
self._charging_was = frozenset()
self._charge_ended = frozenset()
# SAFETY-95: the same for a maintenance drain.
self._draining_was = frozenset()
self._drain_ended = frozenset()
# SAFETY-95: batteries settling after their drain ended; meanwhile
# the loop's error counts every battery at its command.
self._drain_tail = set()
# SAFETY-103: per battery, the range its power can be in since its
# reading last changed (that reading and every command since), and
# the house point held while a drain's tail settles.
self._read_span = {}
self._drain_house = None
# SAFETY-96: last cycle's commandable batteries, this cycle's start
# point per battery where a reseed moved it, and batteries settling
# after a step the loop did not make ({name: since, monotonic}).
self._live_was = None
self._loop_start = {}
self._settle = {}
# Set by amain() once the transport is built, so the status page can
# show what only the transport knows (homewizard_local's unchanged_s).
# (DECISIONS/MAIN-orchestration-archive.md)
self.p1_source = None
# ARB-03: which actuators this site has. The roster is `self.inverters`
# and `units` is its names, DERIVED (a property, not a field): a
# settable tuple beside a roster of one Inverter would be two actuators
# and one writer. At N=1 (6.2) the grid connection and the only
# inverter are ONE actuator and every claim routes to the site; a
# second entry makes per-inverter claims stop being site claims (the
# ALLOC-01 seam). DECISIONS/MAIN-orchestration.md.
# live state
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
# SAFETY-10: what the decision path reached on the last shadow cycle,
# in W, or None. Kept apart from `self.target` (STANDING ON THE WIRE:
# held at `shadow.WIRE_W`, re-seeded each shadow cycle): different
# facts, and folding them would show an operator a number nothing is
# doing.
self.shadow_would_w = None
# SAFETY-47: was the number above SIZED FROM THE BILLED-QUARTER METER
# on the cycle it was recorded? Recorded where the decision was taken,
# not re-derived at render (the page runs on its own task). False, not
# None, before the first shadow cycle: it is a claim about a number.
self.shadow_sized_from_meter = False
# Edge state for the action log. `_shadow_was` is None until the first
# cycle so an install that comes up IN shadow mode says so; coming up
# LIVE is silent (`_shadow_settle()`).
self._shadow_was = None
self._shadow_winner = None
self.target = 0.0
self.sat_count = 0
self.i_w = 0.0 # the loop's integrator, carried between cycles
# SAFETY-52: the direction the battery refuses (+1 / -1 / 0), carried
# like `i_w`, and when the refusal (or its last probe) started.
self.batt_refused = 0
self.batt_refused_at = None
self._now_mono = None
self._refusal_auto = False
# TEL-10: what the last cycle's loop did, for publication; seeded to
# the values the "stopped or blind" branch of `cycle()` writes.
self.loop_i_w = 0.0
self.loop_i_lead = 0.0
self.loop_reason = "not-running"
self.loop_w = None # what the loop asked for last cycle, or None
# TEL-11: the site's PV production is INSTRUMENTATION and nothing reads
# it: not `inputs_ok`, the allocator, the arbiter, a claim or a
# watchdog. Wiring it in would change behaviour and destroy the
# measurement (`tools/tel11_pv_trace.py`). `None` is a reading that
# does not exist and is seeded as one (`app/pv.py`).
self.pv_w = None
# Why there is no reading, in words the page prints, or on a good read
# the UNIT the value came in. Never empty.
self.pv_why = ("not configured" if not self.pv_roster
else "not read yet")
# TEL-12: every configured entity's own answer `(entity, watts, why)`
# in roster order, so `checks()` can name the missing sensor. Empty
# before the first read.
self.pv_parts = []
# Did the grid-following loop win the LAST arbitration? SAFETY-02 asks
# it about the command on the wire, so it is recorded at arbitration.
# Starts False.
self.following = False
self.reason = "starting"
self.why_words = "Starting"
# UX-11: has this meter episode's WARNING been logged? See read_inputs.
self._meter_quiet_logged = False
# ...when the last WARNING was, and whether this episode logged one.
self._meter_quiet_warned = None
self._meter_quiet_loud = False
# ALLOC-01: the allocation record from the last resolution, or None.
# REPLACED every cycle, never merged (a record from a tick whose
# envelopes moved is a cached envelope). SAFETY-28 gates the write path
# on it and DEV-02 reads `shares` off it.
self.allocation = None
# SAFETY-28: the tick the record above belongs to, incremented ONCE PER
# RESOLUTION, not per loop iteration. A new resolution makes the
# previous record stale at once (no replay into a later tick). A cycle
# with NO resolution does not bump it: the "inputs missing, not yet
# stale" hold returns before the allocator so one missed poll does not
# zero the inverter, and the heartbeat re-affirms `self.target` while
# it does; bumping would make that heartbeat unauthorised and drop the
# site to 0 W. The record is SUPERSEDED, never expired.
# DECISIONS/MAIN-orchestration.md.
self.alloc_tick = 0
# What the last cycle's allocator dropped, so the log line is
# edge-triggered. Starts empty.
self._dropped_last = ()
# OPT-09: the last cycle's `unmet_w`, so its line is edge-triggered (a
# second-by-second line would flush the 40-row action log). 0 is "every
# watt placed", the boot state.
self._unmet_last = 0
# The site measurement, the only one left on this object: `soc` and
# `batt` live on each `Inverter`; `grid` is P1, one meter, one
# connection.
self.grid = None
self.peak_fc = None
# SAFETY-118: the unit fault last logged for the peak forecast (None =
# no fault), so the warning comes once and again only if it changes.
self._peak_fc_fault = None
self.cheap = False
self._price_gated = False # MAINT-13: is a price gate set up
# PRICE-01: which step of the price chain the prices come from (logged
# once per change), the last published series (so a gap can say since
# when), and the gap itself: True while a refresh has finished and no
# published price covers now. Notified once per gap, taken down when it
# ends.
self.price_watch = PriceWatch()
self._last_published = None
self.price_gap = False
self.price_gap_since = None
self._price_gap_announced = False
self._fixed_note_said = False
self._gated_key = None # MAINT-13: (options, hour) of the cache
# TARIFF-03: one line of prose for the status page saying which tariff
# window we are in and why it is or is not cheap.
self.tariff_words = "No fixed tariff set"
# OPT-04: the tariff plan the BACKGROUND task last resolved, and when.
# cycle() reads these and never awaits a fetch (TARIFF_REFRESH_S).
# `None` means no refresh has completed: not "no prices" and certainly
# not "free".
self.tariff_plan = None
self.tariff_at = None
self.arb = None
# TARIFF-11: WHEN the standing arbitrage decision was taken, or None.
# `self.arb is None` does NOT mean "trading is off": it also means
# cycle() returned before the step, so `self.arb` is None (startup) or
# the PREVIOUS cycle's decision (input hold). See `arbitrage_row()`.
self.arb_at = None
# SAFETY-45/47: the billed-quarter charging headroom the arbitrage step
# was handed, or None. CARRIED, not recomputed for
# `arbitrage.sized_from_meter()`: `self.peak_guard` is a different
# cycle's by the time the page renders.
self.arb_headroom_w = None
# SAFETY-125: a grid charge sized to the quarter's room this cycle.
self.room_charge = False
# UX-13 P1: today's prices as the Home chart has seen them. Display
# only; nothing reads it back into a decision.
self.home_prices = homeview.DayPrices()
# UX-54: the site's measured solar per hour, for the Forecast tab.
# In memory, display only; nothing reads it back into a decision.
self.solar_today = SolarToday()
# TARIFF-11: when the most recent cycle() STARTED. `arb_at < cycle_at`
# is the derived statement "the latest cycle did not reach the
# arbitrage step".
self.cycle_at = None
self._arb_action = None
self.inputs_bad_since = None
# SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged,
# note, over_rating)` (SAFETY-40 splits the last out). Site-level: the
# integrator that wound is the one SITE integrator; a clamp bit a value
# written to ONE device (`clamp_last` is on the Inverter).
self.loop_clamped: tuple[bool, str, bool] = (False, "", False)
# UX-08: the numbers of a loop-side over-rating, for the notification.
self.loop_clamp_facts: tuple = ()
# SAFETY-85: this cycle's loop Decision, each battery's recent standing
# commands, and how many cycles in a row a battery set by another owner
# gave less than asked.
self._loop_decision = None
self._asked_hist: dict = {}
self._short_run = 0
# SAFETY-119: did another owner set the wire last cycle; the window
# after a hand-back (monotonic s, loop cycles in a row); monotonic s of
# the bound cycles hidden in the last ROLLING_S (a stop does not clear
# them).
self._wire_lost = False
self._handback = None
self._hidden = ()
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
# helpers; SAFETY-52: a battery that refuses is asked for 0 W
def _refusal_probe(self) -> bool:
"""True while a refused direction may try `PROBE_W` (every 15 min)."""
if (not self.batt_refused or self.batt_refused_at is None
or self._now_mono is None):
return False
held = self._now_mono - self.batt_refused_at
if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S:
self.batt_refused_at = self._now_mono
return False
return held >= _ctl.PROBE_EVERY_S
def _refusal_start(self, sign: int, evidence: str) -> None:
self.batt_refused = sign
self.batt_refused_at = self._now_mono
self.log_event(f"BATTERY REFUSES {evidence}",
hint=messages.hint("battery_refuses"))
_LOG.info("The battery refuses to give power, so the add-on asks 0 W "
"and tries %.0f W every %.0f minutes: BATTERY_REFUSES.",
_ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60)
_LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence)
def _refusal_end(self, why: str) -> None:
if not self.batt_refused:
return
self.batt_refused, self.batt_refused_at = 0, None
self.log_event(f"BATTERY REFUSES ENDED: {why}",
hint=messages.hint("battery_gives"))
_LOG.info("The battery refusal ends, control asks for power again: %s.",
why)
def _refusal_update(self, decision, asked_w, batt_w, probe,
rejudged: int = 0) -> None:
"""Follow `compute()`'s carried refused direction, and say so.
`rejudged`: the refusal held while another writer had the wire, so
`compute()` judged it afresh against that writer's ask (SAFETY-107)."""
now = decision.refused or 0
if rejudged:
if now == rejudged:
# Still refused against the ask on the wire: the same episode.
# The probe clock restarts: a probe is the loop's ask and is
# not on the wire while another writer is.
self.batt_refused_at = self._now_mono
return
gave = (rejudged * (batt_w - self._site_idle_w())
>= _ctl.PROBE_ANSWER_W)
self._refusal_end("the battery gives power" if gave
else "another writer")
if now and not self.batt_refused:
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
f"gave {batt_w:+.0f} W for "
f"{decision.sat_count} cycles")
elif self.batt_refused and not now:
gave = (self.batt_refused * (batt_w - self._site_idle_w())
>= _ctl.PROBE_ANSWER_W)
self._refusal_end("the probe was answered" if gave and probe
else "the battery gives power" if gave
else "the grid now asks the other direction")
def _refusal_from_stale(self, inv, now_mono) -> None:
"""A stale-reading stop while the loop is frozen on ~0 W is a refusal.
Only at one inverter: with several, one empty battery must not stop
the site (OPT-09 moves its share)."""
t = self.tuning
standing = inv.standing_w()
if len(self.inverters) > 1:
# SAFETY-73: at N>1 the same trip refuses THIS unit only.
if ((self.auto or self.shadow) and not inv.refused
and inv.ref_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held - inv.idle_w)
< _ctl.REFUSED_BELOW_W
and abs(standing) > _ctl.PROBE_W):
self._now_mono = now_mono
self._unit_refusal_start(
inv, 1 if standing > 0 else -1,
f"stale reading {inv.batt_fresh.held:+.0f} W while asked "
f"{standing:+.0f} W")
return
if (len(self.inverters) == 1 and (self.auto or self.shadow)
and not self.batt_refused
and self.sat_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held - inv.idle_w)
< _ctl.REFUSED_BELOW_W
and abs(standing) > t.saturation_w):
self._now_mono = now_mono
self._refusal_start(1 if standing > 0 else -1,
f"stale reading {inv.batt_fresh.held:+.0f} W "
f"while asked {standing:+.0f} W")
# SAFETY-73: SAFETY-52's decisions per inverter at N>1: a unit giving <
# REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in that
# direction by a P_SAFETY unit limit, so the allocator hands its share on;
# probed at PROBE_W every PROBE_EVERY_S, one unit at a time. The ask is
# PROBE_W, not `saturation_w`: a unit's share is often small and SAFETY-43
# would name the empty battery as not delivering.
# DECISIONS/SAFETY-73-refusal-per-inverter.md.
def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None:
inv.refused, inv.refused_at = sign, self._now_mono
inv.probe_from, inv.ref_count = None, 0
self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}",
hint=messages.hint("unit_refuses", unit=inv.name))
_LOG.info("%s: the battery refuses power, so it is asked 0 W and the "
"other inverters take over: BATTERY_REFUSES.", inv.name)
_LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence)
# Only a refusal to GIVE: a full battery that stops taking charge is
# above its cut-out level by definition.
if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None
and float(inv.soc) > float(inv.bms_soc_floor)):
# Owner decision 3: a refusal above the configured cut-out level
# may mean that level is set wrong. One line, no safety stop.
_LOG.warning("%s refuses power above its battery cut-out level, "
"check its cut-out level: REFUSES_ABOVE_CUT_OUT.",
inv.name)
_LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level "
"%.0f %%, cut-out level %.0f %%.", inv.name,
float(inv.soc), float(inv.bms_soc_floor))
def _unit_refusal_end(self, inv, why: str) -> None:
if not inv.refused:
return
inv.refused, inv.refused_at, inv.probe_from = 0, None, None
inv.ref_count = 0
self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}",
hint=messages.hint("unit_gives", unit=inv.name))
_LOG.info("%s: the battery refusal ends, it is asked for power again: "
"%s.", inv.name, why)
# The site target may stand above what was placed (the refused share
# was unmet). Restart it from the watts on the wire, or the reopened
# unit gets the parked remainder at once (phase A: -1059 W).
placed = float(sum(i.standing_w() for i in self.inverters))
self.target = self.i_w = placed
self.sat_count = 0
def _unit_probe(self, inv) -> bool:
"""True while `inv` may try PROBE_W. One unit probes at a time."""
now = self._now_mono
if not inv.refused or inv.refused_at is None or now is None:
return False
if inv.probe_from is not None:
if now - inv.probe_from < _ctl.PROBE_S:
return True
inv.probe_from, inv.refused_at = None, now
return False
if now - inv.refused_at < _ctl.PROBE_EVERY_S or any(
i.probe_from is not None for i in self.inverters):
return False
inv.probe_from = now
return True
def _unit_refusals(self) -> None:
"""Engage and end each unit's refusal from its own reading."""
for inv in self.inverters:
b = inv.batt
if inv.refused:
# SAFETY-97: given and taken are counted from the idle reading.
if (b is not None and inv.refused * (b - inv.idle_w)
>= _ctl.PROBE_ANSWER_W):
self._unit_refusal_end(
inv, "the probe was answered"
if inv.probe_from is not None
else "the battery gives power")
continue
# The other direction: the battery takes PROBE_ANSWER_W or more
# the other way for SETTLE_CYCLES, counted on its own reading
# so one overshoot through zero does not hand its share back.
inv.ref_count = (inv.ref_count + 1 if b is not None and
-inv.refused * (b - inv.idle_w)
>= _ctl.PROBE_ANSWER_W else 0)
if inv.ref_count >= _ctl.SETTLE_CYCLES:
self._unit_refusal_end(
inv, "the battery takes power the other way")
continue
if inv.rejected_since is not None:
# SAFETY-96: its writes are refused, so its board may have gone
# to 0 W on its own; that is no refusal by the battery. The
# count is kept, as across a gap.
continue
sign = self._gave_nothing(inv)
if sign:
self._unit_refusal_start(
inv, sign,
f"asked {inv.standing_w():+.0f} W, the battery gave "
f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles")
def _gave_nothing(self, inv) -> int:
"""The refusal evidence, one cycle of it: the sign `inv` has given
under REFUSED_BELOW_W while asked over PROBE_W, one way, for
SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1;
SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the
band is centred on the unit's idle reading."""
b = inv.batt
if b is None:
return 0 # a gap keeps the count: the gate's trip seeds
standing = inv.standing_w()
sign = 1 if standing > 0 else -1
# One direction throughout: a command that swaps sign every cycle reads
# ~0 through the plant's lag, which is a battery following, not
# refusing (TEST-19).
if (abs(standing) > _ctl.PROBE_W
and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W):
# SAFETY-94: a reading still moving towards the ask is a battery
# catching up through its lag: start again.
if (sign != inv.ref_sign or not inv.ref_count
or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W):
inv.ref_count, inv.ref_since, inv.ref_from = (
0, self._now_mono, b)
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
else:
inv.ref_count = 0
inv.ref_sign = sign
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
inv.ref_since is None or self._now_mono is None
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
return sign
return 0
def _site_idle_w(self) -> float:
"""SAFETY-97: the site reading at idle, the sum of the units'."""
return float(sum(inv.idle_w for inv in self.inverters))
def _site_refused(self):
"""Owner decision 4: every unit refuses the same way, so the site law
holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law."""
signs = {inv.refused for inv in self.inverters}
return signs.pop() if len(signs) == 1 and 0 not in signs else None
def log_event(self, msg: str, hint: str | None = None) -> None:
"""`msg` goes on the action log, the add-on's own technical record.
`hint` is `messages.hint()`'s plain words for the same event, and is
what the page shows under the buttons (UX-08)."""
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.events.insert(0, f"{stamp} {msg}")
del self.events[40:]
if hint is not None:
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
def _grade_store_migration(self, store) -> None:
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
Graded, not performed: a latch that silently fails to move is a
DISARMED SAFETY GUARD on an install that believes it is protected
(state file says `sign_tripped: true`, banner green, controller resumes
into the fault). The pre-DEV-02 flat value is compared with what the
roster came up with; a disagreement re-arms the latch by hand from the
flat value and writes the store. The fail-safe direction for a latch is
ON.
It runs on EVERY start, not only the migrating one: gating on `rep.ran`
swallowed store.py's rule-2 round trip (upgrade, DOWNGRADE with a trip
in the window, upgrade again: the namespaced key says untripped, the
flat key says tripped; performed on this site 2026-09-02). The gate
could go because `handle_action()` now clears the flat keys with the
namespaced ones and the trip mirrors them, so FLAT TRIPPED AND NO UNIT
TRIPPED means a downgrade-window trip or a lost migration.
DECISIONS/MAIN-orchestration.md."""
rep = self.store_migration
if rep.ran and not rep.ok:
_LOG.error("The saved safety stops did not copy over cleanly, so "
"they are read again from the older saved state: %s.",
rep.explain())
data = getattr(store, "data", {})
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
return
# The question is "is any namespaced latch set THAT A MACHINE READS"
# (SAFETY-44, second half), asked of the STORE's keys, not the roster's
# objects: re-arming A off a flat key that B's own trip mirrored would
# latch a healthy inverter on every restart (DEV-02's defect).
# Three cases: on the roster, a machine reads it, stop. Off-roster and
# named `UNIT_A`, the broken migration's fingerprint (the only name it
# wrote; `roster_names()` never hands out `inverter-a` otherwise): the
# flat latch misdirected, repair onto `inverters[0]`. Off-roster, other
# name: a real unit out of today's config; stop, unless a stranded
# `UNIT_A` latch is also present. Three variables, not one shared flag
# (one key defeated the first fix; MUTATION.md habit 4, instance 17).
# This guard is the ONLY repair for sites upgraded before the fix, as
# `migrate_unit_keys()` returns at the version check.
# Residual: a first unit really NAMED `inverter-a`, latched, then
# removed from the roster is re-armed on `inverters[0]` (a healthy unit
# held at 0 W; the conservative direction). The reason shown is the
# flat mirror's, which carries whatever tripped LAST, so on a
# multi-unit site one inverter's evidence may print under another's
# name. DECISIONS/MAIN-orchestration.md.
on_roster = {inv.name for inv in self.inverters}
stranded, on_roster_latched, other_off_roster = [], False, False
for k, v in data.items():
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
continue
if not bool(v):
continue
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1:
# split through the writer's own spelling.
unit = k.split("/")[1]
if unit in on_roster:
on_roster_latched = True
elif unit == UNIT_A:
stranded.append(k)
else:
other_off_roster = True
if on_roster_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
return
if other_off_roster and not stranded:
# A removed unit's own latch and no misdirected one: the flat key
# is that unit's mirror, so re-arming `inverters[0]` from it would
# latch a healthy pack.
return
if stranded:
_LOG.error(
"A saved safety stop belongs to %s, which is not in the "
"inverter list %s, so it is set again on the first inverter.",
", ".join(sorted(stranded)), sorted(on_roster))
first = self.inverters[0]
# The one state this method exists for.
reason = str(data.get("sign_reason", "") or "")
first.sign_wd.tripped = True
first.sign_wd.reason = reason or (
"SIGN_DISAGREEMENT: latched before this version; the reason was "
"not carried across the per-inverter store migration")
first.store_set("sign_reason", first.sign_wd.reason)
first.store_set("sign_tripped", True)
_LOG.error(
"%s is set to 0 W again because an older saved safety stop was "
"found, so find out why before you start control: %s.",
first.name, messages.reason_code(first.sign_wd.reason))
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
_LOG.debug(
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
"sign-disagreement failsafe is latched and no inverter came up "
"latched. Either the per-inverter migration lost it, or an older "
"add-on wrote it during a rollback. Both would be a disarmed "
"guard, so the latch is re-armed on %s.", first.name)
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
+ first.sign_wd.reason,
hint=messages.hint("stop_restored", unit=first.name))
# the roster, and the N=1 views of it
@property
def units(self) -> tuple:
"""The actuator names, DERIVED from the roster. Never assignable (see
ARB-03 in `__init__`)."""
return tuple(inv.name for inv in self.inverters)
@property
def maint_index(self) -> int:
"""Which roster slot holds the site's ONE maintenance slot: the pack
mid-cycle if any, otherwise the first (always 0 at a roster of one)."""
for i, m in enumerate(self.maints):
if m.phase in POWER_MOVING:
return i
return 0
@property
def maint(self):
"""The `Maintenance` the site's one maintenance slot belongs to
(MAINT-05): a VIEW over `self.maints` (one FSM per roster entry).
Site-level surfaces (`phase` MQTT value, banner, start/abort
buttons) want the one that is recalibrating;
`maintenance.arbitrate()` guarantees at most one is out of IDLE.
None active gives `maints[0]`, as at a roster of one on every tick
(AC 6, test_maintenance.py part 9)."""
return self.maints[self.maint_index]
@property
def maint_unit(self):
"""The pack the site's one maintenance slot is currently on: whichever
holds the slot, by `maint_index`, not roster order (MAINT-05 removed
the flat `inverters[0]`). validate.py rule 6 moved with it: it
grades `maintenance_charge_w` against the weakest cap on the roster,
like rule 5."""
return self.inverters[self.maint_index]
def _maint_button(self, now):
"""The pack an operator's one maintenance button acts on: the pack
holding the slot, so "abort" stops the running cycle and "start"
restarts it; when all are idle, the longest-waiting (`arbitrate()`
clause 2), so forced and scheduled starts choose the same pack."""
if self.maints[self.maint_index].phase in POWER_MOVING:
return self.maints[self.maint_index]
return max(enumerate(self.maints),
key=lambda p: (p[1].waited_days(now), -p[0]))[1]
def inverter(self, name: str):
"""The roster entry called `name`, or None."""
for inv in self.inverters:
if inv.name == name:
return inv
return None
def _can_write(self, name: str) -> bool:
"""Is there a roster entry called `name` WITH a setpoint entity?"""
inv = self.inverter(name)
return inv is not None and inv.has_writer()
def _only(self):
"""The sole inverter, or an error naming why the caller cannot have
one. 6.2's identity (one inverter means site and unit are the same
thing) is false at N>=2, so every N=1 view refuses loudly instead of
answering for "the first one"."""
if len(self.inverters) != 1:
raise AttributeError(
f"this site has {len(self.inverters)} inverters, so there is no "
f"single per-device value to read off the Controller. §6.2's "
f"identity holds only at N=1. Read it off the roster entry: "
f"`for inv in controller.inverters: inv.<attr>`")
return self.inverters[0]
# Compatibility views, as properties not fields: the scalars removed by
# ARB-03/DEV-02 survive as read/write views of the SOLE inverter so an N=1
# install and every replay stay byte-identical (AC 8), and they REFUSE at
# N>=2 so a consumer not yet made per-instance fails on the line that reads
# it. NOTHING INSIDE THIS FILE MAY READ THEM: test_inverter.py scans this
# module's AST and goes red on a `self.<view>` inside any `Controller`
# method.
@property
def unit(self) -> str:
return self._only().name
@property
def soc(self):
return self._only().soc
@soc.setter
def soc(self, value):
self._only().soc = value
@property
def batt(self):
return self._only().batt
@batt.setter
def batt(self, value):
self._only().batt = value
@property
def dev_min(self):
return self._only().dev_min
@dev_min.setter
def dev_min(self, value):
self._only().dev_min = value
@property
def dev_max(self):
return self._only().dev_max
@dev_max.setter
def dev_max(self, value):
self._only().dev_max = value
@property
def last_write(self):
return self._only().last_write
@last_write.setter
def last_write(self, value):
self._only().last_write = value
@property
def last_write_at(self):
return self._only().last_write_at
@property
def last_write_ok(self):
return self._only().last_write_ok
@property
def clamp_last(self):
return self._only().clamp_last
@property
def sign_wd(self):
return self._only().sign_wd
# SAFETY-30: which comparison each sign detector is making
def _arm_sign_watchdogs(self) -> None:
"""Tell every sign detector which of SAFETY-30's two questions it asks.
A pure function of `len(self.inverters)` and the only place the
roster decides anything about this check (one method, not a
conditional at two call sites).
At ONE inverter (6.2, AC 8) the unit's detector is armed against THE
METER with the shipped pair and the site detector is not armed: the
same comparison, thresholds, reason string, keys and `failsafe` claim
as before. At TWO OR MORE each unit is armed against ITS OWN measured
power (`Inverter.batt`, register 0x0518) under the per-unit pair, and
the site detector against the meter under the site pair.
Called again at the top of EVERY `cycle()`: fixtures append an
`Inverter` after construction, and a detector frozen at construction
would be graded against the meter at a roster of two. `arm()` touches
no timer or latch, so repeating it is safe."""
one = len(self.inverters) <= 1
for inv in self.inverters:
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
AGAINST_METER if one else AGAINST_OWN_POWER)
self.site_sign_wd.arm(self.site_sign_cfg, AGAINST_METER)
# The site aggregates, each named for the question it answers. Each picks a
# DIFFERENT reduction (the wrong one drives a pack below its own floor);
# the reason is written at each definition.
@property
def site_max_w(self) -> float:
"""The supervised bound on the SITE target: what the roster can
deliver. SUM: each unit's own `supervised` claim still binds its
share, and bounding the site loop at ONE unit's rating would waste
the second inverter. `max_w` exactly at N=1."""
return float(sum(inv.max_w for inv in self.inverters))
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
"""OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED:
what the site can push AND STILL HAVE SOMEWHERE TO PUT (a roster of
two used to size at the sum of ratings, and unplaceable watts came
back as `unmet_w` for the integrator to chase).
The bounds are arbitrage's own (`arbitrage_soc_floor` / `_ceiling`),
NOT the BMS cut-out: sizing a trade is the trade's business; the
`packshare.envelope()` pack claim uses the physical cut-out
(SAFETY-25). Different questions, different numbers. It sizes the ask,
it does not route it: weights are a preference, so a pack between its
arbitrage floor and its cut-out can still take part of a sell (a card
of its own). A pack whose SoC did not read counts on neither side.
Neither side may be zero on a trading tick (`arbitrage.decide()`
refuses `IDLE_NO_SIZE`: a 0 W claim is a park order).
DECISIONS/MAIN-orchestration.md."""
# The reduction lives in `packshare`; `test_packshare.py` part 9 calls
# the SAME function (a transcription in a check is a copy that drifts).
return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct)
@property
def site_rating_w(self) -> float:
"""The largest magnitude the SITE can be commanded, in W: SUM of each
unit's own `min(max_w, |dev_min|, |dev_max|)` (the peak guard's
`achievable_w` and the freeze detector's rail). Summing the per-unit
conjunction makes B's rating bind B (AC 4). An unread device range
contributes `max_w`."""
return float(sum(inv.rating_w() for inv in self.inverters))
def peak_aim_w(self) -> float:
"""SAFETY-79. `peak_cap_w` less `peak_margin_w`: what the forecast
checks aim at. Reports and the bill use the real cap."""
return float(self.o.get("peak_cap_w", 3500)) - self.peak_margin_w
def site_dev_bound(self, which: str):
"""The roster's summed device floor / ceiling, or None if unknown.
`freeze.looks_frozen()` and `rail_unreachable()` SKIP a bound handed
None, so a bound is summed only when EVERY unit has answered: one
silent unit makes it unknown, not smaller."""
vals = [getattr(inv, which) for inv in self.inverters]
if not vals or any(v is None for v in vals):
return None
return float(sum(float(v) for v in vals))
@property
def site_batt_w(self):
"""SUM. The battery power the SITE loop regulates against. Opposite to
the rest here (ALLOC-01 review): `compute()`'s anti-windup test
`abs(prev_w - actual_w) > saturation_w` compares the SITE target, so
one unit's power at N=2 trips the saturation freeze permanently (up
to 510 W of split-dependence at gain 0.2 on the two-master rig; with
the sum every split is bit-identical, test_alloc.py part 8)."""
vals = [inv.batt for inv in self.inverters]
if not vals or any(v is None for v in vals):
return None
return float(sum(float(v) for v in vals))
@property
def site_circulating_w(self):
"""SAFETY-31. `sum(|B_i|) - |sum(B_i)|` over MEASURED per-unit power:
`inv.batt`, NOT `self.allocation`. The allocation record already has
sign-coherence (`REFUSE_SIGN`), so a record-derived sensor reads
zero by construction and can only confirm the allocator agrees with
itself; every fault this card covers is a divergence between command
and hardware (TEST-19 rig: one unit wedged, record `ok` at -1250 W
each, record-derived 0 W, this 2000 W; HW-01's 6 h 25 m). None when
any unit has not answered, never zero (circulation.py): zero would
report health from an absence. 0.0 at a roster of one (AC 5)."""
return circulating_w([inv.batt for inv in self.inverters])
@property
def pack_floor_soc(self):
"""MINIMUM. The SoC a discharge PERMISSION is graded against: the
EMPTIEST pack (an average lets one be driven below its floor). None
if any unit did not answer (`peakguard.decide()` reads None as
floor-limited). Not the right reduction for a charge ceiling (the
FULLEST pack); nothing files a per-pack ceiling yet
(CFG-04/SPIKE-04)."""
vals = [inv.soc for inv in self.inverters]
if not vals or any(v is None for v in vals):
return None
return float(min(float(v) for v in vals))
@property
def packs(self) -> list:
"""SAFETY-32. Each pack as its own THREE numbers in roster order,
beside `pack_floor_soc` (the site's one verdict, the emptiest pack).
EVERY unit, live or not, as `inputs_ok` does."""
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
for inv in self.inverters]
@property
def pack_energy(self) -> list:
"""OPT-09. Each pack as the FOUR numbers a split needs; the fourth is
`capacity_kwh`, which is why this is not `packs`. EVERY unit, live
or not, for `packs`'s reason (the allocator drops dead ones itself)."""
return [pack_energy_of(inv) for inv in self.inverters]
def pack_weights(self, target_w):
"""OPT-09. The comfort weights for a site target of `target_w`. A
method handed to `resolve()`, not a dict computed before it (energy
headroom depends on the target), and read fresh on every call, never
cached."""
base = packshare.weights(self.pack_energy, target_w)
if not self.modes_on:
return base
# OPT-11: the solar order. While the site charges, Leftover solar
# batteries weigh 0, so Solar first fill first (comfort, like the rest
# here; permission is the mode claim).
w = packmode.charge_weights(base, self.pack_modes(), target_w)
# P3: in a price run, the batteries whose run it is carry it first.
tick = self.mode_tick
return w if tick is None else modetrade.weights(tick.roles, w,
target_w)
def pack_mode(self, inv) -> "packmode.PackMode":
"""OPT-11. This battery's on/off switch and mode, read from the store."""
return packmode.read(getattr(self.store, "data", None) or {}, inv.name)
def pack_modes(self) -> dict:
return {inv.name: self.pack_mode(inv) for inv in self.inverters}
def loop_bound(self, claims):
"""OPT-11. `(lo, hi)`: what the batteries may place if the loop
decides: the sum over live units of each unit's hard limits with its
mode applied (`allocator.loop_bound()`), from THIS cycle's unit
claims. None when no mode is filed (maintenance) or the range leaves
out 0 W (a fault `resolve()` reports)."""
if len(self.inverters) <= 1:
modes = [c for c in claims if c.source == "mode"]
if not modes:
return None
lo = max(c.lo for c in modes)
hi = min(c.hi for c in modes)
return (lo, hi) if lo <= 0.0 <= hi else None
bounds = {u: pre_resolve([c for c in claims if c.actuator == u])
for u in self.units}
return allocator.loop_bound(bounds, live=self.live_units())
# --- OPT-11 P3: price runs, per battery -------------------------------
def _mode_packs(self, maint_results=None) -> list:
"""Each battery as `modetrade.Pack`, in roster order."""
live = set(self.live_units())
out = []
for i, inv in enumerate(self.inverters):
pm = self.pack_mode(inv)
idle = (maint_results is None
or maint_results[i].phase == IDLE)
refused = (inv.refused if len(self.inverters) > 1
else self.batt_refused) or 0
# OPT-15: a charge refusal's probe (SAFETY-73, N>1) as it stood
# when last decided; its pin is filed after this, in cycle().
probing = (len(self.inverters) > 1 and refused < 0
and inv.probe_from is not None)
out.append(modetrade.Pack(
inv.name, pm.use, pm.mode, pm.sell and pm.can_sell,
float(inv.max_w), inv.soc, inv.capacity_kwh,
live=inv.name in live, idle=idle, refused=int(refused),
probing=probing))
return out
def _wx_track(self, now) -> None:
"""TARIFF-24, once per cycle, before the plan: with a forecast read,
remember when the run now under way (cheap, give or sell) ends."""
if self.solar_fc is None:
return
rp = self.mode_runs
ends = []
if rp is not None and rp.on:
ends = [r.end for r in (rp.cheap, rp.sell)
if r is not None and r.holds(now)]
g = rp.give_at(now)
if g is not None:
ends.append(g.end)
self._wx_free_to = max(ends) if ends else None
def weather_held(self, now) -> bool:
"""TARIFF-24: no forecast now, but a run planned with one is under way:
it keeps its gap to its end. The wider gap applies from the next
run. (DECISIONS/MAIN-orchestration-archive.md)"""
return self.solar_fc is None and (
(self._wx_free_to is not None and now < self._wx_free_to)
or (self._charge_latch is not None
and now < self._charge_latch[0]))
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
"""The kept run plan, rebuilt at most once per price slot (15 min) or
when the prices or a battery's choice change; a run under way is
kept to its end (`priceruns.keep_started()`).
(DECISIONS/MAIN-orchestration-archive.md)"""
plan = self.fresh_tariff_plan(now)
if plan is None or not plan.has_prices:
self.mode_runs, self._runs_key = None, None
self.day_plan = None
return None
slot = int(now.timestamp() // 900)
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
for p in packs))
held = self.weather_held(now)
if self.fc_entity is not None:
# P4: a forecast arriving or going rebuilds the plan at once (and
# so does the end of a run that held the gap, TARIFF-24).
key += (self.solar_fc is None, held)
if key != self._runs_key:
done_c = self._run_done.get("charge", (None, set()))[1]
done_s = self._run_done.get("sell", (None, set()))[1]
e = modetrade.energy(packs, floor_pct, ceiling_pct, done_c,
done_s)
required = price_required_spread(
arbitrage.threshold_eur_kwh(self.o),
price_uncertainty_of(plan.read.product)
| arbitrage.weather_uncertainty(True, self.solar_fc, held))
ref = self._ref_cheap
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
ref = self._ref_cheap = None
new = priceruns.plan(
plan.read.points, now, required_eur_kwh=required,
charge_kwh=e[0], charge_kw=e[1], sell_kwh=e[2],
sell_kw=e[3],
ref_eur_kwh=None if ref is None else ref.mean_eur_kwh)
if self.fc_entity is not None:
new = self._day_plan(now, packs, floor_pct, ceiling_pct,
new, plan, required, ref, done_c, e)
self.mode_runs = priceruns.keep_started(self.mode_runs, new, now)
self._runs_key = key
runs = self.mode_runs
# The cheapest cheap run under way lately is the price the stored
# energy was bought at; it stays the reference for REF_CHEAP_KEEP
# after it ends, unless a cheaper one comes.
ref = self._ref_cheap
if runs is not None and runs.cheap is not None \
and runs.cheap.holds(now) and (
ref is None or now - ref.end > REF_CHEAP_KEEP
or runs.cheap.mean_eur_kwh <= ref.mean_eur_kwh):
self._ref_cheap = runs.cheap
return runs
def _day_plan(self, now, packs, floor_pct, ceiling_pct, runs, plan,
required, ref, done_c, e):
"""OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made,
its levels held by the hysteresis (`dayplan.hold()`), then the runs
made again with each battery's shortfall as the charge, so the cheap
run is only as long as the shortfall needs.
(DECISIONS/MAIN-orchestration-archive.md)"""
dp = dayplan.plan(
self.solar_fc,
None if self.house_profile is None
else self.house_profile.estimator(),
priceruns.keep_started(self.mode_runs, runs, now), now, packs,
trust_pct=self.solar_trust_pct, floor_pct=floor_pct,
ceiling_pct=ceiling_pct)
self.day_plan = dp
if dp is None:
self._plan_targets = None
return runs
self._plan_targets = dayplan.hold(
self._plan_targets, dp.targets,
{p.name: p.capacity_kwh for p in packs})
e2 = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, (),
targets=self._plan_targets)
return priceruns.plan(
plan.read.points, now, required_eur_kwh=required,
charge_kwh=e2[0], charge_kw=e2[1], sell_kwh=e[2], sell_kw=e[3],
ref_eur_kwh=None if ref is None else ref.mean_eur_kwh)
def plan_targets(self):
"""P4: each battery's planned level, or None (no day plan). Dropped
at once when the forecast goes (stale or missing), except the level
of a night charge that has started (OPT-34): it keeps that level."""
if self.solar_fc is None:
lt = self._charge_latch
if lt is None or datetime.now(timezone.utc) >= lt[0]:
return None
return dict(lt[1])
return self._plan_targets
def _latch_charge(self, now, runs, targets, packs=None) -> None:
"""OPT-34, once per cycle after `modetrade.decide()`.
(DECISIONS/MAIN-orchestration-archive.md)"""
old = lt = self._charge_latch
tick = self.mode_tick
run = None if runs is None else runs.cheap
in_run = run is not None and run.holds(now)
grace = timedelta(seconds=CHARGE_LATCH_GRACE_S)
if lt is not None and now >= lt[0]:
lt = None
if lt is not None and packs is not None:
ok = {p.name for p in packs
if p.use and p.mode in packmode.PLAN_CHARGE_MODES}
lv = {n: v for n, v in lt[1].items() if n in ok}
lt = (lt[0], lv, lt[2]) if lv else None
# A run may carry the latch on only if it starts by the night's bell.
carry = in_run and (lt is None or run.start <= _night_limit(lt[2]))
if in_run and self.solar_fc is not None:
# Rebuilt from the plan: a battery already latched follows its
# level up or down whatever its role this tick (a fresh figure may
# lower it below the SoC), one that left the plan is dropped, and
# a new one comes in only while it charges from the grid.
tg = targets or {}
lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg}
if tick is not None and tick.action == modetrade.CHARGE:
lv.update({n: float(tg[n]) for n, r in tick.roles.items()
if r == modetrade.CHARGE and n in tg})
if not lv:
lt = None
elif lt is None:
lt = (run.end + grace, lv, now)
else:
lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv,
lt[2])
elif lt is not None and carry:
lt = (max(lt[0], run.end + grace), lt[1], lt[2])
if lt != old:
self._charge_latch = lt
self.store.set(CHARGE_LATCH_KEY, None if lt is None else {
"until": lt[0].isoformat(), "levels": lt[1],
"born": lt[2].isoformat()})
def _mode_roles(self, now, maint_results):
"""OPT-11 P3: each battery's role and range this tick.
(DECISIONS/MAIN-orchestration-archive.md)"""
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
packs = self._mode_packs(maint_results)
self._wx_track(now)
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
guard = arbitrage.guard_state(self.peak_guard)
plan = self.fresh_tariff_plan(now)
charge_ok = sell_ok = False
trade_why = "not_covered"
def allowed(active):
return plan.arbitrage(
peak_guard_active=active,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=arbitrage.weather_uncertainty(
True, self.solar_fc, self.weather_held(now)),
now=now).match(
allowed=lambda _v: True, refused=lambda _v: False)
if guard is not None and plan is not None and runs is not None:
# A charge asks the guard (section 9.1 interlock); a sell only
# needs it to have measured the quarter, since discharge is never
# capped by it (a big discharge alone engages it on export).
sell_ok = allowed(False)
charge_ok = sell_ok and allowed(guard)
trade_why = (None if charge_ok else "peak" if sell_ok
else "not_worth")
self.mode_gate = None if charge_ok else trade_why
price_now = None
if plan is not None:
try:
pt = plan.read.match(series=lambda r: r.at(now),
no_data=lambda _r: None)
price_now = (None if pt is None or pt.provisional
else float(pt.eur_per_kwh))
except Absence:
price_now = None
house = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + float(self.site_batt_w))
if self.house_profile is not None and \
self.house_profile.add(now, house):
self.store.set(HOUSE_PROFILE_KEY, self.house_profile.dump())
targets = self.plan_targets()
if targets is None:
done_c = self._run_mark("charge", runs and runs.cheap, packs,
lambda p: p.soc is not None
and p.soc >= ceiling_pct)
else:
# P4: out of the run at its planned level. A level the
# hysteresis moved opens the run to it again (a new mark key).
def full(p):
to = modetrade.charge_to(p, ceiling_pct, targets)
return p.soc is not None and (to is None or p.soc >= to)
done_c = self._run_mark("charge", runs and runs.cheap, packs,
full, tuple(sorted(targets.items())))
done_s = self._run_mark("sell", runs and runs.sell, packs,
lambda p: p.soc is not None
and p.soc <= floor_pct)
quarter = int(now.timestamp() // 900)
was = self.mode_tick is not None and \
self.mode_tick.action == modetrade.CHARGE
self.mode_tick = modetrade.decide(
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
trade_why=trade_why, price_now=price_now,
headroom_w=self.arb_headroom_w, floor_pct=floor_pct,
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s, targets=targets,
draining=any(r.phase == DRAIN for r in maint_results or ()))
self._latch_charge(now, runs, targets, packs)
# A charge the guard or the headroom stopped stays off this period.
# (DECISIONS/MAIN-orchestration-archive.md)
if was and self.mode_tick.action != modetrade.CHARGE:
why = self.mode_tick.why
gap = why == "headroom" and self.arb_headroom_w is None
if why == "peak" or (why == "headroom" and not gap) or (
gap and self._charge_gap_q == quarter):
self._charge_blocked_q = quarter
elif gap:
self._charge_gap_q = quarter
return self.mode_tick
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
"""The batteries that left run `run` (full, or at the floor); they
stay out until it ends, so a level edge does not re-arm them.
`extra` (P4: the planned levels) is part of the run's key."""
key = None if not run else (run.start, run.end)
if key is not None and extra is not None:
key += (extra,)
was_key, done = self._run_done.get(kind, (None, set()))
if key != was_key:
done = set()
if key is not None:
done |= {p.name for p in packs if out(p)}
self._run_done[kind] = (key, done)
return done
def _mode_trade(self, claims, now) -> bool:
"""OPT-11 P3: file the site trade the roles ask for, sized inside the
modes and the hard limits (the loop bound) and, for a charge, the
billed quarter's headroom (SAFETY-45, aim cap - margin, SAFETY-79).
(DECISIONS/MAIN-orchestration-archive.md)"""
tick = self.mode_tick
if tick is None:
return False
if tick.action == modetrade.CHARGE:
# OPT-20: the ask carries PROBE_W for exactly the probes whose pin
# is filed this tick. (DECISIONS/MAIN-orchestration-archive.md)
pins = _ctl.PROBE_W * sum(
1 for c in claims if c.source == "refused"
and c.kind == "set" and c.value < 0)
if pins != tick.probe_w:
tick = self.mode_tick = dataclasses.replace(
tick, ask_w=tick.ask_w + tick.probe_w - pins,
probe_w=pins)
size = modetrade.size(tick, self.loop_bound(claims)
if tick.action else None, self.arb_headroom_w)
runs = self.mode_runs
if tick.action and (size == 0.0 or runs is None):
# Nothing the batteries may place (a refusal, a hard limit): no
# trade, and the roles say so rather than "charging".
back = {modetrade.CHARGE: modetrade.HOLD,
modetrade.REST: modetrade.HOUSE,
modetrade.SELL: modetrade.GIVE}
tick = self.mode_tick = modetrade.Tick(
tick.ranges, {n: back.get(r, r) for n, r in tick.roles.items()},
None, 0.0, "size")
if size == 0.0 or runs is None:
reason = ("battery modes: no price run now" if tick.why is None
else f"battery modes: no trade ({tick.why})")
self.arb = arbitrage.ArbitrageDecision(
action=None, power_w=None, reason=reason,
spread_eur_kwh=None, required_eur_kwh=None,
price_now_eur_kwh=None)
else:
charge = tick.action == modetrade.CHARGE
run = runs.cheap if charge else runs.sell
who = ", ".join(n for n, r in sorted(tick.roles.items())
if r == tick.action)
act = arbitrage.CHARGE if charge else arbitrage.DISCHARGE
self.arb = arbitrage.ArbitrageDecision(
action=act, power_w=size,
reason=(f"{act}: battery modes, "
f"{'cheap' if charge else 'sell'} run "
f"{run.start.isoformat()} to {run.end.isoformat()} "
f"at {run.mean_eur_kwh:.4f} EUR/kWh; "
f"{abs(size):.0f} W on {who}"),
spread_eur_kwh=None, required_eur_kwh=runs.level_eur_kwh,
price_now_eur_kwh=None)
claims.append(arbitrage.claim(self.arb))
if self.arb.action != self._arb_action:
self._arb_action = self.arb.action
self.log_event(self.arb.explain(), hint=messages.hint(
"trade_charge" if self.arb.action == arbitrage.CHARGE else
"trade_give" if self.arb.action == arbitrage.DISCHARGE
else "trade_off"))
return self.arb.action is not None
def _reseed_run_edge(self, claims, now) -> None:
"""OPT-11 P3: a mode trade just ended, so the loop takes over from a
command no loop chose. (DECISIONS/MAIN-orchestration-archive.md)"""
self._edge = None
batt = self.site_batt_w
if batt is None or self.grid is None:
return
want = float(batt) + float(self.grid) - self.tuning.target_grid_w
bound = self.loop_bound(claims)
if bound is not None:
want = max(bound[0], min(bound[1], want))
want = float(round(max(-self.site_max_w, min(self.site_max_w, want))))
self.target = self.i_w = want
if want != float(batt):
self._edge = (want, 1.0 if want > float(batt) else -1.0,
now + timedelta(seconds=EDGE_HOLD_S), float(batt))
def _edge_grid(self, end, now):
"""OPT-15: the grid reading the loop acts on this tick. While a run
edge is held, the target itself (no error, the command stays at E).
(DECISIONS/MAIN-orchestration-archive.md)"""
e = self._edge
if e is None:
return self.grid
held_w, sign, until, last_b = e
tg, db = self.tuning.target_grid_w, self.tuning.deadband_w
b = self.site_batt_w
moving = (b is not None and sign * (float(b) - last_b) >= db
and sign * (held_w - float(b)) >= db
and now < until + timedelta(seconds=EDGE_HOLD_S))
if (end or (now >= until and not moving)
or sign * (float(self.grid) - tg) < db):
self._edge = None
return self.grid
self._edge = (held_w, sign, until, last_b if b is None else float(b))
self.i_w = held_w
return tg
def _charge_track(self, claims, maint_results) -> None:
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
no longer be placed (stale link, refusal). Kept until the loop runs.
(DECISIONS/MAIN-orchestration-archive.md)"""
if len(self.inverters) < 2:
return
live = set(self.live_units())
now = {CHARGE: set(), DRAIN: set()}
for inv, res in zip(self.inverters, maint_results):
u = self.actuator_of("maintenance", inv)
if res.phase in now and u in live and not pre_resolve(
[c for c in claims if c.actuator == u]).contradiction \
and not (res.phase == DRAIN and (inv.refused or 0) > 0):
now[res.phase].add(inv.name)
self._charge_ended |= self._charging_was - now[CHARGE]
self._charging_was = frozenset(now[CHARGE])
self._drain_ended |= self._draining_was - now[DRAIN]
self._draining_was = frozenset(now[DRAIN])
def _reseed_charge_end(self, maint_results) -> None:
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
starts from what the others stood at plus that battery's new command
(its HOLD pin, or its last discharge if it is free; a charge stops).
(DECISIONS/MAIN-orchestration-archive.md)"""
ended, self._charge_ended = self._charge_ended, frozenset()
if len(self.inverters) < 2 or not ended:
return
for inv, res in zip(self.inverters, maint_results):
if inv.name not in ended:
continue
now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint
else max(float(inv.target), 0.0))
if not inv.is_live(datetime.now(timezone.utc))[0]:
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
self.target += now_w - float(inv.target)
# SAFETY-96: it settles from its charge to that command.
self._loop_start[inv.name] = now_w
self._settle[inv.name] = self._now_mono
self.i_w = self.target
def _reseed_drain_end(self, maint_results, live) -> None:
"""SAFETY-95, N>=2: when a battery's maintenance drain ends, its drain
leaves the loop's start point, as a charge does (SAFETY-93): the
start is what the others stood at plus its new command (its pin,
else 0 W). (DECISIONS/MAIN-orchestration-archive.md)"""
ended, self._drain_ended = self._drain_ended, frozenset()
if len(self.inverters) < 2 or not ended:
return
hi = self.target
for inv, res in zip(self.inverters, maint_results):
if inv.name not in ended:
continue
now_w = (float(res.setpoint_w or 0.0)
if res.owns_setpoint and res.phase != DRAIN
else min(float(inv.target), 0.0))
if inv.name not in live:
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
else:
self._drain_tail.add(inv.name)
self.target += now_w - float(inv.target)
self._loop_start[inv.name] = now_w
self._settle[inv.name] = self._now_mono
lo, hi = sorted((self.target, hi))
# A dropped battery's power is the meter's, like the house's.
here = float(self.grid) - self.tuning.target_grid_w + sum(
float(inv.batt) for inv in self.inverters if inv.name in live)
self.target = self.i_w = min(hi, max(lo, here))
def _drain_grid_w(self, live) -> float:
"""SAFETY-95: the grid the loop regulates.
(DECISIONS/MAIN-orchestration-archive.md)"""
grid = float(self.grid)
self._drain_tail &= set(self._settle) & set(live)
if not self._drain_tail:
self._drain_house = None
return grid
aim = self.tuning.target_grid_w
lo = hi = grid - aim
for inv in self.inverters:
if inv.name not in live:
continue
r = float(inv.batt)
span = self._read_span.get(inv.name)
if (span is None or span[2] != inv.batt_fresh.changed_mono
or span[2] == self._now_mono):
span = (r, r)
# A battery that refuses one way (SAFETY-73) is not heading
# that way, whatever it was asked: that side is its reading.
if (inv.refused or 0) > 0:
span = (min(span[0], r), r)
elif (inv.refused or 0) < 0:
span = (r, max(span[1], r))
lo, hi = lo + span[0], hi + span[1]
held = (float(self.target) if self._drain_house is None
else self._drain_house)
self._drain_house = min(hi, max(lo, held))
return self._drain_house - float(self.target) + aim
def _track_reads(self) -> None:
"""SAFETY-103, N>=2: per battery, the range its power can be in now:
its last changed reading and every command on its wire since (a
lagging battery moves from one toward the others, never beyond)."""
if len(self.inverters) < 2:
return
for inv in self.inverters:
if inv.batt is None:
continue
cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono
lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None))
if was is None or since != was:
lo = hi = float(inv.batt)
self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since)
def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale link)
leaves the loop's start point, so the others take over at one slew
step a cycle instead of taking its share at once.
(DECISIONS/MAIN-orchestration-archive.md)"""
was, self._live_was = self._live_was, frozenset(live)
if len(self.inverters) < 2 or was is None or was == live:
return
for inv in self.inverters:
if inv.name in self._charge_ended | self._drain_ended:
continue # SAFETY-93/95's edge: `_reseed_*_end()`
if inv.name in was and inv.name not in live:
now_w = 0.0
elif inv.name in live and inv.name not in was:
now_w = float(inv.batt)
# The split is made again, so every battery's command steps.
self._settle.update(
{u: self._now_mono for u in live})
else:
continue
self.target += now_w - float(inv.target)
self._loop_start[inv.name] = now_w
self.i_w = self.target
def _held_out_w(self, live) -> float:
"""SAFETY-96, N>=2: what to add to the site sum so that a battery the
loop cannot move right now counts at its command, not at its
reading. (DECISIONS/MAIN-orchestration-archive.md)"""
if len(self.inverters) < 2:
return 0.0
out = 0.0
for inv in self.inverters:
cmd = self._loop_start.get(inv.name, float(inv.target))
since = self._settle.get(inv.name)
age = (None if since is None or self._now_mono is None
else self._now_mono - since)
if since is not None and (
age is None or age >= _ctl.PLANT_SETTLE_DOWN_S
or (age > 0 and abs(float(inv.batt) - cmd)
< self.tuning.deadband_w)):
del self._settle[inv.name]
if (inv.name not in live or inv.rejected_since is not None
or inv.name in self._settle):
out += cmd - float(inv.batt)
return out
def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
(DECISIONS/MAIN-orchestration-archive.md)"""
alloc = resolution.allocation
now = {u for u, _ in getattr(alloc, "mode_faults", ())}
if resolution.mode_fault is not None and self.inverters:
now.add(self.inverters[0].name)
for inv in self.inverters:
if inv.name in now and inv.name not in self._mode_faults:
_LOG.error("Battery mode ignored for %s. Software fault "
"MODE_RANGE.", inv.name)
self.log_event(f"battery mode ignored on {inv.name}, a "
f"software fault", hint=messages.hint(
"mode_fault", unit=inv.name))
self._mode_faults = now
def _pack_action(self, what, unit, value, now) -> dict:
"""OPT-11: switch one battery on or off, set its mode, or (P3) turn its
selling on or off. (DECISIONS/MAIN-orchestration-archive.md)"""
if not self.modes_on:
return {"error": messages.pack_refused("off")}
inv = next((i for i in self.inverters if i.name == unit), None)
if inv is None:
return {"error": messages.pack_refused("unit")}
if what == "pack_use":
if not isinstance(value, bool):
return {"error": messages.pack_refused("value")}
inv.store_set(packmode.USE_KEY, value)
self.log_event(f"{inv.name} switched {'on' if value else 'off'} "
f"by operator",
hint=messages.hint("pack_on" if value
else "pack_off", unit=inv.name))
if not value:
self._maint_stop_off(self.maints[self.inverters.index(inv)],
now)
elif what == "pack_sell":
if not packmode.SELLING_ENABLED:
return {"error": messages.pack_refused("sell_off")}
if not isinstance(value, bool):
return {"error": messages.pack_refused("value")}
inv.store_set(packmode.SELL_KEY, value)
self.log_event(f"{inv.name} selling switched "
f"{'on' if value else 'off'} by operator",
hint=messages.hint("pack_sell", unit=inv.name))
else:
if value not in packmode.MODES:
return {"error": messages.pack_refused("value")}
inv.store_set(packmode.MODE_KEY, value)
self.log_event(f"{inv.name} mode set to {value.replace('_', ' ')} "
f"by operator",
hint=messages.hint("pack_mode", unit=inv.name))
pm = self.pack_mode(inv)
out = {"unit": inv.name, "use": pm.use, "mode": pm.mode}
if what == "pack_sell":
out["sell"] = pm.sell
return out
async def _maint_unit_action(self, what, unit, now) -> dict:
"""MAINT-11: start or abort the maintenance cycle of ONE named battery.
(DECISIONS/MAIN-orchestration-archive.md)"""
inv = next((i for i in self.inverters if i.name == unit), None)
if inv is None:
return self._maint_refusal("unit")
machine = self.maints[self.inverters.index(inv)]
if what == "maint_start":
holder = next((i for i, m in zip(self.inverters, self.maints)
if m.phase in POWER_MOVING), None)
if holder is not None:
return self._maint_refusal("busy", holder.name)
if self.modes_on and not self.pack_mode(inv).use:
return self._maint_refusal("off", inv.name)
for msg in machine.force_start(now):
self.log_event(msg, hint=messages.hint(
"maintenance", phase=machine.phase))
return {"unit": inv.name, "phase": machine.phase}
if machine.phase not in POWER_MOVING:
return self._maint_refusal("idle", inv.name)
for msg in machine.abort(now, "operator"):
self.log_event(msg, hint=messages.hint("maintenance",
phase=machine.phase))
await self.command_site_zero()
return {"unit": inv.name, "phase": machine.phase}
@staticmethod
def _maint_refusal(code, unit="") -> dict:
return {"error": messages.maint_refused(code, unit), "code": code,
"unit": unit}
def mode_values(self) -> dict:
"""OPT-11 P2: each battery's mode and switch, for their HA state
topics. Read from the store, so they are right after a restart. Empty
while battery modes are off."""
if not self.modes_on:
return {}
out = {}
names = [inv.name for inv in self.inverters]
for inv, slug in zip(self.inverters, unit_slugs(names)):
pm = self.pack_mode(inv)
out[unit_state_key(slug, "mode")] = MODE_LABELS.get(pm.mode,
pm.mode)
out[unit_state_key(slug, "use")] = USE_ON if pm.use else USE_OFF
return out
async def mqtt_command(self, topic, payload, retained) -> None:
"""OPT-11 P2: one command from a battery's HA switch or mode list.
(DECISIONS/MAIN-orchestration-archive.md)"""
try:
slug, action, value, why = parse_command(
topic, payload, retained, packmode.MODES)
if why is None:
names = [inv.name for inv in self.inverters]
unit = dict(zip(unit_slugs(names), names)).get(slug)
result = await self.handle_action(action, unit=unit,
value=value)
if not (isinstance(result, dict) and "error" in result):
self.mqtt.publish(self.mode_values())
return
why = result["error"]
else:
why = messages.pack_refused(why)
# Printable only: a topic is sender-chosen, so no log injection.
topic_s = "".join(c if c.isprintable() else "?"
for c in str(topic)[:128])
_LOG.debug("Home Assistant command %s %s refused: %s", topic_s,
repr(payload)[:48], why)
if (topic_s, why) in self._mqtt_refused \
or len(self._mqtt_refused) >= 32:
# Not logged again, but counted: 1, 10, 100... stay visible.
self._mqtt_refused_more += 1
more = self._mqtt_refused_more
if str(more).rstrip("0") == "1":
_LOG.warning("Home Assistant commands still ignored: %d "
"more so far.", more)
return
self._mqtt_refused.add((topic_s, why))
_LOG.warning("Home Assistant command on %s ignored: %s", topic_s,
why)
except Exception: # noqa: BLE001
_LOG.exception("Home Assistant command could not be handled")
def _drain_floor(self, claims, maint_results):
"""SAFETY-78: the site `lo` for a pinned maintenance drain, or None. At
N>=2 the drain pins its pack and the site target is split around the
pin; below the pin a sibling would have to charge (opposite
directions, refused, 0 W, the drain never moves), so the site target
may not go below what the pin places. At N=1 the drain is the site
SET itself: nothing filed. A draining pack with nothing to give adds
no floor (logged once)."""
if len(self.inverters) < 2:
return None
live = set(self.live_units())
floor, waits = 0.0, set()
for inv, res in zip(self.inverters, maint_results):
if res.phase != DRAIN or not res.owns_setpoint \
or not (res.setpoint_w or 0.0) > 0.0:
continue
u = self.actuator_of("maintenance", inv)
b = pre_resolve([c for c in claims if c.actuator == u])
p = (None if u not in live or b.contradiction
or b.pinned_by != "maintenance" else allocator.pin_w(b))
if p is not None and p > 0.0:
floor += p
else:
waits.add(inv.name)
for name in sorted(waits - self._drain_waits):
self.log_event(f"maintenance: {name} cannot give power now, so "
f"its monthly cycle waits",
hint=messages.hint("maint_waits", unit=name))
self._drain_waits = waits
return floor if floor > 0.0 else None
def _peak_on_able(self, decision, maint_results):
"""SAFETY-87: at N>=2, `decision` placed on the batteries that can
still give: live, not refusing, above their own reserve.
(DECISIONS/MAIN-orchestration-archive.md)"""
if len(self.inverters) < 2:
return decision
floor = float(self.o.get(
"peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))
ok, held = peakguard.reserve_split(self.packs, floor)
if not held:
return decision
live = set(self.live_units())
able = [p for p, inv in zip(self.packs, self.inverters)
if p in ok and inv.name in live and not (inv.refused or 0) > 0]
if not able:
return dataclasses.replace(decision, held=held)
drain = sum(min(float(r.setpoint_w), i.rating_w())
for i, r in zip(self.inverters, maint_results)
if i.name in held and r.phase == DRAIN
and r.owns_setpoint and (r.setpoint_w or 0.0) > 0.0)
names = {p.name for p in able}
return peakguard.place_on_able(
decision, held,
able_w=drain + sum(i.rating_w() for i in self.inverters
if i.name in names),
soc=min(float(p.soc) for p in able),
soc_floor=peakguard.binding_soc_floor(able, floor))
def _peak_landing(self, decision):
"""SAFETY-126: `decision` marked `within_aim` while the guard wants
power but the batteries at 0 W would end the quarter within
`ALARM_MISS_W` of the aim (`peakguard.misses_aim()`).
(DECISIONS/MAIN-orchestration-archive.md)"""
batt, prev = self.site_batt_w, self._land_batt_prev
self._land_batt_prev = batt
if not (decision.engaged and decision.blind is None
and (decision.wanted_w or 0.0) > 0.0):
self._peak_needed = False
return decision
if batt is not None and prev is not None:
batt = min(float(batt), float(prev))
self._peak_needed = self._peak_needed or (
self._peak_proj is not None and peakguard.misses_aim(
self._peak_proj, batt_w=batt, target_w=decision.target_w))
return (decision if self._peak_needed
else dataclasses.replace(decision, within_aim=True))
def _peak_reserve(self) -> frozenset:
"""SAFETY-87: the batteries that give nothing for the peak this
cycle: under their reserve while the others carry a peak floor."""
g = self.peak_guard
if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0:
return frozenset()
return frozenset(g.held)
def _held_back(self) -> frozenset:
"""SAFETY-91, N>=2: the batteries at or under their own peak reserve
while another can still give (live, not refusing).
(DECISIONS/MAIN-orchestration-archive.md)"""
if len(self.inverters) < 2:
return frozenset()
floor = float(self.o.get(
"peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))
ok, held = peakguard.reserve_split(self.packs, floor)
held = set(held) | {
p.name for p in ok if p.name in self._held_was
and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0}
live = set(self.live_units())
if not held or not any(
p.name not in held and inv.name in live
and not (inv.refused or 0) > 0
for p, inv in zip(self.packs, self.inverters)):
self._held_was = frozenset()
else:
self._held_was = frozenset(held)
return self._held_was
def _drain_rise(self, claims, maint_results) -> None:
"""SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a
draining battery's pin becomes a floor at its drain rate, so it
rises towards its rating before the held one gives.
(DECISIONS/MAIN-orchestration-archive.md)"""
held = self._held_back()
if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_PEAK for c in claims):
return
live = set(self.live_units())
for inv, res in zip(self.inverters, maint_results):
u = self.actuator_of("maintenance", inv)
if res.phase != DRAIN or not res.owns_setpoint \
or inv.name in held or u not in live:
continue
b = pre_resolve([c for c in claims if c.actuator == u])
p = (None if b.contradiction or b.pinned_by != "maintenance"
else allocator.pin_w(b))
if p is None or not p > 0.0:
continue
for i, c in enumerate(claims):
if c.source == "maintenance" and c.kind == SET \
and c.actuator == u:
claims[i] = Claim.limit("maintenance", P_MAINTENANCE,
lo=p, reason=c.reason,
actuator=u)
def _fill_peak_gaps(self, proj, now, reading):
"""SAFETY-90: `proj` with a meter gap's uncounted seconds counted at
`peakguard.gap_w()` of the imports seen just before and just after it,
and those before this run's first reading likewise at the import seen
first (shown as not measured).
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
self.peak_gap_s = self.peak_unmeasured_s = 0.0
avg = self.p1.averager
since = avg.held_since
if proj is None or proj.block_start is None or since is None:
return proj
if self._unseen is None and self.p1.last is not None:
self._unseen = [proj.block_start, max(0.0, (
since - proj.block_start).total_seconds() - avg.elapsed_s),
since, None]
seen = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + max(0.0, float(self.site_batt_w)))
if proj.block_start != self._gap_block:
self._gap_block, self._gap_w = proj.block_start, None
self._gap_filled_s = self._gap_held_ws = 0.0
cap = float(self.o.get("peak_cap_w", 3500))
ceiling = peakguard.quarter_ceiling_w(cap, reading)[0]
unseen_on = (self._unseen is not None
and self._unseen[0] == proj.block_start)
if unseen_on and self._unseen[3] is None and seen is not None:
# While the add-on was down the batteries were at 0 W, so the
# seconds before its first reading count like a gap, at the
# import seen first; a restored reading's held part is topped up.
self._unseen[3] = peakguard.gap_w(ceiling, seen)
if self._run_pre is not None:
s0, g0 = self._run_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s),
self._unseen[2])
- max(s0, proj.block_start)).total_seconds()
self._unseen_held_ws = max(0.0, h) * max(
0.0, self._unseen[3] - max(0.0, g0))
kw = dict(tail_s=(now - since).total_seconds(),
max_hold_s=avg.max_hold_s,
unseen_s=self._unseen[1] if unseen_on else 0.0,
unseen_w=(self._unseen[3] if unseen_on
and self._unseen[3] is not None else ceiling))
_p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw)
if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S:
self._gap_w = peakguard.gap_w(ceiling, self._gap_w,
self._gap_seen_w, seen)
if self._gap_pre is not None:
# The averager credited the gap's first max_hold_s at the
# reading before it; top that part up to gap_w as well.
s0, g0 = self._gap_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s), since)
- max(s0, proj.block_start)).total_seconds()
self._gap_held_ws += max(0.0, h) * max(
0.0, self._gap_w - max(0.0, g0))
self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s "
f"of this 15-minute period, counted at "
f"{self._gap_w:.0f} W",
hint=messages.hint("peak_gap"))
self._gap_filled_s = max(self._gap_filled_s, gap_s)
if seen is not None:
self._gap_seen_w = seen
if self.grid is not None:
self._gap_pre = (since, float(self.grid))
out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps(
proj, gap_w=self._gap_w if self._gap_w is not None else ceiling,
held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on
else 0.0), **kw)
return out
def _peak_over_drain(self, claims, maint_results) -> None:
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it: the
drain is the site SET at P_MAINTENANCE, above P_PEAK, so the floor
did not bind. Refiled at P_MAINTENANCE where it asks for more;
P_SAFETY still outranks. N>=2: the `drain` floor and
`_drain_over_reserve()`."""
if len(self.inverters) != 1:
self._drain_over_reserve(claims, maint_results)
return
res = maint_results[0]
if res.phase != DRAIN or not res.owns_setpoint:
return
rate = float(res.setpoint_w or 0.0)
for i, c in enumerate(claims):
if c.source == peakguard.SOURCE and c.lo > rate:
claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE)
def _held_pin(self, claims, u):
"""SAFETY-87: what held unit `u` places anyway (its own pin), or None
when that pin charges, so the site is never split both ways."""
b = pre_resolve([c for c in claims if c.actuator == u])
p = None if b.contradiction else allocator.pin_w(b)
return None if p is not None and p < 0.0 else max(p or 0.0, 0.0)
def _drain_over_reserve(self, claims, maint_results) -> None:
"""SAFETY-87, N>=2: while a battery is held at its peak reserve, a
draining battery that may still give is raised from its drain rate
towards its rating for the peak floor, after the others.
(DECISIONS/MAIN-orchestration-archive.md)"""
reserve = self._peak_reserve()
floor = max((c.lo for c in claims if c.source == peakguard.SOURCE),
default=0.0)
if not reserve or not floor > 0.0 or any(
c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_PEAK for c in claims):
return
live = set(self.live_units())
drains, others = [], 0.0
for inv, res in zip(self.inverters, maint_results):
u = self.actuator_of("maintenance", inv)
if u not in live:
continue
if inv.name in reserve:
p = self._held_pin(claims, u)
if p is None:
return
others += p
continue
b = pre_resolve([c for c in claims if c.actuator == u])
if b.contradiction:
continue
p = allocator.pin_w(b)
if p is not None and p < 0.0:
return # a pack pinned to charge: never split both ways
if res.phase == DRAIN and b.pinned_by == "maintenance" \
and p is not None and p > 0.0:
drains.append((u, p, allocator.hi_w(b)))
others += allocator.hi_w(b) if p is None else p
give = floor - others
for u, p, hi in drains:
if give <= 0.0:
break
up = max(p, min(hi, p + give))
give -= up - p
for i, c in enumerate(claims):
if c.source == "maintenance" and c.kind == SET \
and c.actuator == u:
claims[i] = dataclasses.replace(c, value=up)
def _peak_over_hold(self, claims, maint_results):
"""SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). N=1:
refiled at P_MAINTENANCE as for a drain. N>=2: the holding pack's
pin is raised to the floor minus what the others may place, and the
floor is returned as the site `hold` ceiling. A site stop still
decides alone. DECISIONS/SAFETY-86-peak-over-hold.md."""
hold = {self.actuator_of("maintenance", inv)
for inv, res in zip(self.inverters, maint_results)
if res.phase == HOLD and res.owns_setpoint}
peak = [i for i, c in enumerate(claims)
if c.source == peakguard.SOURCE and c.lo > 0.0]
if not hold or not peak:
return
if len(self.inverters) == 1:
for i in peak:
claims[i] = dataclasses.replace(claims[i],
priority=P_MAINTENANCE)
return
if any(c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_PEAK for c in claims):
return
live = set(self.live_units())
reserve = self._peak_reserve() # SAFETY-87: they give nothing
others = 0.0
for inv in self.inverters:
u = self.actuator_of("maintenance", inv)
if u in hold or u not in live:
continue
if inv.name in reserve: # only its own pin, never both ways
p = self._held_pin(claims, u)
if p is None:
return
others += p
continue
b = pre_resolve([c for c in claims if c.actuator == u])
if b.contradiction:
continue
p = allocator.pin_w(b)
if p is not None and p < 0.0:
return # a pack pinned to charge: never split both ways
others += allocator.hi_w(b) if p is None else p
floor = max(claims[i].lo for i in peak)
give = floor - others
if give <= 0.0:
return
for i, c in enumerate(claims):
if c.source == "maintenance" and c.kind == SET \
and c.actuator in hold:
claims[i] = dataclasses.replace(c, value=give)
return floor
def _charge_headroom(self, floor: float, claims) -> float:
"""SAFETY-84: a cheap-window charge pulls no more than the quarter's
room under the peak aim (`charge_room_w()`), so it yields just
enough to the peak guard; at N>=2 nothing under a site stop (its 0 W
would need the other pack to give).
(DECISIONS/MAIN-orchestration-archive.md)"""
if len(self.inverters) > 1 and any(
c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_MAINT_SHAPE for c in claims):
return 0.0
room = peakguard.charge_room_w(self.peak_guard, self._peak_proj)
floor = floor if room is None else min(floor, room)
self.room_charge = self.room_charge or floor > 0.0
return floor
def _charge_ceiling(self, claims):
"""SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None:
the charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above
that the other pack would have to give (opposite directions,
refused, 0 W). Not filed at N=1."""
if len(self.inverters) < 2:
return None
live = set(self.live_units())
ceiling = 0.0
for inv in self.inverters:
u = self.actuator_of("maintenance", inv)
b = pre_resolve([c for c in claims if c.actuator == u])
if u in live and not b.contradiction and b.hi < 0.0:
ceiling += allocator.hi_w(b)
return ceiling if ceiling < 0.0 else None
def _maint_stop_off(self, machine, now) -> None:
"""OPT-11: stop this battery's maintenance cycle because it is off."""
if machine.phase == IDLE:
return
for msg in machine.abort(now, "battery switched off"):
self.log_event(msg, hint=messages.hint("maint_off",
unit=machine.name))
@property
def why_words(self) -> str:
"""The page's "Why" line. While control waits for a missing reading
the age is counted when the page asks (UX-10), not frozen at the age
the last cycle saw."""
if self._why_waiting_since is not None:
return messages.why_waiting(max(0.0, (
datetime.now(timezone.utc)
- self._why_waiting_since).total_seconds()))
return self._why_words
@why_words.setter
def why_words(self, text: str) -> None:
self._why_words = text
self._why_waiting_since = None
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings: every pack, not
the live ones. A unit whose SoC did not answer cannot be graded for
the maintenance FSM or the pack floor, and 11.1 rule 1 for a missing
input is command zero and keep commanding."""
if self.grid is None or not self.inverters:
return False
return all(inv.soc is not None and inv.batt is not None
for inv in self.inverters)
def missing_reading(self) -> str:
"""UX-11: the first reading `inputs_ok` finds missing, in words."""
if self.grid is None or not self.inverters:
return "meter"
for inv in self.inverters:
of = "" if len(self.inverters) == 1 else f" from {inv.name}"
if inv.batt is None:
return f"battery power{of}"
if inv.soc is None:
return f"battery level{of}"
return "input"
# -- io ------------------------------------------------------------------
@property
def pv_roster(self) -> tuple:
"""Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE
reading of the options, `pv.entities()`, the same call
`mqtt.optional_keys()` makes, so what is ANNOUNCED, READ and CHECKED
cannot disagree; it replaced a `pv_entity` property."""
return pv_read.entities(self.o)
async def _read_pv(self) -> None:
"""This cycle's SITE PV production, or an ABSENCE with a reason
(TEL-11/12). `pv.total()` makes "one of three absent" an absence of
the SITE figure. It assigns on EVERY call including failing ones (a
stale value must not survive a failure), and uses `hass.state()` not
`hass.number()` because the UNIT is half of the answer."""
# TEL-12: every entity in ONE gather. Read serially, three slow sensors
# would add three 10 s timeouts to a cycle that feeds the ESP32
# heartbeat; telemetry must have no foot in the control path.
roster = self.pv_roster
states = await asyncio.gather(
*(self.hass.state(entity) for entity in roster))
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)), pv_read.inverted(self.o))
# UX-54: kept for the Forecast tab only. It must never cost a cycle.
try:
self.solar_today.add(datetime.now(timezone.utc), self.pv_w)
except Exception: # noqa: BLE001
_LOG.debug("measured solar not kept", exc_info=True)
def _peak_fc_unit_log(self, fault) -> None:
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
in watts, and again only if the unit changes; the unit is on DEBUG."""
if fault == self._peak_fc_fault:
return
self._peak_fc_fault = fault
if fault is None:
_LOG.info("The 15-minute demand forecast is used again.")
elif fault == "":
_LOG.warning("The 15-minute demand forecast has no unit, so it "
"is not used.")
else:
_LOG.warning("The 15-minute demand forecast is not in a power "
"unit, so it is not used.")
_LOG.debug("The 15-minute demand forecast reads in %r.", fault)
async def _read_forecast(self) -> None:
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
failed or stale read is no forecast, never a zero."""
# OPT-17: every member of both days in one gather.
ents = self.fc_entities + self.fc_tomorrow_entities
states = await asyncio.gather(*(self.hass.state(e) for e in ents))
n = len(self.fc_entities)
now = datetime.now(timezone.utc)
fc, why, gaps = solarfc.read_sum(
list(zip(self.fc_entities, states[:n])),
list(zip(self.fc_tomorrow_entities, states[n:])), now,
hold=self.fc_hold)
if self.fc_hold.changed:
self.fc_hold.changed = False
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
# A member is named only in a sum of several; one entity reads as
# before. Logged once per episode: on a change, never every read.
gaps = tuple(g for g in gaps if len(
self.fc_entities if g[0] == "today"
else self.fc_tomorrow_entities) > 1)
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
if (fc is None) != (self.solar_fc is None) \
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
or bool(fc is not None and fc.kept) != was_kept:
self.log_event(
"solar forecast: " + (
"none (" + str(why) + ")" if fc is None else
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
else "read")
+ "".join(f", {d} {e} not usable ({w})"
for d, _i, e, w in gaps),
hint=messages.hint(
"forecast_none" if fc is None else
"forecast_no_tomorrow" if gaps else "forecast_read"))
self.solar_fc, self.solar_fc_why = fc, why
self.solar_fc_gaps = gaps
async def read_inputs(self, now_mono: float | None = None) -> None:
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
`now_mono` is defaulted rather than read inside `_read_unit()` so
ONE instant covers every unit in a gather."""
o = self.o
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
self._now_mono = now_mono
if self.p1_enabled:
# P1 is the only authoritative measurement of what the utility sees
# (5.1); when stale this is None -> "inputs missing -> command 0
# W". NO fallback to an inverter-side figure: its AC power
# correlates 0.998 with battery power and 0.09 with the real meter
# (a controller would regulate against its own output).
self.grid = self.p1.net_w
else:
self.grid = await self.hass.number(o.get("meter_entity", ""),
bool(o.get("meter_invert")))
# DEV-02: each inverter's own two readings from its own entities,
# CONCURRENTLY across units and sequentially within one (a slow entity
# must not delay the heartbeat path). The PV reads ride in the same
# gather and an empty PV roster appends nothing (no coroutine).
# DECISIONS/MAIN-orchestration.md.
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
if self.pv_roster:
_reads.append(self._read_pv())
# OPT-11 P4: the solar forecast, in the same gather (a slow sensor must
# not delay control), at most once per FORECAST_READ_S.
if self.fc_entity is not None and (
self._fc_read_mono is None
or now_mono - self._fc_read_mono >= FORECAST_READ_S
or now_mono < self._fc_read_mono):
self._fc_read_mono = now_mono
_reads.append(self._read_forecast())
await asyncio.gather(*_reads)
if o.get("peak_forecast_entity"):
# SAFETY-118: read in its own unit like the PV path, one state
# read. No unit or a non-power unit: not used, as if the option
# were empty.
st = await self.hass.state(o["peak_forecast_entity"])
self.peak_fc, _why = pv_read.watts(st)
# A missing reading leaves the unit-fault state as it was.
if pv_read.has_reading(st):
self._peak_fc_unit_log(pv_read.unit_fault(st))
else:
self.peak_fc = None
self._peak_fc_fault = None
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
if now_e and avg_e:
now_p = await self.hass.number(now_e)
avg_p = await self.hass.number(avg_e)
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
self._price_gated = True
self.tariff_words = "Prices from Home Assistant entities"
elif uses_chain(o):
# PRICE-01. A provider with fallbacks gives the maintenance gate
# its own prices: the current 15-minute price against today's
# average, as the Home Assistant pair did.
# (DECISIONS/MAIN-orchestration-archive.md)
self._provider_gate(datetime.now(timezone.utc))
else:
# TARIFF-03: 9.2 makes a static day/night tariff a first-class
# product and its EMS handling TIME-WINDOW SHIFTING; the one
# shiftable load is the maintenance cycle's cheap-window grid
# top-up. Synchronous and I/O-free on purpose (the provider-backed
# plan is the background task's); validate.py refuses
# `static_profile` with the price entities, so the two cannot both
# be set.
_now = datetime.now(timezone.utc)
self.cheap, self.tariff_words = static_status(o, _now)
self._price_gated = self._static_gated(o, _now)
# TARIFF-11: the provider plan reaches the *Tariff* row here, the only
# place the two sources meet. `fresh_tariff_plan()` is an attribute
# read and an age comparison against TARIFF_MAX_AGE_S: still no fetch
# on this path.
_tick = datetime.now(timezone.utc)
_standing = self.fresh_tariff_plan(_tick)
self.tariff_words = tariff_plan_row(
_standing, self.tariff_words,
age_s=(None if _standing is None
else (_tick - self.tariff_at).total_seconds()),
horizon_h=float(price_option(o, "price_horizon_h")),
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
# UX-11: the red meter row says "the add-on log shows why", so a quiet
# meter gets one WARNING per episode (at most one per
# METER_QUIET_REPEAT_S; the rest DEBUG). Log only: no notification,
# decides nothing.
if self.p1_enabled:
if (self.p1.stale and not self._meter_quiet_logged
and self.p1.published_age_s > self.p1.max_age_s):
self._meter_quiet_logged = True
now = _mono()
self._meter_quiet_loud = (
self._meter_quiet_warned is None
or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S)
if self._meter_quiet_loud:
self._meter_quiet_warned = now
_LOG.warning("No meter reading for over %s, so control "
"waits and then sets the battery to 0 W.",
messages.seconds(self.p1.max_age_s))
else:
_LOG.debug("No meter reading for over %s again.",
messages.seconds(self.p1.max_age_s))
elif not self.p1.stale and self._meter_quiet_logged:
self._meter_quiet_logged = False
if self._meter_quiet_loud:
_LOG.info("Meter readings are back.")
else:
_LOG.debug("Meter readings are back.")
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 does not, deliberately: `batt` feeds SAFETY-30's per-unit sign
check and the delivery check, which a frozen register would fool.
The standing command is asked of the unit, not recomputed."""
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,
# so the site holds for `stale_input_s` then commands 0 W and keeps
# commanding (11.1 rule 1).
inv.batt = inv.batt_fresh.reading()
# SAFETY-97: learn this unit's idle reading, only while control runs
# and nothing says the unit is refusing, stale or faulty.
if inv.idle.observe(
inv.batt, inv.standing_w(), now_mono,
bool(self.auto) and inv.batt is not None
and not inv.batt_fresh.stale and not inv.refused
and not self.batt_refused and not inv.sign_wd.tripped):
inv.store_set("idle_w", round(inv.idle.value, 1))
if inv.batt_fresh.stale and not was_stale:
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
hint=messages.hint("battery_stuck", unit=inv.name))
self._refusal_from_stale(inv, now_mono)
# SAFETY-56/57: the stale-reading line names two explanations once,
# in `%s`. The escape clause "clears itself when the register
# reports a different value" was false for the state this line
# announces (the 0 W it announces is what stops the value
# changing), so SAFETY-57 rewrote it and SAFETY-56 the diagnosis.
# DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md.
_LOG.error("%s: battery power reading stopped changing, set to "
"0 W until it changes, or %.0f s after the command "
"drops below %.0f W: %s.",
inv.name, inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w,
messages.reason_code(inv.batt_fresh.reason))
_LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason)
_LOG.debug("%s: the limits are unchanged for %.0f s while asked "
"to move: a command over %.0f W that moved over %.0f W.",
inv.name,
inv.batt_fresh.bound.unchanged_s,
inv.batt_fresh.bound.move_w,
inv.batt_fresh.bound.move_w)
elif inv.batt_fresh.releases > was_releases:
# SAFETY-57: reported, never silent: a latch that lets go on a
# timer and says nothing cannot be audited, and this one withdraws
# a SITE STOP. `release_note()` says nothing has been learned about
# the register.
self.log_event(f"STALE WITHDRAWN {inv.name}",
hint=messages.hint("battery_back", unit=inv.name))
# Not "recovered": nothing is known about the reading yet, so the
# line gives the value it holds and the window it is rechecked over
# (SAFETY-57).
_LOG.warning("%s: the command stayed below %.0f W for %.0f s, so "
"battery power reading %+.0f W is used again and "
"rechecked over %.0f s.", inv.name,
inv.batt_fresh.bound.move_w,
inv.batt_fresh.bound.release_s, inv.batt_fresh.held,
inv.batt_fresh.bound.unchanged_s)
_LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name))
@property
def shadow(self) -> bool:
"""SAFETY-10. Is the battery being left alone right now? A property,
not a field (as `self.units`): the cycle, the write and the status
page ask at three moments, and the write gate asks THIS, not a
parameter passed down."""
return shadow.engaged(self.o, control_started=self.auto)
def shadow_latches(self) -> tuple:
"""The failsafe latches that are UP, named, for the shadow banner: a
standing latch is standing when the controller stops being inert, so
an operator who turns shadow off is not handed a battery that will
not move with no reason shown."""
out = []
if self.circ_wd.tripped:
out.append("circulating power")
if self.site_sign_wd.tripped and len(self.inverters) > 1:
out.append("site sign watchdog")
for inv in self.inverters:
if inv.sign_wd.tripped:
out.append(f"{inv.name} sign watchdog")
return tuple(out)
def write_refusal(self, value: float, inv=None) -> str | None:
"""Why `value` may NOT be written to `inv` right now, or None if it
may.
SAFETY-28: a number reaches the wire only if THE SINGLE ALLOCATOR
placed it, on this tick, for THIS unit (`Allocation.authorises()`).
specs 1.5 makes two writers on one actuator a non-goal;
`len(validate.ACTUATOR_WRITE_KEYS) == 1` is a statement about the
options schema, not the writes. A second writer must file a Claim and
go through `resolve()`.
0 W is ALWAYS authorised and the test for zero is first and reads
nothing: the failsafe writes run while the allocator has REFUSED, so a
gate asking its permission to command zero would deadlock the failsafe
behind the component whose failure it answers; and at S = 0 broadcast
and allocation coincide (no over-command). It tests `value == 0.0`, not
a `failsafe=True` flag (a flag is an assertion by the caller, which is
what a second writer would pass).
DEV-02: the unit is an ARGUMENT: the record holds one share per unit,
and the first inverter's name at a roster of two would authorise A's
share on B's wire. The default is for N=1 callers and REFUSES at N>=2
(`_only()`). DECISIONS/MAIN-orchestration.md."""
if value == 0.0:
return None
inv = self._only() if inv is None else inv
rec = self.allocation
if rec is None:
return ("no allocation record exists yet - nothing has authorised "
f"{value:+.0f} W on {inv.name}")
return rec.authorises(inv.name, value, self.alloc_tick)
async def write_setpoint(self, value: float, *, force: bool = False,
unit=None) -> None:
"""Put `value` on ONE inverter, or with `unit=None` on all. `unit=None`
is for a number that is not a share (stop button, `shutdown()`,
`maint_abort()`: 0 W means the same to every inverter). Writes are
concurrent, each under its own budget (DEV-02)."""
if unit is None:
targets = [i for i in self.inverters]
if len(targets) > 1 and is_commandable(value) and float(value) != 0.0:
# Loud, and it commands zero rather than the value asked for:
# fail toward inaction, as every other refusal here.
self.log_event(
f"refusing to broadcast {float(value):+.0f} W to "
f"{len(targets)} inverters - that is the ARCH-01 fault; "
f"commanding 0 W", hint=messages.hint("all_blocked"))
_LOG.error("Refused to send %+.0f W to all %s at once, so "
"every inverter is set to 0 W. This is a software "
"fault.", float(value),
messages.count(len(targets), "inverter"))
_LOG.debug("ARCH-01: a site target handed to each unit is "
"supplied by each of them, so the gain at the meter "
"becomes N times the per-unit gain. Only 0 W may be "
"written site-wide, and shares come from the "
"allocator.")
value = 0.0
else:
targets = [unit]
if not targets:
return
await asyncio.gather(*(self._write_unit(inv, value, force=force)
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 is on the ONLY path to the wire (cycle,
# heartbeat, stop button, shutdown(); no optimizer, maintenance or
# manual path routes around it). A non-finite value is loss of input,
# not an out-of-range one (11.1): coerced to 0 W.
if not is_commandable(value):
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W",
hint=messages.hint("bad_value"))
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
"0 W and continuing to command. This is a bug upstream, "
"not a limit.", value)
value = 0.0
# SAFETY-28: the refusal is HERE, above the clamp and below the
# non-finite coercion (a nan is loss of input, not an unauthorised
# number), and asked about `inv`, not the site.
# DECISIONS/MAIN-orchestration.md.
refusal = self.write_refusal(value, inv)
if refusal is not None:
self.log_event(
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
hint=messages.hint("write_blocked", unit=inv.name))
_LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W "
"instead, which is a software fault. The reason: %s.",
value, inv.name, refusal)
value = 0.0
# This unit's own three numbers: `inv.max_w` (supervised cap on THIS
# setpoint entity) and `inv.dev_min/dev_max` (what THIS device
# reported). Read off `self.o` they were one inverter's rating applied
# to every inverter.
result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max)
inv.clamp_last = result
if result.engaged:
# EVERY engagement is logged, never deduplicated: the action log
# (15) is a different surface from the deduplicated notification.
# The count says how hard the loop pushed.
self.log_event(f"{inv.name}: {result.describe()}",
hint=messages.hint("write_cut", unit=inv.name))
value = result.applied_w
# SAFETY-10: shadow mode runs the whole decision path and commands
# nothing, and the suppression is HERE, the last thing before the wire,
# in the one method every write reaches (so no call site routes round
# it). A substitution, not a return, like the two refusals above; below
# SAFETY-28's gate and SAFETY-03's clamp; not a second writer (no
# claim, no transport call).
if self.shadow and value != shadow.WIRE_W:
# UX-10: DEBUG, because it repeats every cycle in shadow mode; the
# INFO line when shadow comes on says it once.
_LOG.debug("Shadow mode: would send %+.0f W to %s, and holds "
"%+.0f W. Nothing was sent to the inverter.",
value, inv.name, shadow.WIRE_W)
value = shadow.WIRE_W
changed = (inv.last_write is None or value != inv.last_write)
if not (changed or force):
return
# BOUNDED, and the budget is AC 6: one hung transport would hold the
# coroutine, the whole loop and every OTHER inverter's heartbeat. An
# abandoned write is re-issued next tick (`last_write` moves only on an
# ACCEPTED write).
try:
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; left out it is the first
inverter (what every per-inverter claim meant at N=1). The
classification is not here: `arbiter.CLAIM_SCOPE` decides."""
return actuator_for(source, (inv or self.inverters[0]).name, self.units)
def live_units(self) -> tuple:
"""Which inverters may be commanded THIS TICK, for the allocator. The
allocator DROPS what this does not list and the survivors carry the
whole site target, so it is reported (`Allocation.dropped`).
Answered per tick, never cached (a master goes stale). It answers
ONE question: can this controller put a number on this unit's wire.
Deliberately not "its sensors are missing". At a roster of ONE,
False is a 0 W site."""
now = datetime.now(timezone.utc)
live = []
for inv in self.inverters:
ok, why = inv.is_live(now)
if ok:
live.append(inv.name)
else:
# Level-logged at DEBUG only: `Allocation.dropped` is the
# operator-facing surface (edge-logged in cycle()); a line
# every second would flush the 40-row action log.
_LOG.debug("%s is not commandable this tick: %s", inv.name, why)
return tuple(live)
def liveness_notes(self) -> list:
"""Why each unit is or is not commandable, for the status page.
SAFETY-37's stale-reading line rides here too: a different fault, a
different remedy (a dropped unit is carried by the survivors; a
stale power register is a missing input)."""
now = datetime.now(timezone.utc)
return [n for n in
[x for inv in self.inverters
for x in (inv.link_note(now), inv.stale_note(),
inv.refusal_note())]
if n]
def _provider_gate(self, now: datetime) -> None:
"""PRICE-01: `cheap`, the gate and the gap from the standing plan.
(DECISIONS/MAIN-orchestration-archive.md)"""
plan = self.fresh_tariff_plan(now)
published = None if plan is None else plan.published
if published is not None:
self._last_published = published
gate = gate_for(published, now)
self.cheap, self._price_gated = gate.cheap, True
self.tariff_words = gate.words
# Owner, 2026-10-14: the words say 'maintenance waits', so they show
# only while the maintenance charge is on.
self.price_gap = ((not gate.covered) and self.tariff_at is not None
and MaintConfig.from_options(self.o).enabled)
last = self._last_published
if gate.covered or last is None:
self.price_gap_since = None
elif last.covers(now):
# The last series still covers now, but no refresh has made it
# current for TARIFF_MAX_AGE_S: the refresh task has stopped. The
# gap began when the plan was retired.
self.price_gap_since = (
None if self.tariff_at is None
else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S))
else:
self.price_gap_since = last.last_end_before(now)
def _static_gated(self, o: dict, now: datetime) -> bool:
"""`static_gated()`, worked out once an hour: it builds the same
month-long plan as `static_status()` (about 20 ms with a profile), and
whether a fixed tariff is set up does not change from tick to tick
(options change only with a restart)."""
key = (id(o), now.strftime("%Y-%m-%d %H"))
if key != self._gated_key:
self._gated_key, self._gated = key, static_gated(o, now)
return self._gated
def _charge_gate_open(self, inv) -> bool:
"""MAINT-13: may this pack charge now, for the maintenance timeout?
(DECISIONS/MAIN-orchestration-archive.md)"""
if not self._price_gated or self.cheap:
return True
batt = inv.batt
return batt is not None and float(batt) <= -MAINT_CHARGING_W
async def cycle(self) -> None:
"""Gather claims, resolve precedence, write the winner.
(DECISIONS/MAIN-orchestration-archive.md)"""
now = datetime.now(timezone.utc)
# BEFORE the first `return` of this method on purpose (TARIFF-11): it
# stamps that a cycle RAN, a different fact from the decisions it may
# reach.
self.cycle_at = now
# Each pack on ITS OWN reading, not "the SoC": 12's recalibration
# drains and refills ONE pack and grades ONE coulomb counter; a minimum
# would abort on a sibling's state and an average would declare a pack
# full that is not. `arbitrate()` runs first, once, over all of them.
_off = ({inv.name for inv in self.inverters
if not self.pack_mode(inv).use} if self.modes_on else set())
for inv, machine in zip(self.inverters, self.maints):
if inv.name in _off:
self._maint_stop_off(machine, now)
_on = [m for inv, m in zip(self.inverters, self.maints)
if inv.name not in _off]
_plan_on = iter(maint_arbitrate(_on, now) if _off
else maint_arbitrate(self.maints, now))
maint_plan = [None if inv.name in _off else next(_plan_on)
for inv, _m in zip(self.inverters, self.maints)]
maint_results = []
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
if blocked is None:
maint_results.append(MaintResult(IDLE, False, None, False, []))
continue
# MAINT-13: the charge timeout counts only the minutes the pack was
# allowed to charge (see `_charge_gate_open`).
res = machine.tick(now, inv.soc, blocked_by=blocked,
gate_open=self._charge_gate_open(inv))
for msg in res.events:
self.log_event(msg, hint=messages.hint(
"maint_failed" if res.failed else "maintenance",
phase=machine.phase))
if res.failed:
# Said once: only the tick that failed carries `res.failed`.
multi = len(self.inverters) > 1
slug = (unit_slugs(self.units)[self.inverters.index(inv)]
if multi else "battery")
await self.hass.call(
"persistent_notification", "create",
messages.maint_failed(
res.failed, FAIL_BACKOFF_DAYS,
unit=inv.name if multi else "", slug=slug).data())
maint_results.append(res)
# Carried to the status page, not left in the 40-line action log: the
# deferral is edge-logged, and a pack that is due, idle and WAITING
# must be able to say so when looked at (12's failure mode is a
# schedule that silently stops firing).
self._maint_deferred = [(m.name, r.deferred)
for m, r in zip(self.maints, maint_results)
if r.deferred]
# SAFETY-02, and it runs BEFORE the early return below: evaluated every
# cycle, else a run of held cycles leaves `disagree_since` untouched
# and the timer resumes from a start time that predates the gap. One
# watchdog per inverter on that inverter's own reading; what it is
# compared against is decided by the roster (SAFETY-30).
self._arm_sign_watchdogs()
n_units = len(self.inverters)
standing = {}
for inv in self.inverters:
was_tripped = inv.sign_wd.tripped
# `Inverter.standing_w()`, not the expression it replaces:
# SAFETY-37's freshness gate arms off the same method.
stood = inv.standing_w()
standing[inv.name] = stood
against = self.grid if n_units <= 1 else inv.batt
if inv.sign_wd.evaluate(stood, against, self.following, now) \
and not was_tripped:
# REASON first, FLAG second (each `set` writes the file): a
# crash between the two persists a reason nothing reads, not a
# latch with no explanation. Both keys are namespaced to this
# unit.
inv.store_set("sign_reason", inv.sign_wd.reason)
inv.store_set("sign_tripped", True)
# The legacy flat keys are MIRRORED, the other half of store.py
# rule 2: that rule only covered the latch existing at
# migration time, so a trip after it wrote nothing flat and a
# rollback would find no latch.
self.store.set("sign_reason", inv.sign_wd.reason)
self.store.set("sign_tripped", True)
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason,
hint=messages.hint("direction", unit=inv.name))
_LOG.error("%s is set to 0 W until control is started, "
"because its power went the wrong way: %s.",
inv.name, messages.reason_code(inv.sign_wd.reason))
_LOG.debug("Evidence for %s: %s.", inv.name,
inv.sign_wd.reason)
# The site's own standing command is the SUM of the units': SAFETY-07
# and SAFETY-18 grade a SITE quantity (the quarter average at the grid
# connection; a meter not moving while the site pushes near its rail).
# Same number at a roster of one.
site_standing = float(sum(standing.values()))
# SAFETY-30: the site half of the sign check: the TOTAL standing on the
# wire against the meter, catching a fault no per-unit check sees
# (every unit faithfully executing the wrong total). Only at N>=2 (at
# one inverter it would duplicate `inverters[0].sign_wd`; AC 8). Flat
# keys, not namespaced.
if n_units > 1:
was_site = self.site_sign_wd.tripped
if self.site_sign_wd.evaluate(site_standing, self.grid,
self.following, now) and not was_site:
# REASON first, FLAG second, as the per-unit trip and
# SAFETY-31. The keys are flat, not namespaced, and not the
# per-unit pair (store.py).
self.store.set("site_sign_reason", self.site_sign_wd.reason)
self.store.set("site_sign_tripped", True)
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason,
hint=messages.hint("site_direction"))
_LOG.error("Every inverter is set to 0 W until control is "
"started, because the total power went the wrong "
"way: %s.",
messages.reason_code(self.site_sign_wd.reason))
_LOG.debug("Evidence for the site: %s.",
self.site_sign_wd.reason)
# SAFETY-31, beside SAFETY-02 for the same reason: evaluated EVERY
# cycle, unconditionally, above the input hold (else `over_since`
# resumes from a start that predates the gap). The input is MEASURED
# power, never commanded: a unit ignoring or lagging its share, a stale
# share still executing.
circ_w = self.site_circulating_w
was_circ = self.circ_wd.tripped
if self.circ_wd.evaluate(circ_w, now) and not was_circ:
# REASON first, FLAG second (each `set` writes the file): a crash
# between must not persist a latch with no explanation.
self.store.set("circ_reason", self.circ_wd.reason)
self.store.set("circ_tripped", True)
self._circ_announced = False
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason,
hint=messages.hint("circulating"))
_LOG.error(
"Every inverter is set to 0 W until control is started, "
"because the inverters worked against each other: %s.",
messages.reason_code(self.circ_wd.reason))
_LOG.debug("Evidence for the site: %s.", self.circ_wd.reason)
# SAFETY-42, for SAFETY-02's and SAFETY-31's reason: evaluated every
# cycle above the input hold. The operands are `standing` and
# `inv.batt`, the pair SAFETY-30 uses. `delinquent()` is called once
# over the whole roster, and every number is the configured one, never
# a literal that could drift (`self.delivery_cfg`, not
# `DeliveryConfig()`: part 4b grades a hold that is not the shipped 185
# s).
for inv in self.inverters:
# `self.delivery_cfg`, not `DeliveryConfig()`: the window is
# `delivery_hold_s`, and part 4b grades a hold that is not the
# shipped 185 s.
inv.delivery_wd.arm(self.delivery_cfg)
_delinquent = delinquent(
[UnitDelivery(
inv.name, standing[inv.name], inv.batt,
delivery_account(
# ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is
# `LINK_STALE_S` - the SAME call `live_units()` makes for
# the allocator. (DECISIONS/MAIN-orchestration-archive.md)
live=inv.is_live(now)[0],
# SAFETY-73: a refusing unit is asked 0 W on purpose, and
# one asked under PROBE_W that gives under 100 W is a
# refusal too small to call (its sibling covers the meter).
parked=inv.sign_wd.tripped or bool(inv.refused) or (
n_units > 1 and inv.batt is not None
and abs(float(standing[inv.name])) <= _ctl.PROBE_W
and abs(float(inv.batt) - inv.idle_w)
< _ctl.REFUSED_BELOW_W),
# ⚠️ THIS UNIT'S OWN CUT-OUT.
# (DECISIONS/MAIN-orchestration-archive.md)
soc=inv.soc, bms_floor=inv.bms_soc_floor))
for inv in self.inverters],
self.tuning.saturation_w)
for inv in self.inverters:
was_short = inv.delivery_wd.tripped
if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \
and not was_short:
# REASON FIRST, FLAG SECOND, and each `set` writes the file -
# 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.
# (DECISIONS/MAIN-orchestration-archive.md)
if not self.inputs_ok and not any(i.sign_wd.tripped
for i in self.inverters) \
and not (n_units > 1 and self.site_sign_wd.tripped):
bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0
if bad_for < stale_after:
# A single missed poll is not a fault. Hold, keep the heartbeat
# going, and give it a few seconds to come back.
self.reason = f"inputs missing {bad_for:.0f}s"
# UX-10: the age is rendered when the page asks, not frozen at
# this cycle's. (DECISIONS/MAIN-orchestration-archive.md)
self.why_words = messages.why_waiting(bad_for)
self._why_waiting_since = now - timedelta(seconds=bad_for)
return
claims = []
# safety limits: these bind EVERY strategy, always Defence in depth:
# write_setpoint() clamps to the device range too.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
if inv.dev_min is not None and inv.dev_max is not None:
claims.append(Claim.limit("device", P_SAFETY, inv.dev_min, inv.dev_max, "rating",
actuator=self.actuator_of("device", inv)))
# `max_w` is the supervised cap on what may be WRITTEN to one
# setpoint entity (§11.3), not a bound on the grid connection.
claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w",
actuator=self.actuator_of("supervised", inv)))
# OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS ⚠️ THE POINT OF
# THE CARD, AND IT IS AN ENVELOPE RATHER THAN A WEIGHT.
# 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.
# (DECISIONS/MAIN-orchestration-archive.md)
if n_units > 1:
_pe = packshare.envelope(pack_energy_of(inv))
if _pe.binding:
claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi,
_pe.why,
actuator=self.actuator_of("pack", inv)))
# The SITE bound, for the site-level consumers below. It is the roster
# sum: what this site can deliver, not what one of its inverters can.
max_w = self.site_max_w
# safety stops ⚠️ SAFETY-02 is claimed FIRST and unconditionally while
# latched.
# AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
if inv.sign_wd.tripped:
claims.append(Claim.set("failsafe", P_SAFETY, 0.0,
"sign disagreement",
actuator=self.actuator_of("failsafe", inv)))
# SAFETY-30: THE SITE SIGN TRIP, AND IT IS A SITE SET ⚠️ THE TWIN OF
# THE LOOP ABOVE, NOT A DUPLICATE OF IT.
# AND THE `n_units > 1` GUARD IS NOT REDUNDANT, EVEN THOUGH `cycle()`
# never arms this detector at a roster of one.
# (DECISIONS/MAIN-orchestration-archive.md)
if n_units > 1 and self.site_sign_wd.tripped:
claims.append(Claim.set("sign_site", P_SAFETY, 0.0,
"site sign disagreement",
actuator=self.actuator_of("sign_site")))
# SAFETY-31: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT ⚠️ A
# SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE.
# THE OBJECTION.
# (DECISIONS/MAIN-orchestration-archive.md)
if self.circ_wd.tripped:
claims.append(Claim.set("circulation", P_SAFETY, 0.0,
"circulating power",
actuator=self.actuator_of("circulation")))
# SITE, both of them, 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. (DECISIONS/MAIN-orchestration-archive.md)
if not self.auto and not self.shadow:
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped",
actuator=self.actuator_of("safety")))
elif not self.inputs_ok:
if self.target != 0.0:
self.log_event("inputs missing - commanding 0 W",
hint=messages.hint("inputs_missing"))
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
actuator=self.actuator_of("safety")))
# SAFETY-07: the quarter-peak projection guard ONE `lo` limit at
# P_PEAK, and peakguard.py carries the whole argument.
# `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON
# `self.freeze_wd.active`.
# (DECISIONS/MAIN-orchestration-archive.md)
self._peak_proj = (None if not self.p1_enabled
else self.peak.project(self.p1.averager, now,
roll=self.modes_on))
# OPT-15 x SAFETY-81: see `check_peak_saturated()`.
self._peak_rolled = (self._peak_proj is not None
and self._peak_proj.block_start is not None
and self._peak_proj.block_start
!= self.p1.averager.block_start)
_reading = self.peak.peak(now) if self.p1_enabled else None
# SAFETY-90: seconds a meter gap left uncounted are counted, so the
# guard goes on after the readings return.
self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading)
# SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter,
# so a HOLD's ceiling does not lapse for a cycle onto a held battery.
if self._peak_proj is not None and len(self.inverters) > 1:
self._peak_proj = next_quarter(self._peak_proj,
self.p1.averager.max_hold_s)
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
self._peak_proj,
# ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is
# ALREADY removing from the meter, and the meter sees every
# inverter. (DECISIONS/MAIN-orchestration-archive.md)
batt_w=self.site_batt_w,
cap_w=float(self.o.get("peak_cap_w", 3500)),
margin_w=self.peak_margin_w,
reading=_reading,
# ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each unit
# contributes `min(its max_w, |its own device bounds|)`, so
# inverter B's rating bounds inverter B.
# (DECISIONS/MAIN-orchestration-archive.md)
achievable_w=self.site_rating_w,
# ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE
# None, and it is handed over as None rather than defaulted here.
# (DECISIONS/MAIN-orchestration-archive.md)
soc=self.pack_floor_soc,
# ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN
# CUT-OUTS. (DECISIONS/MAIN-orchestration-archive.md)
soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get(
"peak_soc_floor",
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))),
max_hold_s=self.p1.averager.max_hold_s,
# ⚠️ SAFETY-20. (DECISIONS/MAIN-orchestration-archive.md)
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
site_standing, max_w, self.freeze_wd.cfg,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")),
)
# SAFETY-87: at N>=2 a battery at or under its own reserve gives
# nothing for the peak, and the others carry the guard's need.
if self.peak_guard is not None:
self.peak_guard = self._peak_on_able(self.peak_guard,
maint_results)
self.peak_guard = self._peak_landing(self.peak_guard)
# SITE, and peakguard.claim() says so on the object it builds.
# (DECISIONS/MAIN-orchestration-archive.md)
peak_claim = (peakguard.claim(self.peak_guard)
if self.peak_guard is not None else None)
if peak_claim is not None:
claims.append(peak_claim)
# ⚠️ EDGE-LOGGED, not level-logged.
# (DECISIONS/MAIN-orchestration-archive.md)
engaged_now = self.peak_guard is not None and self.peak_guard.engaged
if engaged_now != self._peak_engaged:
self._peak_engaged = engaged_now
if self.peak_guard is not None:
_pg = self.peak_guard
self.log_event(_pg.explain(), hint=messages.hint(
"peak_off" if not _pg.engaged else
"peak_blind" if _pg.blind else
"peak_on" if _pg.within_aim else
"peak_reserve" if _pg.floor_limited else
"peak_saturated" if _pg.saturated else "peak_on"))
# OPT-04: price arbitrage (§10.1 priority 3) ONE `set` claim at
# P_ARBITRAGE, and app/arbitrage.py carries the whole argument.
# IT IS PLACED AFTER THE PEAK BLOCK BECAUSE IT READS ITS CONCLUSION.
# (DECISIONS/MAIN-orchestration-archive.md)
self.arb, self.arb_at = None, now
self.arb_headroom_w = None
self.room_charge = False
# OPT-11 P3: with battery modes on, each battery's mode decides who
# trades (`_mode_roles()`, `_mode_trade()`, below, which read the
# same headroom), and this site-wide trade is not run.
site_trade = arbitrage.enabled(self.o) and not self.modes_on
if site_trade or self.modes_on:
# SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED.
# (DECISIONS/MAIN-orchestration-archive.md)
self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard,
self._peak_proj)
if site_trade:
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
# SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS
# ONE. (DECISIONS/MAIN-orchestration-archive.md)
charge_headroom_w=self.arb_headroom_w,
# The emptiest pack again, and for the sell side that is the
# right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there
# is one `soc` argument - `pack_floor_soc` names that residual
# rather than hiding it; a per-pack charge ceiling is CFG-04's,
# and at the shipped roster of one the two reductions are the
# same number.
soc=self.pack_floor_soc,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
# OPT-09. PER-PACK SIZING.
# (DECISIONS/MAIN-orchestration-archive.md)
charge_w=charge_w, discharge_w=discharge_w,
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
# SITE, and arbitrage.claim() says so on the object it builds. It
# buys and sells at the grid against a day-ahead price: the price is
# a property of the connection and the energy crosses the meter.
arb_claim = arbitrage.claim(self.arb)
if arb_claim is not None:
claims.append(arb_claim)
self.room_charge = self.arb.action == arbitrage.CHARGE
# Edge-logged, for the peak guard's reason: the action log holds 40
# lines and a trade lasts a whole hour, so a line per cycle would
# flush the context that explains it.
# (DECISIONS/MAIN-orchestration-archive.md)
if self.arb.action != self._arb_action:
self._arb_action = self.arb.action
self.log_event(self.arb.explain(), hint=messages.hint(
"trade_charge" if self.arb.action == arbitrage.CHARGE else
"trade_give" if self.arb.action == arbitrage.DISCHARGE
else "trade_off"))
# maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST
# STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md)
for inv, result in zip(self.inverters, maint_results):
if result.owns_setpoint:
# PER-INVERTER. §12's 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.
# 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.
# (DECISIONS/MAIN-orchestration-archive.md)
claims.append(Claim.set("maintenance", P_MAINTENANCE,
float(result.setpoint_w or 0.0), result.phase,
actuator=self.actuator_of("maintenance", inv)))
self.sat_count = 0
elif result.charge_only:
# Money outranks the maintenance schedule: while the
# quarter-hour projection is over the cap, the charge-only
# shaping is simply not claimed, so the loop can discharge and
# shave the peak.
# THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is
# not tidiness.
# (DECISIONS/MAIN-orchestration-archive.md)
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc, self.peak_aim_w()):
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted",
hint=messages.hint("maint_paused"))
else:
self._maint_shape_suspended = False
hi = 0.0
reason = "charge-only"
if self.cheap:
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND
# READ OF `self.o`.
# (DECISIONS/MAIN-orchestration-archive.md)
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, self.peak_aim_w())
floor = self._charge_headroom(floor, claims)
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged, which is `inv`.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
reason=reason,
actuator=self.actuator_of("maintenance",
inv)))
# OPT-11: each battery's mode, a soft unit limit at P_MODE Filed only
# with battery modes on, and never on a battery in any maintenance
# phase: maintenance at 60/40 outranks the mode.
# (DECISIONS/MAIN-orchestration-archive.md)
if self.modes_on:
tick = self._mode_roles(now, maint_results)
for inv, result in zip(self.inverters, maint_results):
if result.phase != IDLE:
continue
pm = self.pack_mode(inv)
lo, hi = packmode.mode_range(pm, inv.max_w)
rlo, rhi = tick.ranges.get(inv.name, (lo, hi))
lo, hi = max(lo, rlo), min(hi, rhi)
claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key,
actuator=self.actuator_of("mode",
inv)))
# the grid-following loop ⚠️ SAFETY-10: `or self.shadow`.
# (DECISIONS/MAIN-orchestration-archive.md)
if bool(self.auto) != self._refusal_auto:
self._refusal_auto = bool(self.auto)
self._refusal_end("control started" if self.auto
else "control stopped")
for inv in (self.inverters if len(self.inverters) > 1 else ()):
self._unit_refusal_end(inv, "control started" if self.auto
else "control stopped")
# SAFETY-73: each unit's own refusal, engaged and ended on its own
# reading, before the loop reads `self.target` (an end reseeds it).
_probing = {}
if len(self.inverters) > 1 and self.inputs_ok:
self._unit_refusals()
_probing = {inv.name: self._unit_probe(inv)
for inv in self.inverters if inv.refused}
# SAFETY-73: each refusing unit's own P_SAFETY claim A hard unit limit
# that always intersects (OPT-11's P_MODE layer sits inside it): closed
# in the refused direction, so the allocator hands the share to the
# others within their own limits.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
if not inv.refused:
continue
if _probing.get(inv.name):
claims.append(Claim.set("refused", P_SAFETY,
inv.refused * _ctl.PROBE_W,
_ctl.PROBING,
actuator=self.actuator_of("refused",
inv)))
else:
claims.append(Claim.limit("refused", P_SAFETY,
*_refused_interval(inv.refused),
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
# --- SAFETY-78: a pinned drain is a floor under the site target -----
# After every unit claim, so the floor is what the pin really places.
drain = self._drain_floor(claims, maint_results)
if drain is not None:
claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain,
reason="maintenance drain",
actuator=self.actuator_of("drain")))
self._peak_over_drain(claims, maint_results)
# --- SAFETY-86: while a HOLD gives, the site stays at the peak floor --
# So the loop never asks for watts the hold keeps back.
hold_hi = self._peak_over_hold(claims, maint_results)
if hold_hi is not None:
claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi,
reason="maintenance hold",
actuator=self.actuator_of("hold")))
# --- SAFETY-91: a drain rises before a held battery gives ------------
self._drain_rise(claims, maint_results)
# --- SAFETY-84: a cheap-window charge is a ceiling over the site ----
charge = self._charge_ceiling(claims)
if charge is not None:
claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=charge,
reason="maintenance charge",
actuator=self.actuator_of("charge")))
# --- OPT-11 P3: the site trade, after every unit claim --------------
# Sized from what the batteries may place with their modes and hard
# limits (the loop bound), so none is asked for watts it may not give.
mode_trade, trade_was = False, self._mode_trade_was
if self.modes_on:
mode_trade = self._mode_trade(claims, now)
self.room_charge = self.room_charge or (
mode_trade and self.arb.action == arbitrage.CHARGE)
self._mode_trade_was = mode_trade
self._charge_track(claims, maint_results)
_live96 = set(self.live_units())
self._loop_start = {}
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish.
# 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.
# (DECISIONS/MAIN-orchestration-archive.md)
moved = self.loop_w is None or self.target != self.loop_w
if moved:
self.i_w = self.target
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and
# OPT-15 holds it there until the meter shows the step.
if trade_was and not mode_trade:
self._reseed_run_edge(claims, now)
moved = False
loop_grid = self._edge_grid(mode_trade or moved, now)
# SAFETY-52: another writer overrode the loop, so a refusal learned
# against the loop's own ask may no longer describe the wire.
# (DECISIONS/MAIN-orchestration-archive.md)
rejudged = 0
if self.loop_w is not None and self.target != self.loop_w:
rejudged = self.batt_refused
# SAFETY-96: a battery that dropped out of the split leaves the
# loop's start point (SAFETY-93's own if its charge ended too).
self._reseed_drop(_live96)
# SAFETY-93: a charge that ended leaves the loop's start point.
self._reseed_charge_end(maint_results)
# SAFETY-95: then a drain that ended, last (it reads the start
# the others left).
self._reseed_drain_end(maint_results, _live96)
# No probe while it is judged afresh (the loop does not hold the wire).
probe = False if rejudged else self._refusal_probe()
if len(self.inverters) > 1:
probe = any(_probing.values())
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
# The anti-windup test inside compute() is `abs(prev_w - actual_w)
# > saturation_w`, and `prev_w` is the SITE target.
# (DECISIONS/MAIN-orchestration-archive.md)
site_batt = self.site_batt_w
# SAFETY-103: what each battery can be doing (its reading may be
# older than the meter).
self._track_reads()
# SAFETY-96: a battery the loop cannot move counts at its command,
# so its own power does not freeze the loop (the meter has it).
site_batt += self._held_out_w(_live96)
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
# AGAINST. (DECISIONS/MAIN-orchestration-archive.md)
loop_prev = self.target
decision = compute(
prev_w=loop_prev,
# SAFETY-95: a drain's tail is not the loop's to chase.
# OPT-15: `loop_grid` is the grid, or the target while a run
# edge is held.
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
else loop_grid),
actual_w=site_batt,
tuning=self.tuning,
sat_count=self.sat_count,
i_w=self.i_w,
# SAFETY-52 at one inverter only: with several, OPT-09's pack
# limit already closes an empty battery's discharge end.
# SAFETY-73: at N>1 the site law holds 0 W only while every
# unit refuses the same way. Otherwise None, the old law.
refused=(0 if rejudged else self.batt_refused)
if len(self.inverters) == 1
else self._site_refused(),
probe=probe,
# SAFETY-94: at N=1 the engage also needs the reading to have
# given nothing for the whole span, as at N>1.
gave_nothing=(bool(self._gave_nothing(self.inverters[0]))
if len(self.inverters) == 1 else None),
# SAFETY-97: "gives nothing" is centred on the idle reading.
idle_w=self._site_idle_w(),
# OPT-11: None unless battery modes are on. SAFETY-85: or while
# maintenance holds a battery at N>=2, so the loop does not
# wind past what the others can give and jump when it ends.
bound=(self.loop_bound(claims) if self.modes_on or (
len(self.inverters) > 1
and any(r.phase != IDLE for r in maint_results))
else None),
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
f"{site_batt:.0f} W)",
hint=messages.hint("not_keeping_up"))
self.sat_count = decision.sat_count
self.i_w = decision.i_w
self.loop_w = decision.target_w
self._loop_decision = decision
if len(self.inverters) == 1:
self._refusal_update(decision, loop_prev, site_batt, probe,
rejudged)
# TEL-10 ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT
# IS WHAT THE CARD IS.
# AND `loop_reason` IS NOT `self.reason`.
# (DECISIONS/MAIN-orchestration-archive.md)
self.loop_i_w = decision.i_w
self.loop_i_lead = decision.i_w - loop_prev
self.loop_reason = decision.reason
# SAFETY-03 ⚠️ READ THE BOOLEANS, NOT `decision.reason`.
# (DECISIONS/MAIN-orchestration-archive.md)
self.loop_clamped = (
decision.i_clamped or decision.clamped,
loop_clamp_note(decision),
decision.clamped,
)
self.loop_clamp_facts = (
(messages.Clamp(None, decision.requested_w, decision.clamped_w,
"rating"),) if decision.clamped else ())
# SITE. compute() regulates `grid_w` - the P1 measurement - to
# zero. Its subject is the meter, and §6.2's architecture is ONE
# integrator on P1 whose output is then SPLIT.
# (DECISIONS/MAIN-orchestration-archive.md)
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason,
actuator=self.actuator_of("loop")))
else:
# Stopped or blind: no accumulation may survive the outage.
self.i_w = 0.0
self.loop_w = None
self._live_was = frozenset(_live96)
# TEL-10, and it is the same zero the line above writes rather than
# a second opinion about it. ⚠️ `not-running` rather than a stale
# `tracking`: a reason row that keeps repeating the last word the
# loop said while the loop is not running is a row that reads as
# health, which is the failure `publish()`'s own None-dropping note
# is about one field over.
self.loop_i_w = 0.0
self.loop_i_lead = 0.0
self.loop_reason = "not-running"
# A loop that is not running cannot be winding. Clearing this here
# rather than letting the last value stand is what makes the alarm
# de-assert when control is stopped.
self.loop_clamped = (False, "", False)
self.loop_clamp_facts = ()
self._loop_decision = None
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
self.alloc_tick += 1
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD.
# A METHOD, NOT A DICT.
# (DECISIONS/MAIN-orchestration-archive.md)
_live = self.live_units()
while True:
resolution = resolve(claims, units=self.units, live=_live,
weights=self.pack_weights, tick=self.alloc_tick,
reserve=self._held_back())
# SAFETY-73: a probe pin stands only while the resolved site target
# asks at least PROBE_W its way.
# (DECISIONS/MAIN-orchestration-archive.md)
_void = [k for k, c in enumerate(claims)
if c.source == "refused" and c.kind == "set"
and c.value * resolution.target_w < _ctl.PROBE_W ** 2]
if not _void:
break
for k in _void:
claims[k] = Claim.limit(
"refused", P_SAFETY,
*_refused_interval(1 if claims[k].value > 0 else -1),
_ctl.REFUSED, actuator=claims[k].actuator)
self.allocation = resolution.allocation
if self.modes_on:
self._mode_fault_log(resolution)
# THE TWO REFUSALS, 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.
# AND NEITHER MAY BE LEFT UNGRADED.
# (DECISIONS/MAIN-orchestration-archive.md)
if not resolution.allocated:
self.log_event("ARBITER: " + resolution.allocation.explain(),
hint=messages.hint("split"))
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
unit_bounds=resolution.unit_bounds,
shares={}, allocated=False,
scope_errors=resolution.scope_errors,
allocation=resolution.allocation,
winner_priority=resolution.winner_priority)
# 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT.
# ⚠️ RE-AIMED BY DEV-02, NOT DELETED, AND THE DIFFERENCE MATTERS.
# (DECISIONS/MAIN-orchestration-archive.md)
elif {u for u in resolution.shares if not self._can_write(u)}:
undeliverable = ", ".join(
sorted(u for u in resolution.shares if not self._can_write(u)))
self.log_event(
f"ARBITER: the site target was allocated across "
f"{len(resolution.shares)} inverters and this controller has no "
f"setpoint entity for {undeliverable} - refusing to command a "
f"partial site", hint=messages.hint("no_setpoint"))
# ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE
# DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY.
# (DECISIONS/MAIN-orchestration-archive.md)
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
unit_bounds=resolution.unit_bounds,
shares={}, allocated=True,
scope_errors=resolution.scope_errors,
allocation=resolution.allocation,
winner_priority=resolution.winner_priority)
# 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its
# share, which is a real change in what the site can deliver, and an
# operator who is not told reads a working site.
# (DECISIONS/MAIN-orchestration-archive.md)
_dropped = tuple(u for u, _why in (self.allocation.dropped
if self.allocation else ()))
if _dropped != self._dropped_last:
for _u, _why in (self.allocation.dropped if self.allocation else ()):
self.log_event(f"ALLOCATOR: {_u} dropped from the split "
f"({_why}) - the remaining inverter(s) now carry "
f"its share of the site target",
hint=messages.hint("unit_dropped", unit=_u))
if self._dropped_last and not _dropped:
self.log_event("ALLOCATOR: every inverter is back in the split",
hint=messages.hint("units_back"))
self._dropped_last = _dropped
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
# `unmet_w` is the site target the envelopes could not absorb.
# (DECISIONS/MAIN-orchestration-archive.md)
_unmet = int(self.allocation.unmet_w if self.allocation else 0)
if _unmet != self._unmet_last:
if _unmet:
self.log_event(
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
f"placed on any inverter - {self.allocation.explain()}",
hint=messages.hint("unmet", w=_unmet))
else:
self.log_event("ALLOCATOR: the whole site target fits again",
hint=messages.hint("unmet_gone"))
self._unmet_last = _unmet
if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope
# does not permit. (DECISIONS/MAIN-orchestration-archive.md)
_LOG.error("A limit was filed on the wrong inverter, which is a "
"software fault. %s.", " / ".join(resolution.scope_errors))
if resolution.contradiction:
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
hint=messages.hint("clash"))
# SAFETY-02's arming gate Was the number about to go on the wire
# chosen by the grid-following loop, or by a strategy that commands
# against the meter on purpose?
# THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not
# tidiness.
# (DECISIONS/MAIN-orchestration-archive.md)
self.following = loop_is_following(resolution, self.loop_w)
self.target = resolution.target_w
self.reason = resolution.explain()
# UX-08: the same answer in plain words, for the page's "Why" line.
# `reason` stays the technical record the log and the checks read.
# (DECISIONS/MAIN-orchestration-archive.md)
_why = why_key(resolution.winner, claims)
if _why == "inputs missing" and not resolution.contradiction:
# UX-11: past the hold, say which reading is missing.
self.why_words = messages.why_missing(self.missing_reading(),
stale_after)
else:
self.why_words = messages.why(
_why, resolution.target_w, resolution.bound_by,
contradiction=resolution.contradiction,
raised=why_raised(resolution, claims))
# DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ⚠️ ITS OWN SHARE, NEVER
# THE SITE TARGET.
# AT N=1 THIS IS BYTE-IDENTICAL.
# (DECISIONS/MAIN-orchestration-archive.md)
self._loop_alarm_input(resolution)
writes = []
for inv in self.inverters:
inv.target = float(resolution.shares.get(inv.name, 0.0))
writes.append(self.write_setpoint(inv.target, unit=inv))
await asyncio.gather(*writes)
# SAFETY-03, and its boundary is the WRITE ⚠️ NOT THE SAME BOUNDARY AS
# SAFETY-18's BELOW, THOUGH THE CALLS ABUT.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.check_output_clamp()
# SAFETY-18, and its boundary is the ASSIGNMENT ⚠️ THE BOUNDARY IS
# `self.target = resolution.target_w` ABOVE, NOT write_setpoint().
# (DECISIONS/MAIN-orchestration-archive.md)
await self.check_meter_frozen()
# --- PRICE-01: no price for now, said once and taken down once --------
# Not a safety check, so it is one of the cheap ones: an attribute test
# and, on a change only, one Home Assistant call.
await self.check_price_gap()
await self.check_fixed_note()
# SAFETY-31's alarm, and its boundary is the LATCH, not a number
# 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.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.check_circulating()
# SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's It
# grades `inv.delivery_wd.tripped`, which was advanced near the top of
# this method beside SAFETY-02 and SAFETY-31 and which nothing since
# has touched. (DECISIONS/MAIN-orchestration-archive.md)
await self.check_not_delivering()
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
# method assigned above and nothing since has touched.
await self.check_peak_saturated()
# SAFETY-24's alarm, and it is the OTHER way the money gets spent Same
# boundary argument as check_peak_saturated(): it grades
# `self.peak_guard`, which cycle() assigned above and nothing since has
# touched. (DECISIONS/MAIN-orchestration-archive.md)
await self.check_peak_floor_limited()
# TARIFF-01, and it is LAST on purpose Banking a quarter is
# bookkeeping about a moment that has already happened; a slow SD card
# must never sit between the meter and the setpoint.
# (DECISIONS/MAIN-orchestration-archive.md)
self.peak.sync(self.p1)
# SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS Every
# guard above has now read `self.target` - the resolved intent - and
# SAFETY-18 in particular grades exactly that.
# (DECISIONS/MAIN-orchestration-archive.md)
self._shadow_settle(resolution)
def _shadow_settle(self, resolution) -> None:
"""SAFETY-10. Record the decision, then re-seed the carried command.
WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING.
(DECISIONS/MAIN-orchestration-archive.md)"""
engaged = self.shadow
if engaged != self._shadow_was:
# ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT.
# (DECISIONS/MAIN-orchestration-archive.md)
first = self._shadow_was is None
self._shadow_was = engaged
if engaged:
self.log_event(
"SHADOW MODE ON - " + shadow.why(
self.o, control_started=self.auto)
+ f"; inverter held at {shadow.WIRE_W:.0f} W",
hint=messages.hint("shadow_on"))
# UX-10: INFO. It follows a setting or a button press, and
# no person has to act on it.
_LOG.info(
"Shadow mode is on, so every inverter stays at %.0f W and "
"nothing the add-on decides is sent. %s.", shadow.WIRE_W,
shadow.why(self.o, control_started=self.auto))
elif not first:
self.log_event(
"Shadow mode off: the add-on controls the battery again, "
f"starting from {shadow.WIRE_W:.0f} W",
hint=messages.hint("shadow_off"))
_LOG.info(
"Shadow mode is off, so the add-on controls the battery "
"again, starting from %.0f W.", shadow.WIRE_W)
if not engaged:
self.shadow_would_w = None
self.shadow_sized_from_meter = False
self._shadow_winner = None
return
self.shadow_would_w = float(resolution.target_w)
# SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY.
# (DECISIONS/MAIN-orchestration-archive.md)
self.shadow_sized_from_meter = arbitrage.sized_from_meter(
self.arb, self.arb_headroom_w, winner=resolution.winner)
# ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE.
# (DECISIONS/MAIN-orchestration-archive.md)
edge = (resolution.winner, round(float(resolution.target_w)),
self.shadow_sized_from_meter)
if edge != self._shadow_winner:
self._shadow_winner = edge
self.log_event(shadow.log_line(
would_w=resolution.target_w, reason=resolution.explain(),
sized_from_meter=self.shadow_sized_from_meter),
hint=messages.hint("shadow_decision",
sw=resolution.target_w))
self.target = shadow.WIRE_W
for inv in self.inverters:
inv.target = shadow.WIRE_W
def _loop_alarm_input(self, resolution) -> None:
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided
(a rating may cut it). (DECISIONS/MAIN-orchestration-archive.md)"""
d = self._loop_decision
if d is None:
self._short_run = 0
self._asked_hist = {}
self._wire_lost, self._handback = False, None
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
owns = (resolution.winner == "loop" and not d.held and not unmet
and (resolution.target_w == d.target_w
or cut in ("device", "supervised")))
# Graded against the least that stood on the wire (0 W in shadow
# mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds.
now, asked = self._now_mono, {}
for inv in self.inverters:
h = self._asked_hist.setdefault(inv.name, [])
h.append((now, inv.standing_w()))
while len(h) > SHORT_CYCLES and (
now is None or h[0][0] is None
or now - h[0][0] > SHORT_SPAN_S):
h.pop(0)
asked[inv.name] = min(w for _t, w in h)
others = [inv for inv in self.inverters if not owns
or allocator.pin_w(resolution.unit_bounds.get(inv.name))
is not None]
short = [(inv, asked[inv.name], inv.batt) for inv in others
if inv.batt is not None
and delivery_short(asked[inv.name], inv.batt,
self.tuning.saturation_w)]
self._short_run = self._short_run + 1 if short else 0
settling = self._settling(owns, now)
if owns and self.loop_clamped[0] and not (
settling and not self.loop_clamped[2]):
return
engaged = self._short_run >= self.tuning.saturation_cycles
named = len(self.inverters) > 1
self.loop_clamped = (engaged, shortfall_note(
[(inv.name if named else None, a, g) for inv, a, g in short])
if engaged else "", False)
self.loop_clamp_facts = ()
def _settling(self, owns, now) -> bool:
"""SAFETY-119: does the loop's bound on this cycle count?
(DECISIONS/MAIN-orchestration-archive.md)"""
def aged(t0, span):
return now is None or t0 is None or now - t0 >= span
if not owns:
self._wire_lost, self._handback = True, None
return False
if self._wire_lost:
self._wire_lost, self._handback = False, (now, 0)
if self._handback is None:
return False
tc, n = self._handback
n += 1
self._handback = (tc, n)
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
self._handback = None
return False
if self.loop_clamped[0] and not self.loop_clamped[2]:
kept = tuple(t for t in self._hidden if now is None or (
t is not None and now - t < ROLLING_S))
if len(kept) >= ROLLING_BOUND_CYCLES:
self._hidden = kept
return False
self._hidden = kept + (now,)
return True
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
PUBLISHING IS NOT IN THE WRITE PATH.
(DECISIONS/MAIN-orchestration-archive.md)"""
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT.
# (DECISIONS/MAIN-orchestration-archive.md)
clamped_units = [inv for inv in self.inverters
if inv.clamp_last is not None and inv.clamp_last.engaged]
engaged_write = bool(clamped_units)
engaged_loop, loop_note, loop_bug = self.loop_clamped
engaged = engaged_write or engaged_loop
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
bug = engaged_write or loop_bug
if engaged_write:
_named = len(self.inverters) > 1
reason = write_clamp_note([(inv.name if _named else None,
inv.clamp_last)
for inv in clamped_units])
facts = [messages.Clamp(inv.name if _named else None,
inv.clamp_last.requested_w,
inv.clamp_last.applied_w,
inv.clamp_last.bound)
for inv in clamped_units]
else:
reason = loop_note if engaged else ""
facts = self.loop_clamp_facts if engaged else ()
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
bug=bug, facts=facts)
# THE ACTION LOG, AND IT IS COALESCED PER EPISODE Every engagement is
# recorded, but consecutive ones are recorded by UPDATING one row's
# count rather than inserting a row each.
# THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY.
# (DECISIONS/MAIN-orchestration-archive.md)
if engaged and not engaged_write:
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
row = (f"{tag}: "
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
f" - {reason}")
if self.events and tag in self.events[0]:
stamp = self.events[0].split(" ", 1)[0]
self.events[0] = f"{stamp} {row}"
else:
self.log_event(row, hint=messages.hint(
"power_too_high" if bug else "not_keeping_up"))
if not fired:
return
if not self.clamp_wd.bug:
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
# it can never replace a BUG SIGNAL notice still on screen.
_LOG.warning("The battery gives less than asked: %s. Held back %s "
"so far.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug("A short hold after a large load or solar step is "
"normal. If it lasts, check that the battery follows "
"its commands.")
await self.hass.call("persistent_notification", "create",
messages.loop_bound().data())
return
_LOG.error(
"Power request too high, a software fault: %s. Cut back %s so "
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug(
"SAFETY-03: silently clipping this is how a winding integrator "
"reached 14 768 W on this project before. The clamp makes every "
"dashboard read a legal number while the thing producing it goes "
"on climbing. This is an alarm, not a trip.")
# A fixed notification_id, for the reason check_meter_frozen() gives -
# and a DIFFERENT one from every other alarm this add-on raises,
# because a clamp is not a frozen meter and neither is a saturation
# freeze. (DECISIONS/MAIN-orchestration-archive.md)
await self.hass.call("persistent_notification", "create",
messages.output_clamp(self.clamp_wd.facts,
shadow=self.shadow).data())
async def check_peak_saturated(self) -> None:
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
(DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
batt, prev = self.site_batt_w, self._peak_batt_prev
if batt is None:
# Unknown, not "fine": the hold below neither grows nor restarts.
return
self._peak_batt_prev = batt
if prev is not None:
batt = min(float(batt), float(prev))
p = self._peak_proj
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
# projection read "no seconds left" (not high), which restarted the
# hold; a rolled one (battery modes on) is not graded then, so a
# persistent overload is still notified once per quarter.
high = bool(
g is not None and g.saturated and p is not None
and p.block_start is not None and not self._peak_rolled
and peakguard.load_too_high(
p, batt_w=batt, ceiling_w=g.ceiling_w,
achievable_w=self.site_rating_w))
# SAFETY-81: and for ALARM_HOLD_S, on the projection's clock.
saturated = False
if high:
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._peak_high_since is None:
self._peak_high_since = t
saturated = ((t - self._peak_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
else:
self._peak_high_since = None
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
_LOG.error(
"Switch off large loads, the battery alone cannot keep this "
"15-minute period under the peak limit: %s.",
messages.reason_code(g.reason))
_LOG.debug("Evidence: %s.", g.reason)
# SAFETY-81: notified once per quarter; a later episode is logged only.
if p.block_start == self._peak_alarm_block:
return
self._peak_alarm_block = p.block_start
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
await self.hass.call("persistent_notification", "create",
messages.peak_saturated(
g.ceiling_w, shadow=self.shadow).data())
async def check_peak_floor_limited(self) -> None:
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT.
(DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
limited = bool(g is not None and g.floor_limited)
if limited and (g.floor_w or 0.0) > 0.0:
limited = self._reserve_short(g)
else:
self._reserve_batt_prev = self._reserve_high_since = None
# SAFETY-126: nothing can give, so it alarms at once, but only
# when the quarter really misses the aim (`_peak_landing()`).
limited = limited and not g.within_aim
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
# No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
# a second copy is the kind of stutter that makes a 40-line action log
# read like boilerplate. (DECISIONS/MAIN-orchestration-archive.md)
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
_LOG.error(
"Switch off large loads, the peak guard may not draw the battery "
"lower: %s.", messages.reason_code(g.reason))
_LOG.debug("Evidence: %s.", g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.hass.call("persistent_notification", "create",
messages.peak_reserve(
g.ceiling_w, soc_unread=g.soc is None,
floor_unknown=(g.soc is not None
and g.soc_floor is None),
packs=(g.held if len(self.inverters) > 1
else ()),
others=(g.floor_w or 0.0) > 0.0).data())
def _reserve_short(self, g) -> bool:
"""SAFETY-87: the batteries that can still give, at full power, miss
the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as
SAFETY-81 grades 'Load too high'."""
p, batt = self._peak_proj, self.site_batt_w
if batt is None:
return self._peak_floor_limited
prev, self._reserve_batt_prev = self._reserve_batt_prev, batt
if prev is not None:
batt = min(float(batt), float(prev))
if not (p is not None and p.block_start is not None
and peakguard.load_too_high(p, batt_w=batt,
ceiling_w=g.ceiling_w,
achievable_w=g.able_w)):
self._reserve_high_since = None
return False
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._reserve_high_since is None:
self._reserve_high_since = t
return ((t - self._reserve_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
THE LATCH IS NOT THE ALARM.
(DECISIONS/MAIN-orchestration-archive.md)"""
if not self.circ_wd.tripped:
return
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
return
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
# every cycle for a broker that is down would be an alarm storm on the
# event bus on top of a site that is already stopped.
# (DECISIONS/MAIN-orchestration-archive.md)
self._circ_announced = True
self._announced_mono["goodwe_circulating_power"] = _mono()
names = self.units
pos, neg = opposing([inv.batt for inv in self.inverters],
min_w=self.circ_wd.cfg.min_w / 2.0)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# this add-on raises - check_meter_frozen() gives the reasoning.
await self.hass.call("persistent_notification", "create",
messages.circulating(
[names[i] for i in pos] if pos and neg else (),
[names[i] for i in neg] if pos and neg else (),
).data())
def _reannounce_due(self, notification_id: str) -> bool:
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
t = self._announced_mono.get(notification_id)
return t is None or _mono() - t >= REANNOUNCE_S
async def check_not_delivering(self) -> None:
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm.
(DECISIONS/MAIN-orchestration-archive.md)"""
names = self.units
slugs = unit_slugs(names)
for inv, slug in zip(self.inverters, slugs):
nid = f"goodwe_not_delivering_{slug}"
if not inv.delivery_wd.tripped:
continue
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
continue
# Added BEFORE the await, for `check_circulating()`'s reason:
# `hass.call()` can raise or hang, and a retry every cycle for a
# broker that is down would be an alarm storm on the event bus.
# (DECISIONS/MAIN-orchestration-archive.md)
self._delivery_announced.add(inv.name)
self._announced_mono[nid] = _mono()
await self.hass.call("persistent_notification", "create",
messages.not_delivering(inv.name, slug).data())
async def check_price_gap(self) -> None:
"""PRICE-01. One notification when no price covers now, gone when one
does. (DECISIONS/MAIN-orchestration-archive.md)"""
if self.price_gap and not self._price_gap_announced:
self._price_gap_announced = True
await self.hass.call("persistent_notification", "create",
messages.no_prices(*self._gap_args()).data())
elif not self.price_gap and self._price_gap_announced:
self._price_gap_announced = False
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
async def check_fixed_note(self) -> None:
"""PRICE-01 G1. (DECISIONS/MAIN-orchestration-archive.md)"""
if self._fixed_note_said:
return
self._fixed_note_said = True
if fixed_tariff_beside_service(self.o):
_LOG.info(messages.FIXED_TARIFF_LOG)
try:
await self.hass.call("persistent_notification", "dismiss",
messages.fixed_tariff_card_gone().data())
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
_LOG.debug("Could not take down the old fixed-tariff card.")
def _gap_args(self) -> tuple:
"""`(since, now)` in Brussels time for the gap's words. `since` is None
when no price was ever published."""
now = brussels(datetime.now(timezone.utc))
if self.price_gap_since is None:
return None, now
return brussels(self.price_gap_since), now
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
getattr, not an attribute access: `unchanged_s` exists only on
`homewizard_local`, and `p1_source` is None until amain() builds the
transport.
(DECISIONS/MAIN-orchestration-archive.md)"""
unchanged = getattr(self.p1_source, "unchanged_s", None)
# SITE, all three inputs: one meter, the SITE target the arbiter
# resolved, and the rail the SITE could stand at - which is the summed
# per-unit conjunction, so a small inverter B lowers the site rail rather
# than being graded against inverter A's.
fired = self.freeze_wd.evaluate(unchanged, self.target,
self.site_max_w,
datetime.now(timezone.utc),
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
if not fired:
return
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
hint=messages.hint("meter_frozen"))
_LOG.error(
"The meter shows the same value while the battery is asked for "
"full power, and control still follows it, so check the meter: "
"%s.", messages.reason_code(self.freeze_wd.reason))
_LOG.debug("Evidence: %s.", self.freeze_wd.reason)
_LOG.debug(
"SAFETY-18: nothing else stops this. The meter still answers, so "
"its age is fresh, the firmware watchdog is fed and the arbiter "
"only bounds the magnitude. Kept up for a quarter of an hour, it "
"sets the month's billed capacity peak.")
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
# rather than stacking. An operator who has to dismiss eleven identical
# notifications learns to dismiss the twelfth without reading it.
await self.hass.call("persistent_notification", "create",
messages.meter_frozen(unchanged).data())
# -- tasks ---------------------------------------------------------------
# -- OPT-04: the background tariff refresh -------------------------------
def fresh_tariff_plan(self, now):
"""The stored plan if it is still current, else None. NEVER a stale
one.
THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
`loop_is_following()`'s reason: a check has to be able to assert on the
shipped expression rather than on a transcription that drifts.
(DECISIONS/MAIN-orchestration-archive.md)"""
if self.tariff_plan is None or self.tariff_at is None:
return None
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
return None
return self.tariff_plan
async def run_tariff_refresh(self) -> None:
"""Resolve the tariff off the control path, forever. NEVER on the tick.
IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason: a
supplier's outage, a certificate expiry, a malformed feed - all of it
is pricing, and the battery does not care.
(DECISIONS/MAIN-orchestration-archive.md)"""
horizon = float(price_option(self.o, "price_horizon_h"))
while not self.stopping:
now = datetime.now(timezone.utc)
try:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now,
history=self.price_history, watch=self.price_watch)
standing = self.fresh_tariff_plan(now)
# Three terms, and each of them is the only false one in some
# check: the feed answered (replace), nothing current stands
# (take the degradation - this is what makes the age bound
# govern), or what stands is itself a degradation (replace).
keep = (not plan.from_feed
and standing is not None and standing.from_feed)
if keep:
_LOG.warning(
"The price update was incomplete, so the plan from %s "
"is kept until it is %s old. The update held %s.",
messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S), plan.words)
else:
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
except Exception as err: # noqa: BLE001
# ⚠️ NOT THE OUTAGE PATH - see the docstring.
# (DECISIONS/MAIN-orchestration-archive.md)
_LOG.warning(
"The price update failed (%s), so the plan from %s is kept "
"until it is %s old, and trading stops then.",
err, messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S))
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.stop_event.wait(),
timeout=TARIFF_REFRESH_S)
async def run_control(self) -> None:
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4.
# (DECISIONS/MAIN-orchestration-archive.md)
async def _limits(inv):
if inv.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.
# ⚠️ AND THE `if entity:` THAT USED TO WRAP THE WHOLE BLOCK WAS A
# READER THAT FAILED PERMISSIVE - CFG-04's own hazard, in this file.
# (DECISIONS/MAIN-orchestration-archive.md)
problems = []
if entity:
_first = self.inverters[0]
problems += validate_startup_config(self.o, _first.dev_min,
_first.dev_max)
problems += validate_unit_ranges(self.inverters, skip_entity=entity)
if problems:
refuse(problems)
self.refused = problems
self.log_event("REFUSED: " + problems[0],
hint=messages.hint("refused"))
# Signal, 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.
# THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN.
# (DECISIONS/MAIN-orchestration-archive.md)
floor = float(self.o.get("cycle_floor_s", 15))
last_cycle = None
while not self.stopping:
await self.read_inputs()
now_mono = asyncio.get_running_loop().time()
# A cycle is one meter update, exactly as on the reference install -
# or the floor elapsing, whichever comes first.
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
if self.grid != last_grid or overdue:
last_grid = self.grid
last_cycle = now_mono
# ⚠️ The FULL cycle, not a degraded one.
# THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so
# matters more than the guarantee: with `meter_source: off`,
# read_inputs() takes self.grid straight from hass.number()
# with NO age gate at all, so a Home Assistant entity that has
# gone quiet reads as a current value on that path with or
# without this change.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.cycle()
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
# longer than ~30 s as "the controller is gone" and zeroes the
# inverter. Refreshing the SAME value is what proves we are alive.
# ONE DEADLINE PER INVERTER, AND THE REFRESHES GO OUT TOGETHER.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.send_heartbeats(asyncio.get_running_loop().time(),
heartbeat)
self.publish()
self.loop_pass_mono = time.monotonic()
await asyncio.sleep(1)
async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None:
"""Refresh every inverter whose own deadline has come. AC 6 lives here.
IT IS A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` FOR ONE
REASON: the acceptance criterion is "a hung write to A cannot consume
B's layer-1 budget - verified by injecting the hang", and a check
cannot inject a hang into the middle of a `while not self.stopping`
loop that also sleeps, reads inputs and cycles.
(DECISIONS/MAIN-orchestration-archive.md)"""
due = [inv for inv in self.inverters
if loop_now - inv.last_beat >= heartbeat_s]
if not due:
return
# ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in due:
inv.last_beat = loop_now
# ⚠️ CONCURRENTLY. (DECISIONS/MAIN-orchestration-archive.md)
await asyncio.gather(*(
self.write_setpoint(inv.target, force=True, unit=inv)
for inv in due))
def publish(self) -> None:
# ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE.
# (DECISIONS/MAIN-orchestration-archive.md)
values = {
"setpoint": self.target,
"grid": self.grid,
"battery": self.site_batt_w,
"soc": self.pack_floor_soc,
"phase": self.maint.phase,
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
# one. (DECISIONS/MAIN-orchestration-archive.md)
"status": ("failsafe" if any(i.sign_wd.tripped
for i in self.inverters)
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
# SAFETY-10: "shadow" is its own value and not a flavour
# of "stopped".
# (DECISIONS/MAIN-orchestration-archive.md)
else "shadow" if self.shadow
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
# `site_circulating_w`. (DECISIONS/MAIN-orchestration-archive.md)
"circulation": (None if self.site_circulating_w is None
else round(self.site_circulating_w)),
# TEL-10. (DECISIONS/MAIN-orchestration-archive.md)
"loop_i": round(self.loop_i_w, 1),
"loop_lead": round(self.loop_i_lead, 1),
"loop_reason": self.loop_reason,
}
# TEL-11 ⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO
# READING, AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE.
# AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE
# ANNOUNCEMENT USES.
# (DECISIONS/MAIN-orchestration-archive.md)
if "pv" in optional_keys(self.o):
values["pv"] = (None if self.pv_w is None
else round(self.pv_w, 1))
# ⚠️ ONLY when P1 ingestion is actually running.
# (DECISIONS/MAIN-orchestration-archive.md)
if self.p1_enabled:
# Recomputed here, once a second, on purpose - see P1Ingest.
values["p1_age"] = round(self.p1.published_age_s, 1)
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE.
# THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY.
# (DECISIONS/MAIN-orchestration-archive.md)
names = announced_units([inv.name for inv in self.inverters])
for inv, slug in zip(self.inverters, unit_slugs(names)):
values[unit_state_key(slug, "setpoint")] = inv.target
values[unit_state_key(slug, "battery")] = inv.batt
values[unit_state_key(slug, "soc")] = inv.soc
values[unit_state_key(slug, "sign_latch")] = (
"tripped" if inv.sign_wd.tripped else "clear")
# OPT-11 P2: each battery's mode and switch; nothing while modes are off.
values.update(self.mode_values())
self.mqtt.publish(values)
async def shutdown(self) -> None:
"""Deterministic wind-down. Do not skip this."""
self.stopping = True
_LOG.info("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.
(DECISIONS/MAIN-orchestration-archive.md)"""
self.target = 0.0
for inv in self.inverters:
inv.target = 0.0
await self.write_setpoint(0.0, force=True)
# -- UI ------------------------------------------------------------------
def checks(self, when: datetime | None = None) -> list:
"""The status rows. `when` is the reference time for anything billed by
the calendar - today's month when it is None, which is what the live
page means; `status_payload()` calls this with no argument.
IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17).
(DECISIONS/MAIN-orchestration-archive.md)"""
o = self.o
out = []
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
if self.loop_pass_mono is None:
out.append({"ok": True, "warn": False, "id": "controller",
"text": messages.row_controller_starting()})
else:
_pass_age = time.monotonic() - self.loop_pass_mono
if _pass_age > FIRMWARE_INGEST_VETO_S:
out.append({"ok": False, "warn": False, "id": "controller",
"text": messages.row_controller_stalled(
_pass_age, FIRMWARE_INGEST_VETO_S)})
else:
out.append({"ok": True, "warn": False, "id": "controller",
"text": messages.row_controller_ran(_pass_age)})
# SAFETY-112. The headline unless control has stalled: while the
# Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or
# write gets through, and the rows below that need it follow from it.
if OUTAGE.waiting():
out.insert(1 if out[0]["ok"] is False else 0,
{"ok": False, "warn": False, "id": "home_assistant",
"banner": messages.banner_ha_waiting(),
"text": messages.row_ha_waiting()})
if self.p1_enabled:
age = self.p1.published_age_s
if (self.p1.stale and self.p1.age_s() is None
and age <= self.p1.max_age_s):
# UX-10: no reading yet since the start, still inside the
# limit. Amber, not red: it is not a fault yet, but control
# waits for it. Red at 0 s contradicted the limit the row
# prints. (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": True, "id": "meter",
"text": messages.row_meter_waiting(
self.p1.max_age_s)})
elif self.p1.stale:
# The last error is on the add-on log's WARNING line (p1.py).
out.append({"ok": False, "warn": False, "id": "meter",
"text": messages.row_meter_stale(
age, self.p1.max_age_s)})
else:
# ⚠️ unchanged_s is REPORTED, never thresholded and never
# folded into the age - see HomeWizardLocalSource.unchanged_s
# for why (at the converged -10 W this controller aims for, a 1
# Wh register needs ~6 minutes to move, so any limit
# false-trips at the exact operating point we target).
# (DECISIONS/MAIN-orchestration-archive.md)
unchanged = getattr(self.p1_source, "unchanged_s", None)
out.append({"ok": True, "warn": False, "id": "meter",
"text": messages.row_meter(
self.p1.net_w, age, self.p1.parse_errors,
unchanged)})
# SAFETY-18. (DECISIONS/MAIN-orchestration-archive.md)
if self.freeze_wd.active:
# The figures are in `freeze_wd.reason`, on the log and the
# event list (check_meter_frozen()).
out.append({"ok": False, "warn": False, "id": "meter_frozen",
"text": messages.row_meter_frozen()})
elif getattr(self.p1_source, "unchanged_s", None) is None:
# Not "armed". Every transport but homewizard_local is invisible
# to this check, and saying "armed" there would be a lie the
# status page tells on behalf of a guard that cannot run.
out.append({"ok": False, "warn": True, "id": "freeze_check",
"text": messages.row_freeze_off("source")})
elif rail_unreachable(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")):
# SAFETY-20, and THE SECOND REASON TO USE THIS ROW RATHER THAN
# A NEW MECHANISM. (DECISIONS/MAIN-orchestration-archive.md)
_no_rating = (not self.site_max_w
or not is_commandable(self.site_max_w))
out.append({"ok": False, "warn": True, "id": "freeze_check",
"text": messages.row_freeze_off(
"rating" if _no_rating else "range")})
else:
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
# USES, device range included.
# (DECISIONS/MAIN-orchestration-archive.md)
_rail = rail_w(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
# And when the device is what set that number, SAY SO - the
# operator configured `max_w` and is owed the reason the guard
# is armed somewhere else. ⚠️ Either bound may be None on its
# own - `hass.limits()` reads `min` and `max` independently and
# returns None for whichever attribute is missing or
# unparseable - so this renders whatever is there rather than
# formatting a None. (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": True, "warn": False, "id": "freeze_check",
"text": messages.row_freeze_on(
self.freeze_wd.cfg.hold_s, _rail,
own_limit=_rail < rail_w(self.site_max_w))})
# TARIFF-01. (DECISIONS/MAIN-orchestration-archive.md)
_pk = self.peak.peak(when)
if _pk.is_unreadable:
out.append({"ok": False, "warn": True, "id": "month_peak",
"text": messages.row_month_peak_unreadable()})
else:
# §9.1's "warning entity at 80 %", as a status row rather than
# a second entity - see the constructor on not doubling up.
_frac = self.peak.warning_frac(when) or 0.0
_ok = _frac < 0.8
out.append({"ok": _ok, "warn": not _ok, "id": "month_peak",
"text": (messages.row_month_peak_none()
if not _pk.is_observed else
messages.row_month_peak(
_pk.peak_w,
brussels(_pk.quarter_start),
_frac))})
out.append({"ok": True, "warn": False, "id": "periods_measured",
"text": messages.row_periods_measured(
self.peak.recorded)})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same way
# would show a green line over a guard that is switched off.
# (DECISIONS/MAIN-orchestration-archive.md)
_pg = self.peak_guard
if _pg is None:
out.append({"ok": True, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_waiting()})
elif _pg.saturated and not _pg.within_aim:
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited and (
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
or self._peak_floor_limited):
# SAFETY-24. NOT ok, like saturation: in both states the money
# is going to be spent and a human is the only actuator left.
# SAFETY-25.
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"banner": messages.banner_peak_reserve(),
"text": messages.row_peak_reserve(
_pg.ceiling_w, soc_unread=_pg.soc is None,
floor_unknown=(_pg.soc is not None
and _pg.soc_floor is None),
packs=(_pg.held if len(self.inverters) > 1
else ()),
others=(_pg.floor_w or 0.0) > 0.0)})
elif _pg.blind:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_blind(
peakguard.blind_kind(_pg.blind))})
elif self.peak_unmeasured_s > 0.0:
# SAFETY-90: the start of this quarter came before the first
# reading, and the guard counts it as high use.
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_unmeasured()})
elif _pg.engaged:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_on(
_pg.floor_w or 0.0, _pg.ceiling_w,
shadow=self.shadow)})
elif _pg.projected_w is not None:
# SAFETY-125: "under" only when it is; over the limit at this
# rate, a grid charge is cut back to the quarter's room.
out.append({"ok": True, "warn": False, "id": "peak_guard",
"text": messages.row_peak_guard_idle(
_pg.projected_w, _pg.ceiling_w,
month_peak=(_pg.ceiling_from
== peakguard.CEILING_MONTH_PEAK),
cut=self.room_charge and not self.shadow)})
else:
out.append({"ok": True, "warn": False, "id": "peak_guard",
"text": _pg.explain()})
else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the
# point. (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_off()})
# ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row
# on a site with two packs is a page that shows one pack and calls it
# the site. (DECISIONS/MAIN-orchestration-archive.md)
rows = []
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
rows.append((messages.LABEL_SOC, unit, inv.soc, inv.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.
# AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER.
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": False, "id": "sensor",
"text": messages.row_sensor_unusable(label, unit)})
else:
out.append({"ok": True, "warn": False, "id": "sensor",
"text": messages.row_sensor(label, value, unit)})
for inv in self.inverters:
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. (DECISIONS/MAIN-orchestration-archive.md)
_now = datetime.now(timezone.utc)
for inv in self.inverters:
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
(inv.stale_note(), False),
(inv.refusal_note(), False)):
if note:
out.append({"ok": False, "warn": not red, "text": note})
# SAFETY-03. (DECISIONS/MAIN-orchestration-archive.md)
_cw = self.clamp_wd
if _cw.active and _cw.bug:
out.append({"ok": False, "warn": False, "id": "power_too_high",
"banner": messages.banner_power_too_high(),
"text": messages.row_power_too_high(
_cw.facts, _cw.over_rating, shadow=self.shadow)})
elif _cw.active:
# SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_not_keeping_up(
_cw.bound_episodes)})
# No over-rating in THIS episode (`bug` would be set), so any count
# is from an earlier one: keep that software-fault evidence shown.
if _cw.over_rating:
out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": messages.row_before(
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
else:
if _cw.over_rating:
out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": messages.row_before(
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
if _cw.bound_episodes:
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_before(
messages.T_LOOP_BOUND, _cw.bound_episodes)})
for inv in self.inverters:
if inv.last_write_ok is False:
out.append({"ok": False, "warn": False, "id": "write",
"text": messages.row_write_refused(
None if len(self.inverters) == 1
else inv.name)})
# SAFETY-02. (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE.
# (DECISIONS/MAIN-orchestration-archive.md)
if inv.sign_wd.tripped:
out.append({"ok": False, "warn": False, "id": "direction",
"text": messages.row_direction_trip(unit)})
else:
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "direction",
"text": messages.row_direction_check(
held, inv.sign_wd.cfg.hold_s, unit)})
# SAFETY-30: the SITE row ⚠️ ONLY AT N>=2, and the absence at N=1 is
# the honest report rather than a missing row.
# (DECISIONS/MAIN-orchestration-archive.md)
if len(self.inverters) > 1:
if self.site_sign_wd.tripped:
out.append({"ok": False, "warn": False, "id": "site_direction",
"text": messages.row_site_direction_trip()})
else:
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "site_direction",
"text": messages.row_site_direction_check(
_sheld, self.site_sign_wd.cfg.hold_s)})
# SAFETY-31 ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and
# the row says so. (DECISIONS/MAIN-orchestration-archive.md)
_circ = self.site_circulating_w
if self.circ_wd.tripped:
out.append({"ok": False, "warn": False, "id": "circulating",
"text": messages.row_circulating_trip()})
elif len(self.inverters) <= 1:
# AC 5. At the shipped roster the quantity is identically zero for
# every value of every reading, so "armed" would be a claim to be
# watching something that cannot happen. Say what is true instead.
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_one_inverter(
messages.CHECK_CIRCULATING)})
else:
held = self.circ_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_circulating_check(
_circ, held, self.circ_wd.cfg.hold_s)})
# SAFETY-42 ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE
# ONE PLACE THE "reports, does not act" DECISION IS VISIBLE TO AN
# OPERATOR. (DECISIONS/MAIN-orchestration-archive.md)
if len(self.inverters) > 1:
for inv in self.inverters:
if inv.delivery_wd.tripped:
out.append({"ok": False, "warn": True, "id": "delivery",
"text": messages.row_not_delivering(inv.name)})
else:
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "delivery",
"text": messages.row_delivery_check(
inv.name, _dheld,
inv.delivery_wd.cfg.hold_s)})
else:
# AC 5, and the same honesty `circulating power: n/a` uses one
# block up. (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": True, "warn": False, "id": "delivery",
"text": messages.row_one_inverter("Delivery check")})
if not o.get("estop_fitted", False):
out.append({"ok": False, "warn": True, "id": "estop",
"text": messages.row_no_estop()})
else:
out.append({"ok": True, "warn": False, "id": "estop",
"text": messages.row_estop_fitted()})
if not o.get("peak_forecast_entity"):
# ok=False + warn=True renders as a caution, not a pass. A check that
# is both is a check nobody reads.
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_no_peak_forecast()})
elif getattr(self, "_peak_fc_fault", None) is not None:
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_peak_forecast_unit(
self._peak_fc_fault)})
# OPT-17: a forecast summed from several entities names, by position,
# each member with no usable reading. A caution: control goes on.
gaps = getattr(self, "solar_fc_gaps", ())
for day, ents in (("today", getattr(self, "fc_entities", ())),
("tomorrow",
getattr(self, "fc_tomorrow_entities", ()))):
gone = [i for d, i, _e, _w in gaps if d == day]
if gone:
out.append({"ok": False, "warn": True, "id": "solar_forecast",
"text": messages.row_forecast_gap(
day, gone, len(ents))})
# TEL-11 ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A
# DELIBERATE DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE.
# AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a
# CAUTION, never `bad`.
# (DECISIONS/MAIN-orchestration-archive.md)
roster = self.pv_roster
if roster:
# UX-08: sources are numbered in the order of the settings list,
# never named by entity id (STYLE.md). The per-source figures and
# units are what `pv.total()` returns; they stay in `pv_parts`.
flip = pv_read.inverted(self.o)
n_src = len(roster)
if self.pv_w is None and n_src == 1:
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_none(self.pv_why)})
elif self.pv_w is None:
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
# THOSE WORDS - and which sources are missing, by position.
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_partial(
[i + 1 for i, (_e, watt, _y)
in enumerate(self.pv_parts) if watt is None],
n_src)})
else:
out.append({"ok": True, "warn": False, "id": "pv",
"text": messages.row_pv(
self.pv_w, n_src,
n_src == 1 and roster[0] in flip)})
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is
# the rule an operator upgrading from 0.11.0 is most likely to trip
# over. (DECISIONS/MAIN-orchestration-archive.md)
if pv_read.entity_is_ignored(self.o):
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_single_ignored()})
# TEL-14: a sign that looks reversed, per entity rather than on the
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
for i, (ent, watt, _y) in enumerate(self.pv_parts):
if watt is not None and watt < pv_read.NEGATIVE_W:
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_negative(
i + 1, n_src, watt, ent in flip)})
_inv_list = [str(x or "").strip()
for x in (self.o.get("pv_invert_entities") or [])]
for ent in pv_read.stray_inverts(self.o):
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_stray(
_inv_list.index(ent) + 1)})
return out
def status_payload(self, lang: str = "en") -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
# SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while
# control goes on, so it neither reads "Not ready" nor holds the page.
runs = [c for c in bad if c.get("runs")]
bad = [c for c in bad if not c.get("runs")]
running_alarm = waiting = False
# SAFETY-10, AND IT IS FIRST IN THE CASCADE "The battery is NOT being
# controlled" is the one sentence on this page that may never be
# displaced by another condition.
# AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE.
# (DECISIONS/MAIN-orchestration-archive.md)
_shadow = shadow.banner(self.o, control_started=self.auto,
latched=self.shadow_latches())
if _shadow is not None:
level, banner = _shadow
elif self.circ_wd.tripped:
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
# latched P_SAFETY stops, so the site is at 0 W either way and the
# banner can only carry one sentence.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_circulating()
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
# SAFETY-30's site half, ABOVE the per-unit rows below and below
# circulating power above, and the ordering is the same argument
# both times: the banner carries one sentence, so it carries the
# one that says the widest true thing.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_site_direction()
elif any(i.sign_wd.tripped for i in self.inverters):
# Outranks "Not ready": nothing is missing, something went wrong.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_direction(
len(self.inverters) > 1)
elif bad:
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_not_ready(bad[0])
# SAFETY-112: nothing to do but wait, so Home says that instead
# of "Needs you now". Only when it IS the headline: a safety stop
# above outranks it and still needs a person.
waiting = bad[0].get("id") == "home_assistant"
elif not self.auto:
level, banner = "warn", messages.banner_stopped()
elif runs:
level, running_alarm = "bad", True
banner = runs[0].get("banner") or messages.headline_bi(
runs[0]["text"])
elif any(i.delivery_wd.tripped for i in self.inverters):
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
# BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT
# IS NOT COSMETIC.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "warn", messages.banner_not_delivering(
[i.name for i in self.inverters if i.delivery_wd.tripped])
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "warn", messages.banner_maintenance(
self.maint.phase,
self.maint_unit.name if len(self.inverters) > 1 else "")
else:
level, banner = "ok", messages.banner_running()
def w(v):
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a
# site with two schedules has to be the one that comes first, or the
# row says "2026-10-01" while a pack recalibrates tomorrow.
# (DECISIONS/MAIN-orchestration-archive.md)
_now = datetime.now(timezone.utc)
_dues = [m.next_due(_now) for m in self.maints]
_waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u]
next_due = None if any(d is None for d in _dues) else min(_dues)
rows = [
("Grid", w(self.grid)),
("Battery", w(self.site_batt_w)),
# SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is
# not derivable from. "Grid 0 W, Battery 0 W, Circulating 6000 W"
# is the whole card in three rows: both of the numbers an operator
# normally reads say the site is idle, and the third says 6 kW is
# going round between the packs.
# (DECISIONS/MAIN-orchestration-archive.md)
*(() if len(self.inverters) <= 1
else ((("Circulating", w(self.site_circulating_w)),))),
# TEL-11, and ONLY when a source is configured - see `checks()`.
# `w(None)` renders "—", which is how this row says "no reading"
# rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md)
*(() if not self.pv_roster else ((("Solar production",
w(self.pv_w)),))),
("Battery level", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Power request", w(self.target)),
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
# "Commanded 0 W" above and a "Why" below that explains a trade is
# the exact pair an operator would otherwise have to guess at; this
# row is what makes the two of them consistent, and it is rendered
# in EVERY state - including `off`, where it says the battery IS
# being controlled - because a surface whose ABSENCE is how you
# learn something is a surface you have to already know about.
("Shadow mode", shadow.words(
self.o, control_started=self.auto,
would_w=self.shadow_would_w,
# SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE
# - the page runs on its own task and `self.peak_guard` has
# moved on. See `Controller.__init__`.
sized_from_meter=self.shadow_sized_from_meter,
reason=self.why_words)),
("Why", self.why_words),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
# page - where `_maint_deferred` is empty on every tick - carries
# exactly the rows it carried before.
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
for n, why in self._maint_deferred)),)
if self._maint_deferred else ()),
# MAINT-13: only while a failed cycle waits, so a site with nothing
# failed carries exactly the rows it carried before.
*((("Maintenance paused", messages.row_maint_waits(
min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
# PRICE-01: only while no price covers now.
*((("Prices", messages.row_no_prices(*self._gap_args())),)
if self.price_gap else ()),
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
# between a 48-hour plan and a 12-hour one on a weekday morning.
# (DECISIONS/MAIN-orchestration-archive.md)
("Price history", self.price_history.words),
# OPT-04. The strategy's own sentence, not a re-derivation of it -
# every refusal it can make already carries a reason a human can
# act on, and "Arbitrage: no" would throw all of them away.
# TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A COMMENT
# THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION.
# (DECISIONS/MAIN-orchestration-archive.md)
("Arbitrage", arbitrage_row(
on=arbitrage.enabled(self.o) or self.modes_on,
decision=self.arb,
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.why_words)),
]
held = level == "bad" and not running_alarm
# UX-46. (DECISIONS/MAIN-orchestration-archive.md)
checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks]
rows = [(k, messages.pick(v, lang)) for k, v in rows]
banner = messages.pick(banner, lang)
out = {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.hint,
# The action log itself, newest first.
# (DECISIONS/MAIN-orchestration-archive.md)
"events": list(self.events),
}
# UX-13 P1: the Home screen's structured half. Read-only, and it may
# never cost the page its rows: a fault here is logged and the old
# fields are served alone (the page then shows the installer details).
try:
out.update(homeview.build(self, level=level, checks=checks,
lang=lang, held=held, waiting=waiting))
except Exception: # noqa: BLE001
# Once per process at ERROR: the page polls every 3 s.
if not getattr(self, "_home_failed", False):
self._home_failed = True
_LOG.exception("Home view could not be built, so the page "
"shows the plain status rows only")
else:
_LOG.debug("Home view failed again", exc_info=True)
out["home_error"] = True
return out
def forecast_payload(self, lang: str = "en") -> dict:
"""UX-54: the Forecast tab's data. Built on request, from what the
control cycle already holds; no Home Assistant call. Today's blocks are
Home's own, so `status_payload()` is built first and read."""
try:
return dayview.build(self, self.status_payload(lang=lang),
datetime.now(timezone.utc), lang)
except Exception: # noqa: BLE001
if not getattr(self, "_forecast_failed", False):
self._forecast_failed = True
_LOG.exception("The Forecast tab could not be built")
else:
_LOG.debug("The Forecast tab failed again", exc_info=True)
return {"ok": False}
async def handle_action(self, what: str, *, unit=None, value=None):
now = datetime.now(timezone.utc)
if what in ("pack_use", "pack_mode", "pack_sell"):
return self._pack_action(what, unit, value, now)
if what == "auto_toggle":
self.auto = not self.auto
self.store.set("auto", self.auto)
self.log_event("control started" if self.auto else "control stopped",
hint=messages.hint("control_started" if self.auto
else "control_stopped"))
if self.auto:
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
# IT CLEARS EVERY INVERTER'S LATCH, AND SAYS SO PER INVERTER.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
if not inv.sign_wd.tripped:
continue
inv.sign_wd.reset()
inv.store_set("sign_tripped", False)
inv.store_set("sign_reason", "")
self.log_event(f"sign-disagreement failsafe cleared by "
f"operator on {inv.name}",
hint=messages.hint("stop_cleared",
unit=inv.name))
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM,
# UNCONDITIONALLY. (DECISIONS/MAIN-orchestration-archive.md)
self.store.set("sign_tripped", False)
self.store.set("sign_reason", "")
# SAFETY-30's SITE latch, cleared by the SAME press and for the
# same reason the per-unit ones are: the condition always goes
# away on its own, because the trip commands 0 W everywhere and
# a site commanding zero cannot oppose anything.
# UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON
# THE ROSTER.
# (DECISIONS/MAIN-orchestration-archive.md)
if self.site_sign_wd.tripped:
self.log_event("site sign-disagreement failsafe cleared "
"by operator",
hint=messages.hint("site_stop_cleared"))
self.site_sign_wd.reset()
self.store.set("site_sign_tripped", False)
self.store.set("site_sign_reason", "")
# SAFETY-31, cleared by the SAME press and for the same reason:
# the condition always goes away on its own, because the trip
# commands 0 W everywhere and two inverters at zero cannot
# oppose.
# AND `_circ_announced` GOES WITH IT.
# (DECISIONS/MAIN-orchestration-archive.md)
if self.circ_wd.tripped:
self.circ_wd.reset()
self.store.set("circ_tripped", False)
self.store.set("circ_reason", "")
self.log_event("circulating-power trip cleared by operator",
hint=messages.hint("site_stop_cleared"))
self._circ_announced = False
# SAFETY-42, cleared by the SAME press, and for a reason that
# is NOT the one above.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
if inv.delivery_wd.tripped:
self.log_event(f"not-delivering report on {inv.name} "
f"cleared by operator",
hint=messages.hint(
"not_delivering_cleared",
unit=inv.name))
inv.delivery_wd.reset()
inv.store_set("delivery_tripped", False)
inv.store_set("delivery_reason", "")
self._delivery_announced = set()
if not self.auto:
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see
# command_site_zero().
# (DECISIONS/MAIN-orchestration-archive.md)
await self.command_site_zero()
return {"auto": self.auto}
if what in ("maint_start", "maint_abort") and unit is not None:
# MAINT-11: the operator named the battery. A request with no unit
# falls through to the branches below, unchanged.
return await self._maint_unit_action(what, unit, now)
if what == "maint_start":
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
# ONE LABEL. (DECISIONS/MAIN-orchestration-archive.md)
_m = self._maint_button(now)
for msg in _m.force_start(now):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
return {"phase": self.maint.phase}
if what == "maint_abort":
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
_m = self._maint_button(now)
for msg in _m.abort(now, "operator"):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
await self.command_site_zero()
return {"phase": self.maint.phase}
return None
# MAINT-13: a battery power reading at or below minus this many W is a pack
# that is taking charge.
MAINT_CHARGING_W = 50.0
# SAFETY-61. Exit status when the control task dies; 70 is sysexits(3)
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
EXIT_CONTROL_DIED = 70
class ControlTaskDied(RuntimeError):
"""The control task ended while nothing had asked it to stop."""
def mode_announcement(opts, store) -> tuple:
"""OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up.
(DECISIONS/MAIN-orchestration-archive.md)"""
modes = packmode.MODES if packmode.enabled(opts) else ()
stored = store.data.get(MODE_CONFIGS_KEY)
keep, retract = mode_configs(roster_names(opts), modes, stored)
if keep and keep != stored:
store.set(MODE_CONFIGS_KEY, keep)
return modes, retract
def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
died: list) -> None:
"""Done-callback on the control task: log its death now and wake amain()
to wind down and exit (SAFETY-61, see its DECISIONS record)."""
if stop.is_set() or controller.stopping:
return # the normal wind-down, or run_control()'s refusal path
exc = None if task.cancelled() else task.exception()
_LOG.critical("Control stopped unexpectedly (%s), so 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.
(DECISIONS/MAIN-orchestration-archive.md)"""
async def ask(url, field):
"""`(value, why)`: the field, or None and a short reason (UX-09)."""
try:
async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={
"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
data = body.get("data", body) if isinstance(body, dict) else {}
value = data.get(field) if isinstance(data, dict) else None
return value, (None if value else f"no {field} in its answer")
except Exception as err: # noqa: BLE001 - a link, never a stop
# A timeout's own text is empty, so the type is always named.
text = str(err).strip()
return None, (f"{type(err).__name__}: {text}" if text
else type(err).__name__)
(slug, why_slug), (ha, why_ha) = await asyncio.gather(
ask("http://supervisor/addons/self/info", "slug"),
ask("http://supervisor/info", "homeassistant"))
out = {"slug": slug, "ha_version": ha}
# ONE line, whatever happened: the link is a convenience, not a fault.
if slug and ha:
_LOG.info("Documentation link: add-on %s, Home Assistant %s", slug, ha)
else:
missed = [f"{what} ({why})" for what, why in
(("add-on name", why_slug), ("Home Assistant version", why_ha))
if why]
_LOG.info("Documentation link: the Supervisor gave no %s, so the "
"link is guessed or left out.", " and no ".join(missed))
return out
async def ha_time_zone(session, token: str) -> tuple:
"""MAINT-10: `(zone, why)` - Home Assistant's configured time zone, or None
and a short reason. Same 3 s budget as the docs link: never holds up start."""
try:
async with session.get(f"{CORE_API}/config", timeout=DOCS_LINK_TIMEOUT_S,
headers={"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
zone = body.get("time_zone") if isinstance(body, dict) else None
return (zone, "") if zone else (None, "no time_zone in its answer")
except Exception as err: # noqa: BLE001 - a fallback exists, never a stop
text = str(err).strip()
return None, f"{type(err).__name__}: {text}" if text else type(err).__name__
_UTC_NAMES = {"UTC", "ETC/UTC", "GMT", "ETC/GMT", "UCT", "ETC/UCT",
"UNIVERSAL", "ETC/UNIVERSAL", "ZULU", "ETC/ZULU"}
async def maint_site_tz(session, token: str):
"""MAINT-10: the zone the maintenance start hour is read in, logged once."""
zone, why = await ha_time_zone(session, token)
tz, name, source, why = site_zone(zone, why, os.environ.get("TZ"))
if source == "ha":
_LOG.info("Maintenance start hour is local time in %s, from Home "
"Assistant.", name)
elif source == "tz" and name.strip().upper() in _UTC_NAMES:
# The Supervisor sets TZ to its copy of HA's zone, or to UTC if it has
# none: HA did not answer here, and UTC may not be the site's clock.
_LOG.warning("Maintenance start hour uses UTC, because Home Assistant "
"did not answer. Restart the add-on when it is up.")
_LOG.debug("maintenance time zone: %s", why)
elif source == "tz":
_LOG.info("Maintenance start hour is local time in %s, from the "
"add-on's time zone. Home Assistant gave none: %s.", name, why)
else:
_LOG.warning("Maintenance start hour uses UTC, because no time zone "
"was found. Set the time zone in Home Assistant.")
_LOG.debug("maintenance time zone: %s", why)
return tz
async def amain() -> None:
opts = load_options()
logging.basicConfig(
level=getattr(logging, str(opts.get("log_level", "info")).upper(), logging.INFO),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
_LOG.info("GoodWe RS485 Controller starting")
from .store import Store
store = Store()
async with aiohttp.ClientSession() as session:
hass = HomeAssistant(session)
# Startup self-check: prove we can actually reach the Core API before
# anything tries to control an inverter with it.
# (DECISIONS/MAIN-orchestration-archive.md)
import os as _os
_tok = _os.environ.get("SUPERVISOR_TOKEN", "")
_LOG.info("supervisor token: %s (%d chars); env has: %s",
"present" if _tok else "MISSING", len(_tok),
",".join(sorted(k for k in _os.environ if "TOKEN" in k.upper())) or "none")
try:
async with session.get("http://supervisor/core/api/",
headers={"Authorization": f"Bearer {_tok}"},
timeout=10) as _r:
_LOG.info("core api probe: HTTP %s %s", _r.status, (await _r.text())[:80])
except Exception as _e: # noqa: BLE001
_LOG.error("core api probe failed: %s", _e)
# UX-09: the Details link in every notification needs this add-on's
# slug and Home Assistant's version (see messages.docs_url()).
# (DECISIONS/MAIN-orchestration-archive.md)
messages.set_site(**await docs_link_facts(session, _tok))
# ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
# configuration validate.py rejects - specs §1.5: a config the system
# cannot honour is refused at startup, not survived at runtime.
# (DECISIONS/MAIN-orchestration-archive.md)
_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.
# (DECISIONS/MAIN-orchestration-archive.md)
if _problems and setuponly.applies(opts, _problems):
await setuponly.serve(
opts, HomeAssistant(session, read_only=True), store,
port=INGRESS_PORT)
return
if _problems:
refuse(_problems)
raise ConfigRefused(_problems)
# ⚠️ Status publishing must NEVER be able to stop the controller. A
# broken broker, a missing library, an API change in paho - all of it
# is observability, and the battery does not care.
# (DECISIONS/MAIN-orchestration-archive.md)
broker = None
try:
# OPT-11 P2: each battery's switch and mode list, only while
# battery modes are on (`mode_announcement()`).
_modes, _retract = mode_announcement(opts, store)
broker = await hass.mqtt_service()
pub = MqttPublisher(
broker.get("host") if broker else None,
broker.get("port", 1883) if broker else 1883,
broker.get("username") if broker else None,
broker.get("password") if broker else None,
omit=() if is_enabled(opts) else ("p1_age",),
# TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE
# OPTION - `Controller.publish()` gates the row on this same
# call. (DECISIONS/MAIN-orchestration-archive.md)
include=optional_keys(opts),
# TEL-07. ⚠️ THE NAMES, NOT A ROSTER.
# (DECISIONS/MAIN-orchestration-archive.md)
units=roster_names(opts),
modes=_modes,
retract=_retract,
)
except Exception as err: # noqa: BLE001
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
class _NoMqtt:
enabled = False
def publish(self, *a, **k): pass
def close(self): pass
pub = _NoMqtt()
stop = asyncio.Event()
controller = Controller(opts, hass, store, pub, stop=stop,
site_tz=await maint_site_tz(session, _tok))
if callable(getattr(pub, "bind", None)):
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
runner = await web.start(controller, port=INGRESS_PORT)
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
# SAFETY-61: the control task's death ends the process, after the
# wind-down below. See DECISIONS/SAFETY-61-control-task-death.md.
died: list = []
control = asyncio.create_task(controller.run_control())
control.add_done_callback(functools.partial(
control_task_done, controller=controller, stop=stop, died=died))
tasks = [control]
# OPT-04.
# STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES: see
# `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and TARIFF-24
# for why each term is there.
# (DECISIONS/MAIN-orchestration-archive.md)
if tariff_refresh_wanted(opts):
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
# off the control loop: telegrams arrive every ~5 s and the loop would
# decimate them, so the 15-minute average - the capacity-tariff billing
# unit - would be computed from a fraction of the data.
p1_source = build_source(opts, controller.p1, session, broker)
if p1_source is not None:
controller.p1_source = p1_source # so checks() can report on it
tasks.append(asyncio.create_task(p1_source.run()))
await stop.wait()
await controller.shutdown()
for task in tasks:
task.cancel()
for task in tasks:
if task is control and died:
continue # already logged by control_task_done()
with contextlib.suppress(asyncio.CancelledError):
await task
await runner.cleanup()
_LOG.info("stopped")
if died:
raise ControlTaskDied(died[0])
# The late-arriving-device refusal from run_control(). Re-raised only
# now, so the wind-down above still ran and the inverter is at 0 W.
if controller.refused:
raise ConfigRefused(controller.refused)
def main() -> None:
try:
asyncio.run(amain())
except KeyboardInterrupt:
pass
except ConfigRefused:
# ⚠️ No traceback, on purpose.
# (DECISIONS/MAIN-orchestration-archive.md)
raise SystemExit(78)
except ControlTaskDied:
# No second traceback: control_task_done() logged it at the death.
raise SystemExit(EXIT_CONTROL_DIED)
if __name__ == "__main__":
main()