B's power reading shows -95 to -118 W at idle (standby draw), so the |reading| < 100 W refusal band never held a refusing B: never at N=2, and since SAFETY-94's shared rule not at N=1 either (review F1). Each unit now learns its idle reading (app/idle.py): only while control runs, the unit is not refused/stale/tripped and its command has stayed within 50 W for 120 s; first value a 120 s mean, then a 30 min follow; samples beyond 250 W dropped, value clamped to 250 W; saved per unit and restored. The shared refusal evidence, compute()'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked" test are centred on it. Until learned it is 0 W, the rule as it was. Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s meters), before never; refusing A unchanged. Healthy B with its offset, SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
716 lines
38 KiB
Python
716 lines
38 KiB
Python
"""One inverter, as an OBJECT rather than as a set of Controller scalars.
|
|
|
|
WHAT THIS FIXES, in one sentence: every piece of per-device state in this
|
|
add-on used to be a single scalar on the Controller, and several of those
|
|
scalars are SAFETY INPUTS - so a second inverter would have been graded against
|
|
the first one's numbers.
|
|
|
|
The scalars, and what each one was silently asserting:
|
|
|
|
`dev_min` / `dev_max` ONE pair, read from ONE `setpoint_entity`. A second
|
|
inverter's rating was never read at all, so its clamp,
|
|
its freeze rail and the peak guard's `achievable_w`
|
|
were all inverter A's.
|
|
`soc` ONE state of charge into the maintenance FSM,
|
|
SAFETY-24's pack floor and arbitrage's bounds. An
|
|
average lets the emptier pack be driven below its own
|
|
floor; a maximum parks the fuller one.
|
|
`bms_soc_floor` SAFETY-32. ONE cut-out percent for every pack, and it
|
|
describes A BATTERY. Two packs of different models
|
|
have different cut-outs, so the flat key graded the
|
|
shallower pack against the deeper one's BMS - wrong
|
|
in the direction that empties a pack.
|
|
`batt` ONE battery power into the anti-windup saturation
|
|
detector - see the SITE/UNIT note at the bottom of
|
|
this docstring, because this one goes the OTHER WAY.
|
|
`sign_wd` ONE SignWatchdog, whose own docstring says "one
|
|
instance per actuator", latched under FLAT store keys.
|
|
A trip caused by B latched A across restarts and one
|
|
operator action cleared both.
|
|
`last_write` ONE write bookkeeping, and ONE serialised `hass.call()`
|
|
behind it: a hung write to A ate B's 30 s layer-1
|
|
budget.
|
|
|
|
⚠️ WHAT IS DELIBERATELY *NOT* HERE, AND IT IS THE HALF THAT IS EASY TO GET
|
|
BACKWARDS. THERE IS NO `Tuning` ON THIS OBJECT AND THERE IS NO INTEGRATOR.
|
|
§6.2 and ALLOC-01's stability argument are ONE integrator on P1, ABOVE the
|
|
split: the site target S is divided into per-unit shares, so the gain seen at
|
|
the meter is a CONVEX COMBINATION of the per-unit gains and a bad split cannot
|
|
destabilise the site. Give each inverter its own `Tuning` and somebody will
|
|
eventually call `compute()` with it, and the moment two integrators regulate
|
|
one meter the whole argument is gone - that is the two-writers failure the
|
|
arbiter exists to prevent, one level up.
|
|
|
|
The one field of `Tuning` that genuinely IS per-device is `max_w`, the
|
|
supervised cap on what may be WRITTEN to one setpoint entity (§11.3), and it is
|
|
here as `max_w`. The SITE `Tuning` the loop runs on takes its bound from the
|
|
roster - `sum(inv.max_w)` - so at N=1 it is byte-identical to the shipped
|
|
number and at N>=2 it is what the site can actually deliver. That is the whole
|
|
of "the rating stops being a scalar"; a per-unit Tuning would be three more
|
|
numbers nobody reads and one more place a limit has to be kept in step (CFG-03
|
|
already tracks three).
|
|
|
|
------------------------------------------------------------------------------
|
|
SITE OR UNIT? THE TWO HALVES OF THE N=1 IDENTITY, AND THEY MOVE IN OPPOSITE
|
|
DIRECTIONS
|
|
|
|
At N=1 §6.2's identity holds: the fabricated grid reading and the battery
|
|
setpoint are the same number, so "the site" and "the inverter" are the same
|
|
thing and every scalar above was RIGHT. Splitting them is not one change, it is
|
|
two, and they point opposite ways:
|
|
|
|
COMMANDING becomes per-unit. One setpoint entity per inverter, one rating,
|
|
one clamp, one watchdog, one heartbeat.
|
|
SENSING, for the loop, becomes SITE. `compute()` regulates the METER. Its
|
|
`actual_w` is the anti-windup input to
|
|
`abs(prev_w - actual_w) > saturation_w`, where
|
|
`prev_w` is the SITE target. Feed it ONE unit's
|
|
power at a roster of two and that test compares
|
|
a site number against a unit number, trips the
|
|
freeze permanently and leaves a standing grid
|
|
error.
|
|
|
|
That second half is MEASURED, not argued. On admin/goodwe's two-master rig
|
|
(`tools/alloc_sim.py --splits --feedback unit`), with the loop fed one
|
|
inverter's power the aggregate trace at the meter becomes a function of the
|
|
split - up to 510 W apart at gain 0.2, 250 W at gain 0.4, 50 W at gain 0.6 -
|
|
while the allocator's shares sum to S in every one of those runs. The
|
|
counterfactual is the proof: change that ONE input to the site total and every
|
|
split is bit-identical at every gain the schema accepts. ALLOC-01's review
|
|
isolated it and test_alloc.py part 8 graded it as a disclosed defect waiting on
|
|
this card.
|
|
|
|
So this object owns `batt` - THIS inverter's own power, which is what a
|
|
per-unit consumer needs - and the Controller sums them into the site figure the
|
|
loop is fed. Getting that backwards reintroduces the divergence.
|
|
|
|
------------------------------------------------------------------------------
|
|
LIVENESS, AND WHY IT IS A METHOD WITH A REASON
|
|
|
|
`Controller.live_units()` decides which inverters may be commanded this tick,
|
|
and `allocator.plan()` DROPS what it does not list - the survivors then carry
|
|
the whole site target. A wrong answer there is a real change in what the site
|
|
can deliver, so `is_live()` answers one question and says which: CAN THIS
|
|
CONTROLLER PUT A NUMBER ON THIS INVERTER RIGHT NOW.
|
|
|
|
ONE way it cannot, and exactly one: every write has been REJECTED for a whole
|
|
`LINK_STALE_S` window. Not one rejection - `hass.set_number()` returns False for
|
|
a transient 400 as readily as for a device that has gone - but a continuous run
|
|
of them with no acceptance in between.
|
|
|
|
⚠️ AND NOT "IT HAS NO `setpoint_entity`", THOUGH THAT IS ALSO A UNIT THIS
|
|
CONTROLLER CANNOT COMMAND. The two look the same and want opposite answers. A
|
|
link that WAS working and stopped is an event: the survivors carry its share,
|
|
the record says so, and the site goes on running at reduced strength, which is
|
|
the best available outcome. A roster entry with no writer configured at all is a
|
|
CONFIGURATION that cannot be delivered - it was never reachable, nothing has
|
|
gone wrong on the wire, and quietly running the site at part strength on a
|
|
roster somebody wrote down wrong is how a half-driven site reads as healthy on
|
|
every dashboard. That one belongs to main.py's refusal branch, which commands
|
|
0 W and NAMES the inverter; putting it here would absorb it into the ordinary
|
|
drop and delete the last trigger that branch has. See `Controller.cycle()`.
|
|
|
|
⚠️ AND NOT "ITS SENSORS ARE MISSING" EITHER. A pack whose SoC did not answer is
|
|
a third fault with a third 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 kept regulating - the opposite of what a
|
|
missing measurement should cause. Liveness is about the WRITE PATH, and only
|
|
about a write path that has stopped answering.
|
|
"""
|
|
|
|
import math
|
|
from datetime import datetime
|
|
|
|
from .clamp import ClampResult
|
|
from .delivery import DeliveryConfig, DeliveryWatchdog
|
|
from . import messages
|
|
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
|
# freshness.py imports nothing from this package, so the direction is one-way.
|
|
from .freshness import BOUNDS, Freshness
|
|
from .idle import IdleReading
|
|
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
|
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
|
# shipped default and a literal here would be the fourth defaults site that
|
|
# ticket exists to prevent. peakguard imports arbiter/control/tariff and none
|
|
# of those imports this module, so the direction is one-way.
|
|
from .peakguard import PEAK_OPTION_DEFAULTS
|
|
from .store import unit_key
|
|
from .watchdog import SignWatchdog, SignWatchdogConfig
|
|
|
|
# How long every write to one inverter must have been REJECTED before this
|
|
# controller stops claiming it can command it.
|
|
#
|
|
# ⚠️ IT IS LAYER 1'S OWN WINDOW, NOT A NEW OPINION ABOUT LINKS. The ESP32
|
|
# commands 0 W and keeps commanding it after ~30 s without a refresh
|
|
# (`validate.FIRMWARE_INGEST_VETO_S`), so a link this controller has not been
|
|
# able to write for a whole 30 s is a link the hardware is about to take away
|
|
# from it anyway. Claiming it as commandable past that point is the add-on
|
|
# telling the allocator it can deliver watts that the firmware has already
|
|
# decided it will not. No config key: CFG-03 tracks three places a default has
|
|
# to be kept in step and this does not earn a fourth - it is derived from a
|
|
# constant the firmware and validate.py already share.
|
|
LINK_STALE_S = 30.0
|
|
|
|
# The name of the inverter a SINGLE-INVERTER config describes. ⚠️ A NAME, NOT A
|
|
# CONFIGURATION - it exists so that a per-inverter claim has an actuator to be
|
|
# filed ON, which is what makes the site/unit split something a check can grade
|
|
# rather than a comment. It lives here rather than in main.py now that the
|
|
# roster does.
|
|
#
|
|
# ⚠️ CFG-04 DID NOT MAKE THIS "THE FIRST ONE". It is the name the flat-keys path
|
|
# gives its one unit, and nothing else; a declared `inverters:` roster names its
|
|
# own entries and this constant is not consulted. The store migration
|
|
# (`migrate_unit_keys`) still targets it, because the flat pre-DEV-02 keys can
|
|
# only ever have belonged to a single-inverter install.
|
|
UNIT_A = "inverter-a"
|
|
|
|
# The per-unit keys this object keeps in the Store, namespaced by unit name.
|
|
# ⚠️ THE VALUES ARE THE PRE-DEV-02 FLAT KEY NAMES, and that is what makes the
|
|
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
|
|
# reads this same mapping, so a key renamed here cannot silently stop being
|
|
# migrated. See store.py.
|
|
# ⚠️ SAFETY-42's PAIR IS DELIBERATELY NOT IN THIS TUPLE, AND THE ABSENCE IS THE
|
|
# STATEMENT. The values here are the PRE-DEV-02 FLAT key names that
|
|
# `store.migrate_unit_keys()` moves under a unit; `delivery_tripped` /
|
|
# `delivery_reason` never existed flat, because the check that writes them
|
|
# cannot fire at a roster of one (see delivery.py) and a one-inverter install is
|
|
# the only install that ever had flat keys. Adding them here would make the
|
|
# migration look for keys that cannot exist and would report them as absent.
|
|
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
|
|
|
|
|
|
class Inverter:
|
|
"""ONE inverter: its entities, its rating, its watchdogs, its store keys.
|
|
|
|
Constructed once per configured inverter and held in `Controller.inverters`.
|
|
Everything on it is about THIS device; nothing on it is about the site.
|
|
|
|
⚠️ `hass` IS NOT A CONSTRUCTOR ARGUMENT. This object is built in
|
|
`Controller.__init__`, which runs before any I/O and, in four existing
|
|
checks, with `hass=None` entirely. The device range is read LATER, by
|
|
`Controller.run_control()`, from THIS unit's own `setpoint_entity` - which
|
|
is the whole of "each instance's rating is read from its own entity".
|
|
"""
|
|
|
|
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
|
batt_entity="", batt_invert=False, max_w=2000.0,
|
|
bms_soc_floor=None, capacity_kwh=None, store=None,
|
|
sign_cfg=None, delivery_cfg=None):
|
|
self.name = str(name)
|
|
self.setpoint_entity = str(setpoint_entity or "")
|
|
self.soc_entity = str(soc_entity or "")
|
|
self.batt_entity = str(batt_entity or "")
|
|
self.batt_invert = bool(batt_invert)
|
|
# The supervised cap on what may be written to THIS setpoint entity.
|
|
# See the module docstring on why this is a number and not a `Tuning`.
|
|
self.max_w = float(max_w)
|
|
# SAFETY-32. THIS PACK'S OWN BMS CUT-OUT, in percent, or None when the
|
|
# roster entry did not state one.
|
|
#
|
|
# ⚠️ NOT DEFAULTED HERE, AND None RATHER THAN A NUMBER. A cut-out is a
|
|
# property of the battery in the cupboard; inventing one for a pack that
|
|
# did not state it is precisely this card's defect - a pack graded
|
|
# against a floor that belongs to a different battery - rebuilt in the
|
|
# constructor. `peakguard.pack_floor_pct()` treats None as an unknown
|
|
# floor and `discharge_permitted()` treats an unknown floor as NO
|
|
# permission, so the absence costs a shave; validate.py rule 8 refuses
|
|
# the config by name long before that, at startup.
|
|
self.bms_soc_floor = (None if bms_soc_floor is None
|
|
else float(bms_soc_floor))
|
|
# OPT-09. THIS PACK'S USABLE CAPACITY IN kWh, or None when the roster
|
|
# entry did not state one.
|
|
#
|
|
# ⚠️ OPTIONAL WHERE `bms_soc_floor` IS REQUIRED, AND THE ASYMMETRY IS
|
|
# THE POINT RATHER THAN AN INCONSISTENCY. A missing cut-out would have
|
|
# to be defaulted, and one default for two batteries protects the
|
|
# shallower pack with the deeper pack's number - SAFETY-32's whole card.
|
|
# A missing capacity has an honest answer that costs nothing: this pack
|
|
# states no energy headroom, so `packshare.weights()` has NO OPINION
|
|
# about the split and every unit weighs 1.0, which is exactly the
|
|
# behaviour that shipped. None, never 0.0: a zero-capacity pack is one
|
|
# that may never be used, and nobody decides that by omitting a key.
|
|
self.capacity_kwh = (None if capacity_kwh is None
|
|
else float(capacity_kwh))
|
|
|
|
# --- the device's own reported range, read from its own entity -------
|
|
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
|
# often not up when the add-on boots, and (None, None) is the honest
|
|
# report that the range checks were skipped rather than invented.
|
|
self.dev_min = None
|
|
self.dev_max = None
|
|
|
|
# --- this unit's own measurements ------------------------------------
|
|
self.soc = None
|
|
# ⚠️ SAFETY-37: `batt` IS NOW THE GATED READING, NOT THE RAW ONE. It is
|
|
# None whenever `batt_fresh` says the register is not following its own
|
|
# command - see freshness.py - and a None here is a MISSING input, which
|
|
# §11.1 rule 1 answers with 0 W. The raw number that arrived is on
|
|
# `batt_fresh.last`; read that only for diagnostics, never for control.
|
|
self.batt = None
|
|
# SAFETY-37. One arrival record per SIGNAL per UNIT. Per unit for
|
|
# DEV-02's reason - a freshness verdict about B is not evidence about A,
|
|
# and at N>=2 a shared record would be the flat-scalar defect rebuilt
|
|
# inside the gate that exists to protect the per-unit checks.
|
|
#
|
|
# ⚠️ ONLY `batt` IS WIRED. `BOUNDS["soc"]` exists and is deliberately not
|
|
# constructed here: SoC's obligation term is energy through the pack, not
|
|
# command travel, and the constant it needs has not been measured. That
|
|
# is TEL-08's, and freshness.py's docstring says exactly what it owes.
|
|
self.batt_fresh = Freshness(BOUNDS["batt"])
|
|
|
|
# --- what this unit was last told, and what it last accepted ---------
|
|
# `target` is THIS unit's share of the site target from the last
|
|
# resolution - the number its heartbeat re-emits and the number
|
|
# SAFETY-02 grades against the meter. At N=1 it IS the site target,
|
|
# because the allocator is a pass-through there.
|
|
self.target = 0.0
|
|
self.last_write = None
|
|
self.last_write_at = None
|
|
self.last_write_ok = None
|
|
# ⚠️ TWO TIMESTAMPS, NOT ONE FLAG. `last_write_ok` is the last write's
|
|
# verdict and it flips on a single transient 400; liveness needs a RUN
|
|
# of rejections, so `rejected_since` records when the current run
|
|
# started and is cleared by any acceptance.
|
|
self.rejected_since = None
|
|
# The heartbeat's own deadline, on the event loop's monotonic clock.
|
|
# ⚠️ PER UNIT, WHICH IS THE WHOLE OF AC 6. One shared `last_beat` and
|
|
# one serialised write meant a hung call to A pushed B's refresh past
|
|
# layer 1's ~30 s veto - i.e. one inverter's transport fault zeroing a
|
|
# healthy sibling. Starts at 0.0, which is what the single scalar did.
|
|
self.last_beat = 0.0
|
|
|
|
# --- SAFETY-03's per-write evidence ----------------------------------
|
|
# What THIS unit's last write clamped, or None before it has run. The
|
|
# ALARM (`ClampWatchdog`) stays one per controller - clamp.py says so in
|
|
# its own docstring, and it is an anti-flood counter over an event bus
|
|
# rather than a property of a device - but the EVIDENCE is per unit,
|
|
# because a clamp on B says nothing about A.
|
|
self.clamp_last: ClampResult | None = None
|
|
|
|
# --- SAFETY-02, one instance per actuator, under NAMESPACED keys -----
|
|
# ⚠️ THE WATCHDOG'S OWN DOCSTRING SAID "one instance per actuator" WHILE
|
|
# THE CONTROLLER HELD EXACTLY ONE. Restored from THIS unit's own keys,
|
|
# so a trip caused by B latches B and leaves A commanding - and an
|
|
# operator clearing one does not silently clear the other.
|
|
self.store = store
|
|
data = getattr(store, "data", {}) if store is not None else {}
|
|
self.sign_wd = SignWatchdog(
|
|
sign_cfg or SignWatchdogConfig(),
|
|
tripped=bool(data.get(self.key("sign_tripped"), False)),
|
|
reason=str(data.get(self.key("sign_reason"), "") or ""))
|
|
|
|
# --- SAFETY-42, one instance per unit, under NAMESPACED keys ---------
|
|
# ⚠️ PER UNIT BECAUSE THE CONCLUSION IS ABOUT A UNIT. "This box is not
|
|
# producing what it was told, while its sibling is" names a device, and
|
|
# naming the device is card requirement 2 - the site figure cannot say
|
|
# which one to look at, which is the whole reason SAFETY-30 split
|
|
# per-unit from site. The site-level REDUCTION that decides whether a
|
|
# unit qualifies lives in `delivery.delinquent()` and is computed once
|
|
# per cycle in `Controller.cycle()`; what is per-unit here is the
|
|
# WINDOW and the LATCH.
|
|
#
|
|
# ⚠️ AND IT IS RESTORED FROM THIS UNIT'S OWN KEYS, LIKE `sign_wd`. A
|
|
# latch held only in memory is not a latch: Home Assistant restarts, the
|
|
# `persistent_notification` goes with it, and the fault runs on unseen.
|
|
# That is SAFETY-34, and it is how the 2026-09-02 outage lasted 6 h 25 m.
|
|
self.delivery_wd = DeliveryWatchdog(
|
|
delivery_cfg or DeliveryConfig(),
|
|
tripped=bool(data.get(self.key("delivery_tripped"), False)),
|
|
reason=str(data.get(self.key("delivery_reason"), "") or ""))
|
|
|
|
# SAFETY-73. The direction this unit's battery refuses (+1 give, -1
|
|
# take, 0 none), when that refusal or its last probe started, the
|
|
# current probe's start, and the cycles of evidence so far and when
|
|
# they began. Memory only, like SAFETY-52's: stop and start end it.
|
|
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
|
|
self.refused = 0
|
|
self.refused_at = None
|
|
self.probe_from = None
|
|
self.ref_count = 0
|
|
self.ref_since = None
|
|
self.ref_sign = 0
|
|
self.ref_from = None
|
|
# SAFETY-97: what this unit's reading shows at idle; "gives nothing"
|
|
# is centred on it. Learned, persisted, restored (app/idle.py).
|
|
self.idle = IdleReading(data.get(self.key("idle_w")))
|
|
|
|
@property
|
|
def idle_w(self) -> float:
|
|
return self.idle.w
|
|
|
|
# -- store ---------------------------------------------------------------
|
|
def key(self, name: str) -> str:
|
|
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
|
return unit_key(self.name, name)
|
|
|
|
def store_set(self, name: str, value) -> None:
|
|
if self.store is not None:
|
|
self.store.set(self.key(name), value)
|
|
|
|
# -- the numbers other modules ask this object for -----------------------
|
|
def standing_w(self) -> float:
|
|
"""What this controller has standing on THIS unit's wire right now.
|
|
|
|
⚠️ ONE DEFINITION, TWO CALLERS, and it was a transcription before
|
|
SAFETY-37. `cycle()` computes this for the sign watchdog and
|
|
`_read_unit()` needs the identical number for the freshness conjunction;
|
|
two spellings of "what is standing" would be two answers to the question
|
|
the gate and the check it protects are both asking, and the copy that
|
|
drifts is always the one further from the wire.
|
|
|
|
`last_write` is the 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 what `last_write` holds.
|
|
"""
|
|
return float(self.last_write if self.last_write is not None
|
|
else self.target)
|
|
|
|
def rating_w(self) -> float:
|
|
"""The largest magnitude this unit can be commanded, in W.
|
|
|
|
`min(max_w, |dev_min|, |dev_max|)` - the supervised cap and whatever the
|
|
device itself reports, whichever is smaller. It is the same conjunction
|
|
`freeze.rail_ceiling_w()` computes for the freeze rail and the same one
|
|
main.py hands the peak guard as `achievable_w`, which is why it is
|
|
written once here: three transcriptions of a rating is three chances for
|
|
inverter B to be graded against inverter A's number.
|
|
|
|
A bound that could not be read is SKIPPED, not treated as zero: (None,
|
|
None) means ESPHome was not up, and answering 0 W there would say this
|
|
inverter can do nothing.
|
|
"""
|
|
out = abs(float(self.max_w))
|
|
for bound in (self.dev_min, self.dev_max):
|
|
if bound is not None and math.isfinite(float(bound)):
|
|
out = min(out, abs(float(bound)))
|
|
return out
|
|
|
|
def has_writer(self) -> bool:
|
|
return bool(self.setpoint_entity)
|
|
|
|
# -- liveness ------------------------------------------------------------
|
|
def note_write(self, ok: bool, now: datetime) -> None:
|
|
"""Record one write's verdict. The only thing that moves liveness."""
|
|
self.last_write_ok = bool(ok)
|
|
if ok:
|
|
self.rejected_since = None
|
|
elif self.rejected_since is None:
|
|
self.rejected_since = now
|
|
|
|
def is_live(self, now: datetime, stale_s: float = LINK_STALE_S):
|
|
"""`(live, why_not)` - may this controller command this unit THIS TICK?
|
|
|
|
Read the module docstring before widening this - in particular on why a
|
|
unit with NO `setpoint_entity` is deliberately still "live" here and is
|
|
refused one layer up instead. The allocator DROPS what `live_units()`
|
|
does not list and the survivors carry the whole site target, so a false
|
|
"not live" is a site target divided among fewer inverters than exist -
|
|
and at a roster of one it is the allocator refusing outright and the
|
|
site commanding 0 W.
|
|
|
|
⚠️ ANSWERED PER TICK, NEVER CACHED, for `allocator.plan()`'s reason: a
|
|
link goes stale between one cycle and the next.
|
|
"""
|
|
if self.rejected_since is not None:
|
|
held = (now - self.rejected_since).total_seconds()
|
|
if held >= float(stale_s):
|
|
return False, (f"every write rejected for {held:.0f} s "
|
|
f"(limit {float(stale_s):.0f} s)")
|
|
return True, ""
|
|
|
|
def stale_note(self) -> str:
|
|
"""SAFETY-37's one line for the status page. Empty when nothing to say.
|
|
|
|
Separate from `link_note()` because they are about different halves of
|
|
the device and want opposite remedies: liveness is about the WRITE path
|
|
and its answer is to drop this unit and let the survivors carry it,
|
|
while a stale MEASUREMENT is about the read path and its answer is to
|
|
stop the site (`inputs_ok`). Folding one into the other is how a
|
|
measurement fault ends up handing a sibling extra power.
|
|
"""
|
|
return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
|
|
else "")
|
|
|
|
def refusal_note(self) -> str:
|
|
"""SAFETY-73's status row while this unit's battery refuses."""
|
|
if not self.refused:
|
|
return ""
|
|
return messages.row_battery_refuses(self.name, self.refused,
|
|
self.probe_from is not None)
|
|
|
|
def link_note(self, now: datetime) -> str:
|
|
"""One line for the status page. Empty when there is nothing to say.
|
|
|
|
The words are `app/messages.py`'s (UX-09). main.py colours the row red
|
|
exactly when `is_live()` is False.
|
|
"""
|
|
if not self.has_writer():
|
|
return messages.row_no_setpoint(self.name)
|
|
live, _why = self.is_live(now)
|
|
held = (0.0 if self.rejected_since is None
|
|
else (now - self.rejected_since).total_seconds())
|
|
if not live:
|
|
return messages.row_not_controllable(self.name, held, LINK_STALE_S)
|
|
if self.rejected_since is not None:
|
|
return messages.row_commands_refused(self.name, held, LINK_STALE_S)
|
|
return ""
|
|
|
|
def __repr__(self) -> str:
|
|
return (f"<Inverter {self.name} sp={self.setpoint_entity!r} "
|
|
f"max_w={self.max_w:.0f} dev=({self.dev_min}, {self.dev_max})>")
|
|
|
|
|
|
def roster_entries(opts: dict) -> list:
|
|
"""The `inverters:` option, normalised to a list of dicts. Never None.
|
|
|
|
Separate from `build_roster()` because validate.py needs to grade the SAME
|
|
list this builds from, and a second transcription of "what counts as an
|
|
entry" is a second answer to it. A non-list, or a list with non-dict
|
|
members, comes back as it was so the validator can name what it found -
|
|
dropping the junk here would hand the validator a clean list and a config
|
|
nobody refused.
|
|
"""
|
|
entries = opts.get("inverters")
|
|
if entries is None or entries == "":
|
|
return []
|
|
if not isinstance(entries, (list, tuple)):
|
|
return [entries]
|
|
return list(entries)
|
|
|
|
|
|
def roster_names(opts: dict) -> list:
|
|
"""The unit names, in roster order. THE one answer, for two callers.
|
|
|
|
⚠️ IT EXISTS BECAUSE MQTT DISCOVERY CANNOT WAIT FOR A ROSTER. TEL-07 gives
|
|
every unit past the first its own MQTT device, and `MqttPublisher` is
|
|
constructed in `main()` BEFORE `Controller.__init__` builds the roster -
|
|
announcing happens on the broker's CONNACK, which can land first. Building a
|
|
second roster there would construct a second set of `Inverter`s off the same
|
|
Store; transcribing the naming rule there would be a third spelling of it.
|
|
So the naming rule lives here, `build_roster()` reads it, and the two cannot
|
|
drift.
|
|
|
|
⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries both
|
|
called `inverter-a` would share one Store namespace, so one unit's SAFETY-02
|
|
latch would be the other's - a fault on B latching A is the exact failure
|
|
DEV-02's namespaced keys removed. Rule 9 refuses a nameless entry outright;
|
|
this only decides what the refusal is allowed to call it in the meantime.
|
|
|
|
An EMPTY `inverters:` is the flat-key shape and its one unit is `UNIT_A`,
|
|
which is what every install running today reads.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
return [UNIT_A]
|
|
names = []
|
|
for i, entry in enumerate(entries):
|
|
entry = entry if isinstance(entry, dict) else {}
|
|
names.append(str(entry.get("name") or "").strip() or f"inverter-{i + 1}")
|
|
return names
|
|
|
|
|
|
def build_roster(opts: dict, store, sign_cfg=None,
|
|
delivery_cfg=None) -> list:
|
|
"""The site's inverters, in roster order.
|
|
|
|
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
|
`inverters:` empty?
|
|
|
|
EMPTY the six flat keys - `setpoint_entity`, `soc_entity`,
|
|
`batt_entity`, `batt_invert`, `max_w`, `bms_soc_floor` (SAFETY-32)
|
|
- describe the ONE inverter,
|
|
exactly as the shipped scalars did. This is what every install
|
|
that upgrades into this version reads, without an operator
|
|
touching anything, and it is byte-identical to what DEV-02
|
|
shipped. config.yaml's default is `[]` for precisely that reason,
|
|
and Supervisor 2026.08.0 refuses the whole options blob without
|
|
it - see the options-block note on `inverters:`.
|
|
|
|
NON-EMPTY each entry describes one inverter and the flat keys describe
|
|
none of them. OPT-09's `capacity_kwh` is an entry key too, and an
|
|
OPTIONAL one - see `_entry_capacity_kwh()` and `Inverter.__init__`
|
|
on why that absence is answerable where a missing cut-out is not. ⚠️ NOTHING IS INHERITED. An entry that omits
|
|
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
|
|
because "this unit's number came from somewhere else" is the
|
|
defect DEV-02 spent a card removing and a fallback here would
|
|
rebuild it in three lines. `name`, `setpoint_entity`, `max_w` and
|
|
`bms_soc_floor` (SAFETY-32) are required of every entry - the schema enforces it on the
|
|
Supervisor path, `validate.py` rule 9 enforces it for sim/inject.py
|
|
and the checks, which reach this function with any dict at all.
|
|
|
|
⚠️ THIS FUNCTION REFUSES NOTHING, AND THAT IS ON PURPOSE. A refusal here
|
|
happens inside `Controller.__init__`, before the startup gate has run and
|
|
before anything can render the reason to an operator; validate.py's rule 9
|
|
is the place that says no, with a message, through `refuse()`. So this
|
|
builds the most honest roster it can out of whatever it was handed - a
|
|
missing name becomes a positional one, a missing rating becomes 0.0 W - and
|
|
every one of those states is something rule 9 has already refused by the
|
|
time a control loop exists. Keep the two in step: a shape this tolerates
|
|
that rule 9 does not name is a shape that runs.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
# ⚠️ THE UNCHANGED PATH. Same name, same entities, same rating, same
|
|
# store keys as before CFG-04 - an install that upgrades reads
|
|
# identically, which is AC 3.
|
|
return [Inverter(
|
|
UNIT_A,
|
|
setpoint_entity=opts.get("setpoint_entity", ""),
|
|
soc_entity=opts.get("soc_entity", ""),
|
|
batt_entity=opts.get("batt_entity", ""),
|
|
batt_invert=bool(opts.get("batt_invert")),
|
|
max_w=_flat_max_w(opts),
|
|
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
|
|
# - the one pack the flat options describe is the one pack whose
|
|
# cut-out `bms_soc_floor` states. Defaulted from CFG-03's single
|
|
# mirror exactly as validate.py rule 8 defaults it, so an install
|
|
# that never set the key reads the same 10 % in both places.
|
|
bms_soc_floor=_flat_bms_floor(opts),
|
|
# ⚠️ OPT-09 ADDS NO FLAT `capacity_kwh`, AND THE ABSENCE IS THE
|
|
# DECISION. Capacity is read for ONE purpose - weighting a split
|
|
# between two packs - and at the single-inverter shape there is no
|
|
# split: `allocator.plan()` is a pass-through at N<=1 and a weight
|
|
# is arithmetic nobody reads. A flat key would be an option an
|
|
# operator can set, that the add-on reads, and that changes nothing,
|
|
# which is the worst of the three.
|
|
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
|
|
|
|
roster = []
|
|
names = roster_names(opts)
|
|
# ⚠️ NAMES COME FROM `roster_names()`, NOT FROM A SECOND READING OF THE
|
|
# ENTRIES. TEL-07 announces one MQTT device and one `unique_id` per unit off
|
|
# these names, and it has to build them BEFORE a `Controller` exists - so a
|
|
# second spelling of the fallback here would be an entity announced under a
|
|
# name no unit answers to. `zip` pairs them positionally, which is what
|
|
# `roster_names()` is defined to be.
|
|
for entry, name in zip(entries, names):
|
|
entry = entry if isinstance(entry, dict) else {}
|
|
roster.append(Inverter(
|
|
name,
|
|
setpoint_entity=entry.get("setpoint_entity", ""),
|
|
soc_entity=entry.get("soc_entity", ""),
|
|
batt_entity=entry.get("batt_entity", ""),
|
|
batt_invert=bool(entry.get("batt_invert")),
|
|
# ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did
|
|
# not state is not the flat one, it is a rating rule 9 refuses.
|
|
max_w=_entry_max_w(entry),
|
|
# ⚠️ NOT `opts["bms_soc_floor"]`, FOR `max_w`'S REASON AND THIS
|
|
# CARD'S. An entry that omits its cut-out has NOT got the flat one;
|
|
# it has none, and rule 8 refuses it by name. Borrowing here would be
|
|
# SAFETY-32's own defect written in three lines - the shallower pack
|
|
# graded against the deeper pack's BMS.
|
|
bms_soc_floor=_entry_bms_floor(entry),
|
|
# OPT-09, and NOT inherited either, for the reason above it: a
|
|
# capacity borrowed from a sibling would weight a 15.36 kWh pack as
|
|
# though it were a 20.48 kWh one, which is the split this card
|
|
# exists to stop being wrong.
|
|
capacity_kwh=_entry_capacity_kwh(entry),
|
|
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
|
|
return roster
|
|
|
|
|
|
def _flat_bms_floor(opts: dict):
|
|
"""The single-inverter shape's own cut-out, or None if it is unparseable.
|
|
|
|
Defaulted from `PEAK_OPTION_DEFAULTS` when the key is absent or blank, which
|
|
is `validate._number()`'s behaviour on the same key - the two have to agree
|
|
or the validator would grade a number the roster does not build.
|
|
"""
|
|
# ⚠️ ONE defaulting line, not two. The first cut wrote the fallback into the
|
|
# `.get()` AND into the blank test below it, and a mutation that deleted
|
|
# either one survived - each was covering for the other, which is
|
|
# MUTATION.md's masking section in three lines of configuration reading.
|
|
raw = opts.get("bms_soc_floor")
|
|
if raw is None or raw == "":
|
|
return float(PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
|
try:
|
|
return float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _entry_bms_floor(entry: dict):
|
|
"""One entry's OWN cut-out, or None when it did not state a usable one.
|
|
|
|
None rather than a borrowed number and rather than 0.0 - `_entry_max_w()`
|
|
can answer 0.0 because a zero rating commands nothing, but a zero FLOOR is
|
|
the most permissive value the key has and would read as "this pack may be
|
|
emptied". Rule 8 refuses the missing key by name; until it does, None
|
|
withholds.
|
|
"""
|
|
raw = entry.get("bms_soc_floor")
|
|
if raw is None or raw == "":
|
|
return None
|
|
try:
|
|
return float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _flat_max_w(opts: dict) -> float:
|
|
"""The single-inverter shape's cap, tolerating what rule 9 tolerates.
|
|
|
|
⚠️ THIS USED TO BE A BARE `float(opts.get("max_w", 2000))` AND IT
|
|
DISAGREED WITH THE VALIDATOR. `validate.py`'s `_number()` reads `""` as
|
|
"use the default", so rule 9 looks at an empty `max_w` box, sees 2000 and
|
|
is content - while the bare `float("")` here raised ValueError from inside
|
|
`Controller.__init__`. Through UX-02's panel that surfaced as HTTP 500 on a
|
|
non-JSON body, which the page reported as "cannot reach the add-on": untrue,
|
|
and for the most prominent limit field's most likely transient state.
|
|
|
|
That inverted this module's own docstring rule - "keep the two in step: a
|
|
shape this tolerates that rule 9 does not name is a shape that runs" - in
|
|
the other direction: a shape rule 9 TOLERATED could not be composed at all.
|
|
The roster path never had the bug, because `_entry_max_w` below already
|
|
guards; only the flat path did.
|
|
|
|
Empty or absent is the default, exactly as `_number()` reads it.
|
|
Unparseable is 0.0, exactly as `_entry_max_w` reads it - and rule 9 refuses
|
|
it by name at line 790, with a sentence, before a control loop exists.
|
|
"""
|
|
raw = opts.get("max_w", 2000)
|
|
if raw is None or raw == "":
|
|
return 2000.0
|
|
try:
|
|
return float(raw)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
def _entry_capacity_kwh(entry: dict):
|
|
"""One entry's OWN usable capacity in kWh, or None when it stated none.
|
|
|
|
None rather than 0.0 and rather than a borrowed number: `_entry_max_w()` can
|
|
answer 0.0 because a zero rating commands nothing and rule 9 refuses it by
|
|
name, but a zero CAPACITY is a pack with no energy headroom in either
|
|
direction, i.e. a pack the weighting would silently stop preferring. "Not
|
|
stated" and "empty" must not be the same answer - `packshare.weights()`
|
|
turns the first into "no opinion at all" and the second into a weight of
|
|
zero, and those are different splits.
|
|
|
|
A NON-POSITIVE or unparseable value also answers None. Rule 9 refuses it at
|
|
startup with a message; until it does, withholding is the direction that
|
|
cannot invent a preference.
|
|
"""
|
|
raw = entry.get("capacity_kwh")
|
|
if raw is None or raw == "":
|
|
return None
|
|
try:
|
|
val = float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return val if val > 0.0 else None
|
|
|
|
|
|
def _entry_max_w(entry: dict) -> float:
|
|
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
|
|
|
0.0 rather than a borrowed number, and rather than an exception: rule 9
|
|
refuses `max_w` missing or unparseable by name, and a roster that cannot be
|
|
built at all would refuse it with a traceback instead.
|
|
"""
|
|
try:
|
|
return float(entry.get("max_w"))
|
|
except (TypeError, ValueError):
|
|
return 0.0
|