Files
goodwe-addon/goodwe_controller/app/packshare.py
T
glenn schrooyenandClaude Opus 5 cab267c4f3
checks / unit (pull_request) Successful in 6m10s
OPT-09: the 6500 W window is a bound the ratings never let the site reach
Round-2 non-blocking findings, both fixed.

⚠️ THE WINDOW FIGURE WAS ARITHMETIC NOBODY HAD RUN. `min_w * (w_A + w_B) / w_B`
= 6500 W is correct about `_fill()`'s PROPORTIONAL regime and is not a
reachable steady state: the healthy pack's own 5000 W rating binds first.

SWEPT on part 8b's own fixture, reproduced rather than transcribed:

    load   equal   OPT-09   site target   A      B
    1000   199/200   0/200       1080    997     83
    2000   199/200   0/200       2170   2003    167
    3000   199/200   0/200       3250   3000    250
    4000   199/200   4/200       4340   4006    334
    4500   199/200   8/200       4890   4514    376
    4900   199/200  41/200       5320   4911    409   <- largest suppressed
    5000   199/200 195/200       8000   5000   3000   <- A at its rating
    6000   199/200 195/200       8000   5000   3000

So the alarm returns BELOW the formula bound, not above it, and the largest
site target reached with the wedged unit still under the 500 W threshold is
5320 W - not 6500. The equal-weights "suppressed below 1000 W" bound is
unreachable for the same reason: the equal arm names the wedged unit 199/200 at
EVERY load swept, 1000 W included.

The formula and its check are kept, because the mechanism they state is right
and it is the mechanism that generalises to other ratings. What was wrong was
the sentence around them, in four places, implying the site passes through the
bound. Each now carries the sweep and says the rating binds first.

⚠️ THIS NUMBER CAME FROM THE REVIEW, NOT FROM THE BRANCH, and was adopted in
good faith by the implementer. The reviewer found it in round 2 and said so.
That is worth recording: a figure arriving from a trusted reviewer got less
scrutiny than one written by the author, and PROC-01's eight fabricated numbers
in two days is now nine. The rule that catches it is the same either way - run
it before you ship it.

Also: the DECISIONS header's base sha, stale for the third time (464d398 ->
3368231 -> 3d29993, moved by two rebases and caught by review each time). It now
records that history, and the header is why `test_translations.py`'s counts are
computed rather than typed.

SAFETY-43 is now named by id where the follow-up is referenced, so a reader can
follow the thread from the disclosure to its closure.

Suite: 25 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 23:26:22 +02:00

452 lines
26 KiB
Python

"""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.
⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
=======================================================================
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
case at all. THE ENVELOPE HALF IS TRUE: a wedged box reads a healthy SoC and
nothing here closes. THE WEIGHT HALF WAS FALSE. `weights()` is a function of a
pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
moves whenever its headroom differs from its sibling's - the ordinary case this
module exists to exploit.
`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
own 10 % floor, so `envelope()` does not close and `account()` explains
nothing): at a 1500 W load the healthy pack can carry alone, equal weights name
the wedged unit on 199 of 200 cycles and these weights name it on NONE. The
suppression window widens from `site target < 2 * min_w` = 1000 W to
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
hands the wedged unit a big share again and the alarm returns.
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
is a disclosed residual, not a fixed thing. Closing it means making
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
and a design change, filed as its own card and deliberately NOT done here.
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
and a green check that says otherwise would be worse than the gap.
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)))