`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
19 KiB
OPT-09 - the weights parameter, and what it is honestly for
Status: implemented. Branch: OPT-09. Base: release/1.0 @ 3d29993 (rebased onto the UX-02 merge; it read 3368231, and before that 464d398 - each rebase moved it and review caught it each time, which is why the counts in test_translations.py are now computed rather than typed)
(git merge-base HEAD 3368231; the branch was rebased and this line used to name
the pre-rebase base, 464d398).
0. What was actually wrong
allocator.plan() has taken a weights argument since ALLOC-01, _fill()
consumes it in two passes, and nothing had ever passed one. Confirmed on the
base commit:
arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...)
main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...)
So weights was None on every tick of every install, plan() turned that into
1.0 per unit, and the site target was split 50/50 regardless of what either
pack could do. The card was filed as "per-pack arbitrage sizing"; the architect
pass found the sharper content, and it is the sharper content that this branch is
mostly about.
1. Two instruments, and they are not interchangeable
allocator.py states the rule this card is built on, and it is mechanical rather
than advisory:
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.
_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. A pack at its own cut-out that is only weighted
to zero is therefore still commanded to discharge the moment its sibling fills
up. So this branch ships both:
| question | mechanism | |
|---|---|---|
packshare.envelope() |
MAY this pack be asked, at all, in this direction? | a pack UNIT claim at P_SAFETY, hi = 0 at the cut-out, lo = 0 at 100 % |
packshare.weights() |
GIVEN that both may, which should carry more? | energy headroom in kWh, handed to resolve() |
2. ⚠️ What the weights CANNOT do
ARCH-01's convex-combination argument - the gain at the meter is
sum(a_i * g_i), so it lies in [min g_i, max g_i] for any split -
guarantees that a bad split cannot destabilise the site. It says nothing
about whether the site can regulate. A unit at its energy limit has
g_i = 0; the combination is still inside the interval and the load it was
asked to carry simply does not move.
Measured, test_packshare.py part 8, two units rated 2000 W each, B sitting on
its own floor and therefore answering 0 W to any discharge command. The plant is
modelled in that file (lag_s = 0, obeys within one cycle, bounded by its
rating) and the loop is the real compute():
| load | arm | site target | shares | grid | unmet |
|---|---|---|---|---|---|
| 1500 W | shipped | +3000 W | A +1500, B +1500 | 0 W | 0 W |
| 1500 W | weights | +1500 W | A +1500, B +0 | 0 W | 0 W |
| 1500 W | envelope | +1500 W | A +1500, B +0 | 0 W | 0 W |
| 3000 W | shipped | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
| 3000 W | weights | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
| 3000 W | envelope | +4000 W | A +2000, B +0 | +1000 W | +2000 W |
Three things are worth reading off that table, and the second and third are the uncomfortable ones:
- At a load the healthy pack can cover, the weights alone deliver the whole improvement. The shipped arm reaches the same meter reading only by winding the site target to twice the load and standing a +1500 W command on a battery that answers nothing, permanently saturation-frozen. That is not a working site, it is a site with no headroom left for the next load step.
- At a load it cannot cover, the weights stop working entirely. The
weighted arm and the shipped arm command the identical +2000 W into the empty
pack, because
_fill()'s last-resort pass hands the remainder to the zero-weighted unit. Same shares, same watts, same error. - And the envelope does not rescue the delivery either - it names the shortfall. Same 2000 W delivered, same 1000 W standing at the meter. What changes is that the empty pack is commanded 0 W instead of 2000 W, and 2000 W of site target is reported as unplaceable instead of appearing to have been placed.
Weights are a comfort choice for STABILITY and NOT for DELIVERY. Nothing in this add-on shifts a unit's load to its sibling once the sibling is at its rating, and weighting a failing unit down is not a remedy for it.
2b. ⚠️ The card's own premise is only half true
The card opens with "a unit that stops delivering its share (empty, full, curtailed, wedged) parks the site with a standing grid error and no alarm." After this branch: the ask stops, and while the sibling has headroom the site regulates to zero error instead of winding to twice the load. The standing grid error does not stop once the sibling is at its rating - §2's table measures 1000 W standing in all three arms - and there is still no alarm for it, only a report. SAFETY-42 will not alarm on a pack at its own floor; that is one of its acceptance criteria and its evidence is that the benign and the wedged case are indistinguishable in the power domain.
So the two cards together mean: an empty pack limiting the site is now REPORTED
(unmet_w, an action-log line) and still, deliberately, NOT ALARMED. Whether it
should be alarmed is a decision - and the alarm it would need is about a site's
stored ENERGY, not about an inverter's delivery, so it is not this card's and not
SAFETY-42's. Recorded rather than quietly closed against a premise this card only
half meets.
3. Which floor, and which ceiling - the two places this could have become policy
The floor is bms_soc_floor alone, never peakguard.pack_floor_pct(). That
function's conjunction with peak_soc_floor is right for the peak guard and
wrong here: SAFETY-25 decided (2026-09-02, Option A) that peak_soc_floor bounds
the peak guard only, ordinary self-consumption discharges straight past it,
and closing an envelope at it would turn an operator preference into a hard stop
on the grid-following loop - the memo's deferred Option B, built by accident.
delivery.py's account() made the same choice one step out, for the same
reason. Graded: a pack at 15 % with a 10 % cut-out is not closed, though a 20 %
EMS floor would have closed it.
The ceiling is 100 %, and the coarseness is the honest answer. 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 only other number available is arbitrage_soc_ceiling, which is one
site-wide trade policy; filing it as a P_SAFETY envelope on the loop would
be the mistake above in the charge direction. So the envelope closes only where
the ask is physically impossible, 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.
4. N=1, and the tripwire this had to argue past
actuator_for() routes every source to the site at one inverter, so a pack
interval filed there would bound the SITE TARGET rather than a share. main.py
therefore does not file it at all below a roster of two - the same guard, for the
same reason, that SAFETY-30's sign_site carries. AC 4 ("at N=1 allocation is
byte-identical") is that if.
test_peakguard.py part 10f pins the claim-source set against main.py's own
AST precisely so that a state-of-charge-conditioned claim cannot arrive quietly.
pack is state-of-charge-conditioned, so the argument is written at that set
rather than the count edited: the number is not an option, the claim is
unit-scoped so it cannot bound the site target, it is not filed at N=1, and part
10f's own note names this as the revisit trigger - "under ALLOC-01 the reserve
becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a ninth claim
here". Stage 1 is how an interval reaches the allocator and stage 1's input is
claims, so a unit-scoped claim is the shape that sentence asks for.
What N=1 still does not get, said plainly: nothing stops the loop discharging into the BMS cut-out at one inverter. That is unchanged, it is SAFETY-25's deferred option, and the BMS is what stops it.
5. SAFETY-42 - the same hazard from the opposite side
The seam is the state of charge - and they overlap in one direction, which is what the rest of this section is about:
- OPT-09 stops the site ASKING a pack whose own SoC says it cannot deliver.
- SAFETY-42 alarms when a pack that said it could does not.
A wedged unit reads a healthy SoC, so no envelope closes - that half is
absolute, and it is why this card cannot stop the ask on a wedged box. A pack at
its floor is account()'s explained case, so SAFETY-42 stays silent - and after
this card that ask does not arrive in the first place, which removes the benign
arm from the detector's input rather than teaching it a new discriminator.
⚠️ BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43
CLOSED IT - see the block at the end of this section. An earlier draft of
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
at all". The second clause was false, and it was load-bearing for the claim
that the two cards do not overlap. A weight is a function of the 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 - which is the ordinary case this card
exists to exploit. And delivery.delinquent()
named a unit only while its short_by() gap exceeded min_w = Tuning.saturation_w, and that was 500 W in absolute watts with nothing
else beside it. Shrinking a unit's share shrinks its gap. Shrink the
share far enough and the gap drops below 500 W while the unit is still delivering
nothing, and the alarm stops firing.
Measured on the closed loop - real compute(), real plan(), real
delinquent(), modelled plant, B wedged at 15 % against its own 10 % floor so
nothing closes and account() explains nothing, i.e. a SAFETY-42 true
positive by that card's own definition:
load 1500 W equal weights (shipped) named on 199/200 cycles
shares {a:1500, b:1500} grid +0 W named=['inverter-b']
load 1500 W OPT-09 weights named on 0/200 cycles
shares {a:1505, b: 125} grid -5 W named=(nobody)
⚠️ That plant is modelled in test_packshare.py, and inverter B does not
exist (expected ~2026-09-12): it is two copies of A driven with different
numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the
serial. The claim is about the arithmetic of the split and the threshold, which
needs no hardware, and not about a GW5000S-BP.
At a load the healthy pack can cover - the regime where nothing else tells the
operator anything, unmet_w is 0, the meter is satisfied and no action-log line
fires - a wedged inverter goes from named on essentially every cycle to never
named. The suppression window widens from site target < 2 * min_w = 1000 W to
site target < min_w * (w_A + w_B) / w_B, which on that fixture is 6500 W of
a 9600 W site; above it - once the healthy pack is at its own rating and
_fill()'s last-resort pass hands the wedged unit a big share again - 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.
test_packshare.py part 8b graded all of it and pinned the unflattering
behaviour, so that the day it changed a named check would move. It has moved.
⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
This section used to end "until then this is a disclosed residual [...] at two inverters, a wedged unit with materially less energy headroom than its sibling can go unreported at ordinary house loads". That sentence is retired, not softened, and what replaces it is a measurement rather than an intention.
delivery.delinquent() is now relative to the ask as well as absolute:
named if short_by() > min_w OR short_by() > frac * share
with frac = delivery.DELIVERY_SHARE_FRACTION. A unit delivering 0 of 125 W is
100 % delinquent and is named; a unit delivering 0 of 1500 W was already named
and still is. On the same fixture, under the same weights, part 8b now
grades both rules in one run - share_frac=None is SAFETY-42's absolute arm,
exactly:
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
⚠️ min_w WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE. The
absolute arm's suppression window is still exactly the arithmetic above - 1000 W
at equal weights, 6500 W at these - and part 8b still asserts both numbers.
Lowering saturation_w would have traded a missed alarm for a false one on
every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a
second way to be named and left the first where it was.
⚠️ AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED. This card stops the site
ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a
wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the
loop did not move. The derivation of frac - swept over the same 2640 no-fault
runs that produced delivery_hold_s's floor - is in
DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md.
So the seam, stated accurately as of SAFETY-43: OPT-09 stops the ask, and it still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as described above - but it no longer narrows the DETECTOR, because the detector no longer reads only that arm.
⚠️ And OPT-09 supplies the mechanism SAFETY-42 said was missing, without connecting it. That module's rejection of re-allocation rests on "the only re-allocation this add-on can express is ALL OR NOTHING [...] a partial re-allocation is what would make acting safe, and the allocator has no way to express it today". Passing weights is that expression. Wiring delinquency into them is deliberately not done here: it would be acting on the alarm, which SAFETY-42 refused with rig evidence (a unit derated to 50 % is 400 W worse dropped than kept, and no instrument this add-on has can tell it from a wedged one), and - see §2 - a weight cannot stop a unit being commanded anyway. That is a card, and this paragraph is its input.
6. Per-pack arbitrage sizing, and the half that is deliberately not moved
arbitrage.decide() was handed site_max_w on both sides, so a two-pack
site would buy at the sum of the ratings whatever either pack's state of charge
was. It is now sized to the packs whose own SoC permits that direction
(Controller.trade_power_w()), using arbitrage's own bounds rather than the
BMS cut-out - sizing a trade is the trade's business; permitting a command is the
pack's.
Two honest limits:
- The sell side is a no-op on every roster today.
decide()is handedpack_floor_soc- the emptiest pack - so a sell is refused outright whenever any pack is at or under the floor, i.e. in exactly the states where the sum could be smaller than the site rating. The arithmetic is graded directly (test_packshare.pypart 9); it becomes reachable the day that gate is per-pack. - Moving that gate is not this card's. Sizing a sell off the fullest pack
would let the trade proceed while the emptiest pack was below
arbitrage_soc_floor, and the only per-pack instrument that could keep it out of that pack is the weights - which are a preference, and_fill()will hand it watts anyway. Doing it safely needs an envelope that depends on WHICH CLAIM WON the tick, which the arbiter's partition does not express. §2 is why that is a real objection and not a hedge.
arbitrage.decide() also now refuses a 0 W sizing by name (IDLE_NO_SIZE),
because a Claim.set(..., 0.0) at P_ARBITRAGE is a park order that outranks
the loop - the failure the note on claim() is already written against. The
sizing can never be zero on a tick that trades today; that is a coincidence of
the existing gate, and this makes it structural instead.
7. The fixture, which is the hard part
Two packs that differ in only ONE of SoC and capacity cannot separate a
SoC-aware weighting from a capacity-aware one; two identical packs cannot
separate ANY weighting from any other. test_packshare.py part 1 grades the
fixture itself before anything is asserted with it - four candidate schemes
(equal, power rating, SoC only, energy), four distinct splits of the same
1000 W:
equal A +500, B +500
power A +521, B +479
soc only A +125, B +875
energy A +97, B +903
and the counterfactuals: on two identical packs all four collapse to one answer;
at the same SoC, soc only collapses onto equal; at the same capacity,
energy collapses onto soc only.
8. Provenance of every number
- A: 5000 W, 15.36 kWh, 10 % floor - measured (GW5000S-BP, register 0x0560).
- B: 4600 W, 20.48 kWh, 10 % floor - FROM DATASHEET AND SERIAL
(
goodwe/NOTES.md§1.1b, GW5048D-ES55048ESU22AW0992). ⚠️ Inverter B does not exist (expected ~2026-09-12). - Every two-unit run in
test_packshare.pyis arithmetic over intervals and weights, or a plant modelled in that file.sim/plantsim.py:216'sINVERTER_Bis inverter A copied withmeasured=False, so any two-inverter simulation result anywhere in this project is two copies of A. - No rig was taken, no hardware was touched, and nothing here is evidence about a second GoodWe inverter.
9. Config
capacity_kwh joins the inverters roster as an optional per-entry key
(float(0.1,200)?) - the opposite choice to SAFETY-32's required
bms_soc_floor, and for that card's own reason: 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, whereas a missing capacity has an honest answer -
no opinion about the split, every unit weighs 1.0, which is exactly what shipped.
It is all or nothing across the roster and rule 9 refuses a partial one: a
pack measured in kWh beside a sibling weighing a dimensionless 1.0 is not a
comparison, weights() answers "no opinion" for the whole roster in that case,
and a key that silently changes nothing is worse than one that refuses to load.
There is deliberately no flat capacity_kwh: at one inverter there is no
split to weight, so it would be an option an operator can set and nothing reads.