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