Files
goodwe-addon/goodwe_controller/app/packshare.py
T
Claude Opus 5 2271e147c6
checks / unit (pull_request) Successful in 6m20s
SAFETY-43: delinquency measured against the ask, not against a flat 500 W
`delivery.delinquent()` named a unit whose `short_by()` gap exceeded an
ABSOLUTE `min_w = Tuning.saturation_w = 500 W`. OPT-09's SoC-aware weights
shrink a low-headroom unit's share, and shrinking the share shrinks the gap, so
a unit standing at +125 W and delivering 0 W - 100 % delinquent - was never
named. Measured end to end on the real loop (test_packshare.py part 8b):

    load 1500 W, B wedged at 15 %, equal weights    named on 199/200 cycles
    load 1500 W, B wedged at 15 %, OPT-09 weights   named on   0/200 cycles

⚠️ The suppressed regime is the quiet one: at a load the healthy sibling covers,
the meter is satisfied, `unmet_w` is 0 and no action-log line fires, so this
alarm is the ONLY instrument that would have told the operator.

WHAT CHANGED, in one line:

    named if  short_by() > min_w  OR  short_by() > frac * share

⚠️ `min_w` WAS NOT LOWERED. It exists to stop normal saturation being reported
as delinquency, and lowering it trades a missed alarm for a false one on every
unit at once - SAFETY-41's four-attempt regime. This is a SECOND way to be
named, not a looser first one: all seven of test_delivery.py part 1's existing
fixtures answer identically under both rules, and the absolute arm's suppression
window is still exactly 1000 W / 6500 W, still asserted in part 8b.

THE FRACTION IS DERIVED, 0.85. admin/goodwe:sim/delivery_window.py gained a
`--fractions` axis and grades the constant over the SAME 2640 no-fault runs that
produced `delivery_hold_s`'s 35.0 s floor. Worst continuous legitimate episode,
worst of three families, twenty fractions:

    frac   0.05  0.10  0.15  0.20  0.25  0.30  0.35  0.40  0.45  0.50
    worst  90.0  90.0  70.0  70.0  60.0  55.0  55.0  60.0  45.0  45.0  s
    frac   0.55  0.60  0.65  0.70  0.75  0.80 *0.85  0.90  0.95  0.99
    worst  40.0  40.0  45.0  45.0  45.0  45.0  40.0  40.0  40.0  40.0  s
    absolute arm alone: 35.0 s (SAFETY-42 §3.3, reproduced in the same runs)

⚠️ NO CLIFF ANYWHERE: 0.45-0.99 is flat within one control cycle, and it is not
monotone - §3.3's own mechanism (c). ⚠️ "Zero false namings" is NOT achievable
at any fraction and the record says so; what is zero is false ALARMS - 90.0 s
worst against a 185 s hold, so nothing comes within 95 s of notifying anybody.
N=1 control 0.0 s at every fraction in all 2640 one-inverter runs; flapping
0.0 s in all 840.

DRIVEN. Part 8b pinned the SUPPRESSED behaviour and said "if it ever goes red
because the alarm fired, read the card that made it fire". It now grades BOTH
rules on one run - `share_frac=None` is SAFETY-42's rule exactly:

    OPT-09 weights, absolute arm alone   named on   0/200
    OPT-09 weights, the shipped rule     named on 200/200

POSITIVE CONTROL. Same fixture, same weights, same 125 W share, one legitimate
fact changed: a pack at its own 10 % cut-out is named on 0/200 (account() still
gates it), and a unit delivering HALF its share is named on 0/200.

N=1 BYTE-IDENTICAL, asserted: `plan()` handed a `weights` mapping that raises on
any read still answers identically at a roster of one, and the same mapping
raises at a roster of two; `delinquent()` answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.

MUTATION, run on fresh :ro trees: M12 (drop the relative arm), M13 (frac -> 1.0)
and M14 (frac -> 0.05) are all red on named checks. The constant cannot move in
either direction unseen.

⚠️ CFG-07 gets ONE CONTROL CYCLE WORSE and is deliberately not fixed here: the
worst legitimate episode goes 35.0 -> 40.0 s, so the schema's `int(30,900)`
false-tripping band widens from [30,35] to [30,40]. CFG-07 should be re-derived
against 40.0 s.

No option value, no schema bound and no default changed. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 03:14:16 +02:00

465 lines
27 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 WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's
DETECTION WINDOW - A DETECTION REGRESSION AT N=2, NOW CLOSED BY SAFETY-43
=======================================================================
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()` named a unit only while its `short_by()` gap exceeded
`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 % cut-out, so `envelope()` does not close and `account()` explains
nothing): at a 1500 W load the healthy pack can carry alone, equal weights named
the wedged unit on 199 of 200 cycles and these weights named it on NONE. The
suppression window widened 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 had
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 absolute arm names it.
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE - KEPT HERE BECAUSE IT IS A MEASUREMENT, EVEN THOUGH SAFETY-43 HAS SINCE MADE THE WINDOW IRRELEVANT TO THE DETECTOR. Swept on this fixture with the ABSOLUTE arm alone, 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 returned BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm named the wedged unit 199/200 at every load swept, 1000 W included.
⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to
the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a
unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W.
On the SAME fixture, under the SAME weights, the wedged unit is now named
on 200 of 200 cycles. Part 8b grades both rules on one run:
`share_frac=None` is SAFETY-42's absolute arm alone and still says nothing,
which is what keeps the paragraph above honest rather than nostalgic.
⚠️ AND `min_w` WAS NOT LOWERED. The absolute arm's suppression window is
still exactly the arithmetic above - 1000 W at equal weights, 6500 W at
these - because a lower `saturation_w` would report NORMAL SATURATION as
delinquency on every unit at once, which is the false-trip regime SAFETY-41
spent four attempts in. What changed is that there is a SECOND way to be
named; nothing that was quiet before became noisy.
⚠️ WHAT THIS MODULE STILL CANNOT DO IS UNCHANGED. OPT-09 stops the site
ASKING a pack whose own SoC says it cannot deliver; it does not, and will
not, re-allocate a wedged unit's watts to its sibling - `delivery.py`'s
"IT REPORTS. IT DOES NOT ACT" block is the measurement behind that, and
SAFETY-43 did not touch it. The alarm is louder; the loop is identical.
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)))