Files
adminandClaude Opus 5.5 6918e154c8
checks / unit (pull_request) Failing after 29m7s
infra-pin / fresh (pull_request) Failing after 2s
SAFETY-97: "gives nothing" is graded against each battery's idle reading
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
2026-10-04 12:21:24 +02:00

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