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:
co-authored by
Claude Opus 5
parent
3d2999337d
commit
f8e501393f
@@ -394,6 +394,14 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
`.pinned_by` and `.contradiction` so that this module does not
|
||||
import the arbiter and cannot grow a dependency on claims.
|
||||
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
|
||||
⚠️ A MAPPING, ALWAYS - never a function. OPT-09 lets `resolve()`
|
||||
accept a callable of the site target, because an energy-headroom
|
||||
weighting is direction-dependent and the direction is stage 2's
|
||||
answer; `resolve()` calls it and hands the RESULT here. Keeping
|
||||
that out of this module is what keeps `plan()` a pure function of
|
||||
numbers and intervals: a callback invoked inside the fill would
|
||||
put a caller's code, and whatever it reads, back into the module
|
||||
whose whole discipline is that it reads nothing.
|
||||
`live` the units whose master has reported recently enough to be
|
||||
commanded, or None for "all of them". A unit not listed is
|
||||
DROPPED and the record says so; the survivors then carry the
|
||||
|
||||
@@ -205,6 +205,27 @@ CLAIM_SCOPE = {
|
||||
# At N=1 the two are the same comparison and this claim is the one filed,
|
||||
# exactly as it shipped.
|
||||
"failsafe": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("pack", P_SAFETY, lo, hi, ...)`, OPT-09. THIS PACK'S
|
||||
# OWN STATE OF CHARGE against THIS PACK'S OWN `bms_soc_floor`, filed only
|
||||
# where it BINDS and only at a roster of two or more. It says "this battery
|
||||
# cannot do that", which is a fact about one cupboard in the same way
|
||||
# `device`'s rating is a fact about one box, so it is scoped like `device`
|
||||
# and filed at the same priority.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT SAFETY-25's DEFERRED LOOP-SIDE RESERVE, WHICH LOOKS
|
||||
# EXACTLY LIKE IT AND IS THE REASON test_peakguard.py PART 10f EXISTS. That
|
||||
# one was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
|
||||
# `loop_soc_floor` option - an EMS preference, chosen by an operator,
|
||||
# stopping the grid-following loop at a depth somebody picked. This is the
|
||||
# pack's own BMS cut-out, which is measured off register 0x0560 and required
|
||||
# of every roster entry since SAFETY-32; no option is added, `peak_soc_floor`
|
||||
# is deliberately NOT consulted, and the SITE target is not touched - only
|
||||
# which unit carries it. Part 10f's own note names the revisit trigger:
|
||||
# "under ALLOC-01 the reserve becomes a PER-INVERTER INTERVAL AT THE
|
||||
# ALLOCATOR rather than a ninth claim here", and stage 1 is how an interval
|
||||
# reaches the allocator, so a unit-scoped claim is the shape that sentence
|
||||
# asks for rather than an evasion of it.
|
||||
"pack": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
|
||||
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
|
||||
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
|
||||
@@ -544,7 +565,10 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
|
||||
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
|
||||
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
|
||||
wear - and cannot destabilise the site whatever it says. `live` is the set
|
||||
wear - and cannot destabilise the site whatever it says. ⚠️ OPT-09 LET IT BE
|
||||
A CALLABLE as well as a mapping: called with the resolved site target once
|
||||
stage 2 has run, because an energy-headroom weighting is direction-dependent
|
||||
and the direction is stage 2's answer. See the call site below. `live` is the set
|
||||
of units whose master has reported recently enough to be commanded; a unit
|
||||
left out of it is DROPPED from the envelope set and the record says so,
|
||||
because the survivor carrying a dead unit's share silently is the failure
|
||||
@@ -600,6 +624,27 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
# left as a log line nothing grades.
|
||||
out.scope_errors = scope_violations(claims)
|
||||
out.unit_bounds = bounds
|
||||
# OPT-09. THE WEIGHTS MAY ARRIVE AS A FUNCTION OF THE SITE TARGET, and this
|
||||
# is the one line that makes SoC-aware weighting expressible at all.
|
||||
#
|
||||
# ⚠️ THE REASON IS THAT THE DIRECTION IS NOT KNOWN ANY EARLIER. A weighting
|
||||
# by ENERGY HEADROOM 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 site target is chosen by
|
||||
# stage 2, three lines up, so a caller computing weights before `resolve()`
|
||||
# would have to guess which way the site is about to go, and would be wrong
|
||||
# in one of the two directions on every tick.
|
||||
#
|
||||
# ⚠️ IT IS CALLED ONCE, HERE, BETWEEN STAGE 2 AND STAGE 3, AND NEVER FROM
|
||||
# INSIDE THE ALLOCATOR. `allocator.plan()` goes on taking a plain mapping,
|
||||
# which is what keeps it a pure function of numbers and intervals: a
|
||||
# callback invoked inside the fill would put a caller's code - and whatever
|
||||
# it reads - back into the module whose whole discipline is that it reads
|
||||
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
|
||||
# own direction can differ from the target's sign under a pin, in which case
|
||||
# the weighting is a worse preference and still cannot be unsafe).
|
||||
if callable(weights):
|
||||
weights = weights(out.target_w)
|
||||
out.shares, out.allocated, out.allocation = allocate_record(
|
||||
out.target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
||||
|
||||
@@ -173,6 +173,17 @@ IDLE_NO_SOC = (
|
||||
"state of charge is not being read, and a battery whose fill nobody knows "
|
||||
"cannot be told to empty or to fill")
|
||||
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
|
||||
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
|
||||
# and are now SIZED to the packs whose state of charge permits that direction, so
|
||||
# a caller can hand this function a zero. A zero must not reach `claim()`: a
|
||||
# `Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the loop,
|
||||
# which is the failure the note on `claim()` is written against - it would
|
||||
# silently disable self-consumption for the whole hour. So the sizing is checked
|
||||
# where the decision is made rather than trusted to arrive positive.
|
||||
IDLE_NO_SIZE = (
|
||||
"no pack's state of charge permits this side of the trade, so it is sized "
|
||||
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
|
||||
NEGATIVE_PRICE = (
|
||||
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
|
||||
"energy away and pay for the privilege. Arbitrage must not export at a "
|
||||
@@ -372,6 +383,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
|
||||
f"arbitrage may not spend (§10.1 priority 4)", **common)
|
||||
if not abs(float(discharge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=DISCHARGE, power_w=abs(float(discharge_w)),
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
|
||||
@@ -388,6 +401,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
if not abs(float(charge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-abs(float(charge_w)),
|
||||
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||
|
||||
@@ -192,8 +192,8 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
bms_soc_floor=None, store=None, sign_cfg=None,
|
||||
delivery_cfg=None):
|
||||
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 "")
|
||||
@@ -215,6 +215,20 @@ class Inverter:
|
||||
# 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
|
||||
@@ -482,7 +496,9 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
it - see the options-block note on `inverters:`.
|
||||
|
||||
NON-EMPTY each entry describes one inverter and the flat keys describe
|
||||
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
|
||||
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
|
||||
@@ -519,6 +535,13 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
# 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 = []
|
||||
@@ -546,6 +569,11 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
# 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
|
||||
|
||||
@@ -617,6 +645,30 @@ def _flat_max_w(opts: dict) -> float:
|
||||
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""OPT-09 - what each pack can still do, in the two forms stage 3 accepts.
|
||||
|
||||
ARCH-01 left the allocator ONE free parameter - `weights` - and ALLOC-01 built
|
||||
the machinery that consumes it (`allocator._fill`). Nothing ever passed one.
|
||||
`main.py` called `resolve()` without `weights=`, `arbiter.resolve()` defaulted
|
||||
it to None and `allocator.plan()` turned that into 1.0 for every unit, so the
|
||||
split was a fixed 50/50 REGARDLESS OF WHAT EITHER PACK COULD DO. This module is
|
||||
what fills that argument, and it fills the other one too - the per-unit
|
||||
interval - because on their own the weights cannot do the job. Read the two
|
||||
sections below in order; the second is the important one.
|
||||
|
||||
⚠️ THE TWO ARE NOT INTERCHANGEABLE, AND THE ALLOCATOR SAYS SO FIRST
|
||||
===================================================================
|
||||
`allocator.py`'s docstring states the rule this module is built on:
|
||||
|
||||
WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
|
||||
"please do not use this unit", and it is honoured while any other unit has
|
||||
headroom - but it is NOT a constraint [...] To make a unit genuinely
|
||||
unusable, close its envelope or leave it out of `live`.
|
||||
|
||||
That is mechanical, not advisory: `_fill()` runs a SECOND, last-resort pass over
|
||||
the zero-weight units when the preferred ones fill up, deliberately, so that a
|
||||
preference can never manufacture a standing error at the meter. So a pack at its
|
||||
own cut-out that is only WEIGHTED to zero is still commanded to discharge the
|
||||
moment its sibling saturates. Closing its `hi` is what actually stops the ask.
|
||||
|
||||
Hence this module produces two things and they answer two different questions:
|
||||
|
||||
envelope(pack) MAY this pack be asked, at all, in this direction?
|
||||
-> a `hi`/`lo` that main.py files as a UNIT claim, and the
|
||||
allocator obeys absolutely (`cap` is never overridden).
|
||||
weights(packs,S) GIVEN that both may, which should carry more of it?
|
||||
-> a comfort choice, in the sense ARCH-01 means: the gain
|
||||
seen at the meter is a CONVEX COMBINATION of the
|
||||
per-unit gains for ANY split, so this cannot destabilise
|
||||
the site whatever it says.
|
||||
|
||||
⚠️ AND WHAT THE WEIGHTS CANNOT DO, BECAUSE THE CONVEX-COMBINATION ARGUMENT IS
|
||||
EASY TO OVER-READ
|
||||
============================================================================
|
||||
ARCH-01 guarantees that a bad split cannot DESTABILISE the site. It says nothing
|
||||
whatever about whether the site can REGULATE. A unit sitting at its energy limit
|
||||
has g_i = 0 - it answers no watts to any command - and the convex combination
|
||||
then contains a zero: `gbar = sum(a_i * g_i)` is still inside [min g_i, max g_i]
|
||||
and the site is still stable, while the load it was asked to carry simply does
|
||||
not move. `compute()` sees the resulting standing error, trips its saturation
|
||||
freeze and holds the site target where it is, so the share of a unit that cannot
|
||||
deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
|
||||
|
||||
⚠️ SO: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY.
|
||||
Weighting a failing unit down is not a remedy for it. The remedies this
|
||||
add-on has are the ENVELOPE (here, when the reason is the pack's own state
|
||||
of charge - a fact this controller reads) and `live_units()` (when the unit
|
||||
cannot be commanded at all). SAFETY-42 measured what the third case costs:
|
||||
with one unit wedged and equal weights, the site settles 700 W short and
|
||||
never recovers, because the freeze pins the target and the sibling's share
|
||||
never rises. That module DETECTS it; this one cannot fix it, and saying so
|
||||
is worth more than a weighting scheme that looks like it might.
|
||||
|
||||
WHY ENERGY AND NOT POWER
|
||||
========================
|
||||
The two packs at this site are asymmetric IN OPPOSITE DIRECTIONS. From the
|
||||
datasheet and the serials (NOTES.md 1.1b): A is 5000 W / 15.36 kWh, B is 4600 W
|
||||
/ 20.48 kWh - B has about 33 % more ENERGY and about 8 % less POWER. A split by
|
||||
power rating hands the trade to A; a split by energy headroom hands it to B. The
|
||||
question the split is answering is "which pack can sustain its share for the
|
||||
hour", so it is the energy one, and the ratings are already applied - as the
|
||||
`supervised` and `device` claims, per unit, which is where a POWER bound belongs.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST YET (expected ~2026-09-12) AND NOTHING HERE IS
|
||||
MEASURED ON IT. `sim/plantsim.py`'s INVERTER_B is inverter A copied, with
|
||||
`measured=False` on it. Every figure above is FROM DATASHEET AND SERIAL, and
|
||||
every two-unit result this card reports is TWO COPIES OF A driven with different
|
||||
numbers, which is a statement about the arithmetic and not about the hardware.
|
||||
|
||||
WHICH FLOOR, AND WHICH CEILING - THE TWO CHOICES THAT ARE POLICY IF YOU GET
|
||||
THEM WRONG
|
||||
==========================================================================
|
||||
⚠️ THE FLOOR IS `bms_soc_floor` ALONE, NOT `peakguard.pack_floor_pct()`'s
|
||||
conjunction with `peak_soc_floor`. This is the same choice `delivery.py`'s
|
||||
`account()` had to make and it is made for the same reason, one direction
|
||||
further out: `peak_soc_floor` bounds the PEAK GUARD ONLY (SAFETY-25), ordinary
|
||||
self-consumption discharges straight past it, and closing a unit's envelope at
|
||||
it would silently turn an EMS preference into a hard stop on the grid-following
|
||||
loop - which is the memo's DEFERRED Option B, decided against on 2026-09-02, and
|
||||
not this card's to build. `bms_soc_floor` is a property of the battery in the
|
||||
cupboard: below it the pack's own BMS stops, so a discharge command sent there
|
||||
is a command that cannot be executed by anything.
|
||||
|
||||
⚠️ AND THE CEILING IS 100 %, WHICH IS COARSE, AND THE COARSENESS IS THE HONEST
|
||||
ANSWER RATHER THAN A GAP TO BE PAPERED OVER. There is no per-pack charge ceiling
|
||||
anywhere in this add-on - `delivery.py` says so at length and calls it a card,
|
||||
and it is why SAFETY-42 is armed on discharge shares only. The numbers that
|
||||
exist are `arbitrage_soc_ceiling` (one site-wide number, and a TRADE policy) and
|
||||
nothing else. Filing a trade policy as a P_SAFETY envelope on the loop would be
|
||||
exactly the mistake the paragraph above refuses, in the charge direction. So the
|
||||
envelope closes only where the ask is physically impossible - a pack reporting
|
||||
100 % being told to take more - and everything between is left to the WEIGHTS,
|
||||
which is the right instrument for a graded preference and is honest about being
|
||||
only a preference.
|
||||
|
||||
CAPACITY IS OPTIONAL AND "UNKNOWN" MEANS "NO OPINION"
|
||||
=====================================================
|
||||
`capacity_kwh` is a new, OPTIONAL per-entry key. Optional, where SAFETY-32 made
|
||||
`bms_soc_floor` required, because the two absences mean different things: a
|
||||
missing cut-out would have to be DEFAULTED and a default protects the shallower
|
||||
pack with the deeper pack's number, whereas a missing capacity can be answered
|
||||
with "then this module has no opinion about the split" - which is exactly
|
||||
today's behaviour, 1.0 for every unit. `weights()` therefore answers None unless
|
||||
EVERY pack in the roster states both a capacity and a state of charge: a
|
||||
half-known roster would weight the packs that answered against a sibling
|
||||
weighing 1.0, which is a comparison between a kWh and a dimensionless one.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import inf, isfinite
|
||||
|
||||
from .peakguard import discharge_permitted
|
||||
|
||||
# The only charge ceiling that is a property of a PACK rather than of a policy.
|
||||
# See the module docstring: `arbitrage_soc_ceiling` is a trade preference and
|
||||
# must not become a P_SAFETY bound on the grid-following loop.
|
||||
FULL_PCT = 100.0
|
||||
|
||||
# The claim source name main.py files these intervals under.
|
||||
#
|
||||
# ⚠️ MAIN.PY STILL WRITES THE STRING LITERALLY AND MUST GO ON DOING SO, so this
|
||||
# constant is a NAME FOR THE CHECKS rather than something to import at the
|
||||
# filing site. test_peakguard.py part 10f reads `Claim.limit(...)`'s FIRST
|
||||
# POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set, and part
|
||||
# 10g reads `actuator_of('<literal>')` the same way; both go blind on a name
|
||||
# they would have to resolve. So the two spellings are tied by a check instead -
|
||||
# `CLAIM_SCOPE[packshare.SOURCE] == SCOPE_UNIT` in test_packshare.py part 10 -
|
||||
# which is the one direction that cannot make the scanners vacuous.
|
||||
SOURCE = "pack"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackEnergy:
|
||||
"""ONE pack's four numbers for the two questions this module answers.
|
||||
|
||||
`name` the roster name. An envelope that does not say WHICH pack it
|
||||
closed sends an operator to the wrong cupboard - the defect
|
||||
`peakguard.Pack` and CFG-04's `unit_caps_indexed()` were
|
||||
both written for.
|
||||
`soc` THIS pack's measured state of charge, in percent. None when
|
||||
its sensor did not answer, and ⚠️ None IS NOT A FULL
|
||||
BATTERY - `discharge_permitted()` says why at length.
|
||||
`bms_floor` THIS pack's OWN `bms_soc_floor`, the percent at which its
|
||||
BMS cuts out. Required of every roster entry since
|
||||
SAFETY-32, so at a declared roster this is never None.
|
||||
`capacity_kwh` THIS pack's usable capacity. None when the entry did not
|
||||
state one, which means "no opinion" and never 0 - a zero
|
||||
capacity is a pack that may never be used, which is a
|
||||
decision nobody made by omitting a key.
|
||||
`max_w` THIS unit's supervised rating. ⚠️ IT IS NOT AN INPUT TO THE
|
||||
SPLIT AND MUST NOT BECOME ONE - the whole of deliverable 2
|
||||
is that the split is by ENERGY and the rating is applied
|
||||
separately, per unit, as the `supervised` and `device`
|
||||
claims. It is here for `trade_power_w()` alone, which
|
||||
answers a different question: how many watts the packs that
|
||||
are ALLOWED to take a trade can take between them.
|
||||
|
||||
⚠️ IT IS A SEPARATE TYPE FROM `peakguard.Pack` RATHER THAN AN EXTENSION OF
|
||||
IT, AND THAT IS DELIBERATE. `Pack` carries the three numbers the FLOOR
|
||||
question needs and is consumed by `pack_permissions()` and
|
||||
`binding_soc_floor()`, whose answers are site verdicts about spending a pack
|
||||
for money. This one carries a fourth number that is meaningless to those
|
||||
reductions and would have to be defaulted into them. Two small types with
|
||||
one purpose each beat one type with a field half its callers must ignore.
|
||||
"""
|
||||
|
||||
name: str
|
||||
soc: float | None
|
||||
bms_floor: float | None
|
||||
capacity_kwh: float | None
|
||||
max_w: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackEnvelope:
|
||||
"""The interval OPT-09 asks for on one pack, and the sentence that says why.
|
||||
|
||||
`(-inf, +inf)` is "no opinion" and is the common answer; `binding` is how a
|
||||
caller asks whether there is anything to file. A claim filed for every pack
|
||||
on every cycle regardless would put an open limit into the arbiter's
|
||||
considered list on every tick, which costs nothing arithmetically and buries
|
||||
the one cycle where it means something.
|
||||
"""
|
||||
|
||||
lo: float = -inf
|
||||
hi: float = inf
|
||||
why: str = ""
|
||||
|
||||
@property
|
||||
def binding(self) -> bool:
|
||||
return self.lo != -inf or self.hi != inf
|
||||
|
||||
|
||||
def usable_kwh(pack) -> float | None:
|
||||
"""Energy this pack can still DISCHARGE before its own BMS cuts out.
|
||||
|
||||
None when it cannot be computed - no capacity, no reading, or no floor - and
|
||||
never 0.0 for those, because "no opinion" and "nothing left" are the two
|
||||
answers a weighting must not confuse: the first weighs 1.0 and the second
|
||||
weighs nothing at all.
|
||||
|
||||
Clamped at 0.0 from below. A pack measured BELOW its own cut-out is at
|
||||
-0.4 kWh of usable energy by arithmetic and at zero in fact.
|
||||
"""
|
||||
if (pack.capacity_kwh is None or pack.soc is None
|
||||
or pack.bms_floor is None):
|
||||
return None
|
||||
return max(0.0, float(pack.capacity_kwh)
|
||||
* (float(pack.soc) - float(pack.bms_floor)) / 100.0)
|
||||
|
||||
|
||||
def spare_kwh(pack) -> float | None:
|
||||
"""Energy this pack can still ACCEPT before it is full.
|
||||
|
||||
`FULL_PCT`, not `arbitrage_soc_ceiling`: the module docstring carries the
|
||||
argument, and it is the same one that keeps `peak_soc_floor` out of
|
||||
`usable_kwh()`. A pack's own fullness is a fact; a trade's reserve is not.
|
||||
|
||||
⚠️ AND IT IS A WEIGHTING INPUT, NOT A PERMISSION. It says nothing about the
|
||||
constant-voltage taper, in which a pack legitimately accepts far less than
|
||||
it is told for tens of minutes - there is no configured ceiling in this
|
||||
add-on that could describe that, which is `delivery.py`'s stated reason for
|
||||
being armed on discharge shares only.
|
||||
"""
|
||||
if pack.capacity_kwh is None or pack.soc is None:
|
||||
return None
|
||||
return max(0.0, float(pack.capacity_kwh)
|
||||
* (FULL_PCT - float(pack.soc)) / 100.0)
|
||||
|
||||
|
||||
def envelope(pack) -> PackEnvelope:
|
||||
"""The interval this pack may be commanded in, from its state of charge.
|
||||
|
||||
Positive is DISCHARGE throughout this add-on, so:
|
||||
|
||||
* a pack at or below its own cut-out gets `hi = 0` - it may still be
|
||||
charged, it may not be asked to discharge;
|
||||
* a pack at 100 % gets `lo = 0` - it may still be discharged, it may not
|
||||
be asked to charge.
|
||||
|
||||
⚠️ THE DISCHARGE HALF ASKS `peakguard.discharge_permitted()`, THE PREDICATE
|
||||
SAFETY-32 MADE PUBLIC, rather than writing `soc <= floor` a third time. That
|
||||
function is already the one place this question is answered - for the peak
|
||||
guard's own verdict and for `delivery.account()`'s discriminator - and a
|
||||
safety predicate transcribed into a third copy is two future disagreements
|
||||
about what it means. What is NOT reused is its wording, which is peak-guard
|
||||
flavoured; the floor handed in is the pack's own cut-out, exactly as
|
||||
`delivery.account()` hands it.
|
||||
|
||||
⚠️ AND A MISSING READING OR A MISSING FLOOR WITHHOLDS THE CLOSURE, NOT THE
|
||||
DISCHARGE - WHICH IS THE OPPOSITE DIRECTION FROM `discharge_permitted()`'s
|
||||
OWN, ON PURPOSE. That predicate answers "may this site SPEND this pack for
|
||||
money", where an unread SoC must not authorise the spend and a full battery
|
||||
is exactly the value that would disarm the floor. The question here is
|
||||
different: whether to take a unit's share away from it and hand it to a
|
||||
sibling. An absent measurement is not evidence that a battery is flat, and
|
||||
closing on it would park a healthy unit's discharge on every tick for as
|
||||
long as its sensor was missing - moving load onto its sibling on the
|
||||
strength of a reading nobody took.
|
||||
|
||||
Both absences already have a stronger answer arriving first, which is why
|
||||
this one costs nothing: a missing SoC stops the WHOLE SITE through
|
||||
`inputs_ok` (a site-level P_SAFETY set of 0 W), and a roster entry with no
|
||||
`bms_soc_floor` is refused at startup by validate.py rule 8 and by
|
||||
config.yaml's own schema, which requires the key of every entry. So the
|
||||
conjunction below is "positive evidence only": both numbers present, and the
|
||||
predicate says the pack is at or under its own cut-out.
|
||||
|
||||
⚠️ `discharge_permitted()` IS STILL THE PREDICATE, and it is still the only
|
||||
place the comparison is written - what this module decides for itself is the
|
||||
two None cases, which it decides differently and says why. The BOUNDARY
|
||||
(`<=`, so a pack sitting exactly on its floor is closed) comes from there
|
||||
and is graded against it over the whole percent range in test_packshare.py
|
||||
part 2, so a future change to that boundary moves both.
|
||||
|
||||
⚠️ AT P_SAFETY IT BINDS THE MAINTENANCE DRAIN TOO, AND THAT IS RIGHT RATHER
|
||||
THAN AN OVERSIGHT. main.py files this as a `P_SAFETY` limit, so clause 2 of
|
||||
the arbiter's rule applies it to the §12 recalibration drain (a SET at
|
||||
P_MAINTENANCE): a drain into a pack already at its BMS cut-out is clamped to
|
||||
0 W. On the shipped numbers it cannot even bite - `maintenance_soc_floor`
|
||||
defaults to 11 % against a 10 % cut-out, so the drain finishes above this
|
||||
interval. On a config that sets the drain target BELOW the cut-out
|
||||
(`int(5,30)` against `int(0,50)`, so the schema permits it) the drain stalls
|
||||
and the FSM's 12 h DRAIN timeout eventually reports "drain stuck" - which is
|
||||
what ALREADY happens without this card, because the pack's own BMS refuses
|
||||
the command whatever the allocator sends. The failure is the config's, the
|
||||
remedy is the same, and this envelope does not make it worse; it only makes
|
||||
the refusal visible one layer earlier, in the allocation record.
|
||||
|
||||
⚠️ BOTH ENDS CANNOT CLOSE AT ONCE ON ANY CONFIGURATION THE SCHEMA ACCEPTS.
|
||||
It would take a pack reporting at least 100 % while sitting at or under its
|
||||
own cut-out, and `config.yaml` bounds `bms_soc_floor` at `int(0,50)` on both
|
||||
the flat key and every roster entry - so the two conditions cannot both hold.
|
||||
If they somehow did, the result is `[0, 0]`, which is an interval containing
|
||||
exactly one integer rather than a contradiction, and the allocator commands
|
||||
that unit 0 W. Nothing is special-cased for it here: inventing a second
|
||||
opinion about an unreachable state is how unreachable states get opinions
|
||||
that are wrong.
|
||||
"""
|
||||
hi, lo, why = inf, -inf, ""
|
||||
if (pack.soc is not None and pack.bms_floor is not None
|
||||
and discharge_permitted(pack.soc, pack.bms_floor) is not None):
|
||||
hi = 0.0
|
||||
why = (f"{pack.name} is at {float(pack.soc):g} %, at or under its own "
|
||||
f"{float(pack.bms_floor):g} % BMS cut-out - it is not asked to "
|
||||
f"discharge")
|
||||
if pack.soc is not None and float(pack.soc) >= FULL_PCT:
|
||||
lo = 0.0
|
||||
why = (f"{pack.name} is at {float(pack.soc):g} % and cannot take a "
|
||||
f"charge - it is not asked to")
|
||||
return PackEnvelope(lo=lo, hi=hi, why=why)
|
||||
|
||||
|
||||
def weights(packs, target_w) -> dict | None:
|
||||
"""Per-unit comfort weights for THIS tick's site target, or None for none.
|
||||
|
||||
The weight of a pack is the energy it has left IN THE DIRECTION THE SITE IS
|
||||
ABOUT TO TRAVEL - usable energy above its own cut-out when the site is
|
||||
discharging, room below full when it is charging. `allocator._fill()` uses
|
||||
only the RATIOS, so the unit is free and kWh is the honest one to leave it
|
||||
in: it is what a reader of the action log can check against the datasheet.
|
||||
|
||||
⚠️ THE DIRECTION IS AN ARGUMENT BECAUSE IT IS NOT KNOWABLE ANY EARLIER. The
|
||||
two directions give OPPOSITE answers on the same roster - A at 20 % of
|
||||
15.36 kWh and B at 80 % of 20.48 kWh weigh 1.5 : 14.3 to discharge and
|
||||
12.3 : 4.1 to charge - so a direction-free weighting is not a compromise
|
||||
between them, it is wrong in one of the two on every tick. The site target
|
||||
is chosen by `arbiter.site_resolve()`, i.e. by stage 2, so the earliest
|
||||
honest moment to ask this is between stage 2 and stage 3. That is why
|
||||
`resolve()` accepts a CALLABLE here and calls it with the resolved target,
|
||||
and why main.py hands it a method rather than a dict.
|
||||
|
||||
⚠️ `target_w >= 0` IS THE DISCHARGE SIDE, AND IT IS THE SITE TARGET'S SIGN
|
||||
RATHER THAN THE FILL'S. `allocator.plan()` decides its own direction from
|
||||
`need = remainder - placed`, which differs from `sign(target_w)` only when a
|
||||
PIN or an envelope that excludes zero has already moved the base - a
|
||||
maintenance drain, a failsafe park. On those ticks the weighting is computed
|
||||
for the other direction and is therefore merely a worse preference; it
|
||||
cannot be unsafe, for ARCH-01's reason, and it cannot break the sum, which
|
||||
is integer arithmetic in the allocator. Chasing the exact direction would
|
||||
mean handing this function into `plan()` and calling it from inside the
|
||||
fill, which would make the allocator's purity depend on a caller's callback.
|
||||
|
||||
None means "no opinion, weigh everything 1.0", and there are three ways to
|
||||
get it, all of which are today's behaviour rather than a new one:
|
||||
|
||||
* fewer than two packs. At N<=1 `plan()` is a pass-through and a weighting
|
||||
would be arithmetic nobody reads;
|
||||
* any pack that cannot state its headroom - see the module docstring on
|
||||
why a half-known roster is worse than an unweighted one;
|
||||
* every pack at zero headroom in this direction. All-zero weights would
|
||||
put the whole roster into `_fill()`'s LAST-RESORT pass, where the shares
|
||||
come out equal anyway and every `UnitPlan.why` reads "last resort: the
|
||||
preferred units were full" - a true sentence about the wrong thing.
|
||||
"""
|
||||
packs = list(packs)
|
||||
if len(packs) < 2:
|
||||
return None
|
||||
room = (usable_kwh if float(target_w) >= 0.0 else spare_kwh)
|
||||
out = {}
|
||||
for p in packs:
|
||||
kwh = room(p)
|
||||
if kwh is None or not isfinite(kwh):
|
||||
return None
|
||||
out[p.name] = float(kwh)
|
||||
if not any(v > 0.0 for v in out.values()):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def trade_power_w(packs, floor_pct, ceiling_pct) -> tuple:
|
||||
"""`(charge_w, discharge_w)` - how hard an arbitrage 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()` was handed
|
||||
`site_max_w` on BOTH sides, so a two-pack site would ask to buy at the sum of
|
||||
the ratings whatever either pack's state of charge was - 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 answers "how much may this strategy ask
|
||||
for"; `envelope()` above answers "what may this pack be commanded at all",
|
||||
and that one is the pack's own physical cut-out for SAFETY-25's reason.
|
||||
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. TWO QUESTIONS, TWO NUMBERS.
|
||||
|
||||
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack carries 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. 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 an envelope
|
||||
that depends on WHICH CLAIM WON the tick, which the arbiter's partition does
|
||||
not express, and it is a card of its own.
|
||||
|
||||
A pack whose SoC did not read is counted on NEITHER side: withholding is the
|
||||
direction that cannot over-size, and `inputs_ok` has stopped the site by
|
||||
then anyway.
|
||||
|
||||
⚠️ NEITHER SIDE MAY REACH `arbitrage.claim()` AS A ZERO - a 0 W arbitrage
|
||||
claim is a PARK ORDER that outranks the loop. It cannot be zero on a tick
|
||||
that trades today, because the sell gate refuses on the emptiest pack and
|
||||
the buy gate refuses when every pack is at the ceiling; `arbitrage.decide()`
|
||||
refuses a 0 W sizing by name anyway, so that is structural rather than a
|
||||
coincidence of the gates.
|
||||
"""
|
||||
return (float(sum(p.max_w for p in packs
|
||||
if p.soc is not None and float(p.soc) < ceiling_pct)),
|
||||
float(sum(p.max_w for p in packs
|
||||
if p.soc is not None and float(p.soc) > floor_pct)))
|
||||
@@ -570,7 +570,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
rules up. A rule the Supervisor duplicates costs nothing and covers the
|
||||
callers it does not.
|
||||
|
||||
Five things are refused, and each is a config that would otherwise RUN:
|
||||
Seven things are refused, and each is a config that would otherwise RUN:
|
||||
|
||||
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
|
||||
can name it; without the rule the roster would be one inverter built
|
||||
@@ -593,6 +593,18 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
config. Refused rather than ignored, for §1.5's reason and for this
|
||||
card's: a config that silently becomes a different config is worse than
|
||||
one that refuses to load.
|
||||
f. OPT-09. An entry whose `capacity_kwh` is stated but is not a usable
|
||||
positive number of kWh. It is read to weight the SPLIT by energy
|
||||
headroom; a zero, a negative or a word is a pack the split would
|
||||
quietly stop preferring while the operator can see a value in the box.
|
||||
g. OPT-09. `capacity_kwh` stated on SOME entries and not others. The
|
||||
weighting is a COMPARISON between packs, so a pack measured in kWh
|
||||
against a sibling weighing a dimensionless 1.0 is not one -
|
||||
`packshare.weights()` answers "no opinion" for the WHOLE roster in that
|
||||
case, which means every key the operator filled in does nothing at all
|
||||
and nothing says so. All or none, and this is clause (e)'s argument
|
||||
restated: a config that silently becomes a different config is worse
|
||||
than one that refuses to load.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
raw = opts.get("inverters")
|
||||
@@ -651,6 +663,42 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
"sibling is exactly what DEV-02 removed. State this unit's own "
|
||||
"rating in watts.")
|
||||
|
||||
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
|
||||
stated, blank = [], []
|
||||
for i, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue # already refused above, by name
|
||||
raw = entry.get("capacity_kwh")
|
||||
nm = str(entry.get("name") or "").strip()
|
||||
who = f"inverters[{i}] ({nm})" if nm else f"inverters[{i}]"
|
||||
if raw is None or raw == "":
|
||||
blank.append(who)
|
||||
continue
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
val = None
|
||||
if val is None or not (val > 0.0):
|
||||
problems.append(
|
||||
f"{who} has capacity_kwh {raw!r}, which is not a usable number "
|
||||
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
|
||||
"read to weight the split of the site target by ENERGY headroom "
|
||||
"rather than by power rating; a zero or an unparseable value is "
|
||||
"a pack the split would quietly stop preferring while the box "
|
||||
"still shows a value. State it in kWh, or leave it empty on "
|
||||
"every entry.")
|
||||
else:
|
||||
stated.append(who)
|
||||
if stated and blank:
|
||||
problems.append(
|
||||
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
|
||||
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
|
||||
"split weights one pack's energy headroom against another's, so a "
|
||||
"pack measured in kWh beside a sibling that weighs a dimensionless "
|
||||
"1.0 is not a comparison - the add-on answers 'no opinion' for the "
|
||||
"whole roster and the value you filled in changes nothing. State a "
|
||||
"capacity for every inverter, or for none of them.")
|
||||
|
||||
flat_sp = str(opts.get("setpoint_entity") or "").strip()
|
||||
if entries and flat_sp:
|
||||
problems.append(
|
||||
|
||||
Reference in New Issue
Block a user