checks / unit (pull_request) Successful in 6m20s
`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
465 lines
27 KiB
Python
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)))
|