OPT-09: the weights parameter nothing ever passed, and what it cannot do

`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:

  * a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
    `bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
    pack is not asked to discharge and its share goes to the sibling;
  * SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
    `_fill()`'s last-resort pass will override the moment the sibling saturates.

Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.

Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.

SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.

Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.

⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-06 22:55:20 +02:00
co-authored by Claude Opus 5
parent 3d2999337d
commit f8e501393f
20 changed files with 2318 additions and 81 deletions
+197 -2
View File
@@ -65,6 +65,7 @@ from .maintenance import (
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
unit_state_key)
from . import peakguard
from . import packshare
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .static_tariff import resolve_tariff, static_status
@@ -145,6 +146,23 @@ def load_options() -> dict:
return {}
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)
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db"):
@@ -628,6 +646,12 @@ class Controller:
# 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.
@@ -1015,6 +1039,55 @@ class Controller:
"""
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.
@@ -1132,6 +1205,44 @@ class Controller:
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.
"""
return packshare.weights(self.pack_energy, target_w)
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings.
@@ -1930,6 +2041,39 @@ class Controller:
# 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
@@ -2202,6 +2346,7 @@ class Controller:
self.arb = None
if arbitrage.enabled(self.o):
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),
@@ -2214,7 +2359,22 @@ class Controller:
soc=self.pack_floor_soc,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
charge_w=max_w, discharge_w=max_w,
# 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
@@ -2419,8 +2579,24 @@ class Controller:
# 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.
resolution = resolve(claims, units=self.units, live=self.live_units(),
tick=self.alloc_tick)
weights=self.pack_weights, tick=self.alloc_tick)
self.allocation = resolution.allocation
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
@@ -2514,6 +2690,25 @@ class Controller:
if self._dropped_last and not _dropped:
self.log_event("ALLOCATOR: every inverter is back in the split")
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()}")
else:
self.log_event("ALLOCATOR: the whole site target fits again")
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: