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