The architecture's one free parameter was inert: main.py called resolve() without weights and arbiter.py:535 defaulted them to 1.0, so the split was fixed 50/50 regardless of what either pack could deliver. main.py now passes weights=self.pack_weights.
The two packs are asymmetric in opposite directions [FROM DATASHEET]: A is 5000 W / 15.36 kWh, B is 4600 W / 20.48 kWh — B has ~33 % more energy and ~8 % less power.
⚠️ States explicitly what weights cannot do: ARCH-01 guarantees a bad split cannot destabilise the site, but says nothing about whether the site can regulate. A unit at its energy limit has g_i = 0 and the load cannot shift to its sibling. Weights are a comfort choice for stability, not for delivery.
⚠️Reviewer: check this does not mask SAFETY-42. SAFETY-42 alarms when a unit stops delivering its share; this PR stops asking a unit that cannot deliver. Same hazard from opposite sides — a weight that silently zeroes a unit could suppress the alarm.
Rebased onto release/1.0 @ 3368231, clean.
Suite: 24 files, all pass (real harness — goodwe-addon-checks:1, python3 <file> per .gitea/workflows/checks.yml; note goodwe-addon-ci:latest + pytest gives a meaningless all-red, that image has no pytest).
The architecture's one free parameter was inert: `main.py` called `resolve()` without weights and `arbiter.py:535` defaulted them to 1.0, so the split was fixed 50/50 regardless of what either pack could deliver. `main.py` now passes `weights=self.pack_weights`.
The two packs are asymmetric in **opposite** directions [FROM DATASHEET]: A is 5000 W / 15.36 kWh, B is 4600 W / 20.48 kWh — B has ~33 % more energy and ~8 % less power.
⚠️ States explicitly what weights **cannot** do: ARCH-01 guarantees a bad split cannot destabilise the site, but says nothing about whether the site can *regulate*. A unit at its energy limit has g_i = 0 and the load cannot shift to its sibling. Weights are a comfort choice for stability, not for delivery.
⚠️ **Reviewer: check this does not mask [[SAFETY-42]].** SAFETY-42 alarms when a unit stops delivering its share; this PR stops *asking* a unit that cannot deliver. Same hazard from opposite sides — a weight that silently zeroes a unit could suppress the alarm.
Rebased onto `release/1.0` @ `3368231`, clean.
Suite: **24 files, all pass** (real harness — `goodwe-addon-checks:1`, `python3 <file>` per `.gitea/workflows/checks.yml`; note `goodwe-addon-ci:latest` + pytest gives a meaningless all-red, that image has no pytest).
Card: OPT-09. Under review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:
* a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
`bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
pack is not asked to discharge and its share goes to the sibling;
* SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
`_fill()`'s last-resort pass will override the moment the sibling saturates.
Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.
Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.
SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.
Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.
⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
B1, the blocking review finding. §5 of the decision said:
"A wedged unit reads a healthy SoC, so no envelope closes and no weight
moves - OPT-09 cannot see it at all."
The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. A weight 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 card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.
So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit. That is a DETECTION REGRESSION at
N=2. It is disclosed here, not fixed: making delinquent() relative to the ask
rather than absolute is SAFETY-42's code and a design change, filed as its own
card.
1. THE CORRECTION, in the three places that carried the claim
* DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam
accurately, quotes the sentence it is replacing, carries the measured
rows and the window arithmetic, and names the residual in the same voice
as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not
exist". Also drops "they meet and do not overlap" from the opening.
* app/packshare.py - a new section in the module docstring, immediately
after "weights are a comfort choice for stability and not for delivery",
which is where the over-read starts.
* CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the
operator-facing sentence: a wedged inverter with less energy headroom
than its sibling can now go unreported at ordinary house loads.
test_packshare.py part 8's closing check and its "what this file cannot
see" list repeated the same false clause; both now say the ENVELOPE cannot
reach a wedged unit and point at part 8b for what the WEIGHTS do to it.
2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks
The reviewer's probe, reproduced: real compute(), real plan(), real
packshare.weights(), real delivery.account(), real delivery.delinquent();
the only model is the plant (A obeys within one cycle inside its rating,
B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against
its own 10 % floor, so envelope() does not close and account() explains
nothing - a SAFETY-42 true positive by that card's own definition, which
a PRECONDITION check asserts before anything is concluded from it.
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 0/200 cycles,
site target +1630 W, shares a+1505, b+125, grid -5 W, named=(nobody)
load 6000 W, B wedged at 15 %, equal weights named on 199/200 cycles
load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles
suppression window: equal weights, site target < 1000 W; OPT-09
weights, site target < 6500 W of a 9600 W site
THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that
the weighted arm names NOBODY where the equal arm names the wedged unit.
A check asserting the alarm still fires would fail, and papering over it
would be worse than the original defect. The 6000 W row is there so the
part is not pinning "the weighted arm never names anybody": past the
healthy pack's own rating, _fill()'s last-resort pass hands the wedged
unit 3000 W and both arms name it. The window is real and it has an edge.
MUTATION - packshare.weights() keeps its shape and forces every weight to
1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree
with python3 -B, not reasoned about:
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b]
suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W
FAIL AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...]
FAIL ...and the arithmetic behind it, stated as a window [...]
FAIL AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...]
test_packshare.py exits 1. Restored: exits 0.
3. THREE PROC-01 PROSE DEFECTS
* DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @
464d398". The branch was rebased; git merge-base HEAD 3368231 is
3368231. Corrected, and it says why the old number was there.
* Same file:13 cited main.py:2272 for "resolution = resolve(...)".
git show 3368231:goodwe_controller/app/main.py | grep -n says 2422.
Corrected. The sibling citation arbiter.py:535 is right and is left.
* DOCS.md:174 said "Since 0.7.x the split is driven by two separate
things", which tells an operator the fix already shipped. config.yaml
is 0.8.0 and the live install is 0.7.0, where the split is 50/50 - this
card's own premise. Now "from 0.8.0 onwards", with an explicit warning
that 0.7.0 and earlier are a fixed 50/50.
NOT TOUCHED, deliberately: delinquent()'s absolute threshold (SAFETY-42's
code, its own card), and app/main.py:2699's double-logged scope refusal (N4,
filed separately).
INVERTER B DOES NOT EXIST (expected ~2026-09-12). Every two-unit figure above
is TWO COPIES OF A through a plant modelled in test_packshare.py; B's 4600 W /
20.48 kWh / 10 % floor are from the datasheet and the serial. Nothing here was
measured on hardware, and the added disclaimers say so in each place.
Verified with the real harness per .gitea/workflows/checks.yml
(goodwe-addon-checks:1): COLLECTED 24 files, ALLGREEN. Harness positive
control run first - test_validate.py deliberately broken, harness reported
"FAILED: test_validate.py" and exited 1; restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
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
admin
merged commit fdb3b69397 into release/1.02026-09-06 23:33:18 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The architecture's one free parameter was inert:
main.pycalledresolve()without weights andarbiter.py:535defaulted them to 1.0, so the split was fixed 50/50 regardless of what either pack could deliver.main.pynow passesweights=self.pack_weights.The two packs are asymmetric in opposite directions [FROM DATASHEET]: A is 5000 W / 15.36 kWh, B is 4600 W / 20.48 kWh — B has ~33 % more energy and ~8 % less power.
⚠️ States explicitly what weights cannot do: ARCH-01 guarantees a bad split cannot destabilise the site, but says nothing about whether the site can regulate. A unit at its energy limit has g_i = 0 and the load cannot shift to its sibling. Weights are a comfort choice for stability, not for delivery.
⚠️ Reviewer: check this does not mask SAFETY-42. SAFETY-42 alarms when a unit stops delivering its share; this PR stops asking a unit that cannot deliver. Same hazard from opposite sides — a weight that silently zeroes a unit could suppress the alarm.
Rebased onto
release/1.0@3368231, clean.Suite: 24 files, all pass (real harness —
goodwe-addon-checks:1,python3 <file>per.gitea/workflows/checks.yml; notegoodwe-addon-ci:latest+ pytest gives a meaningless all-red, that image has no pytest).Card: OPT-09. Under review.
🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
83df37963eto8123e261ce`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING had ever passed one - main.py called `resolve()` without it and the arbiter defaulted it to 1.0 per unit - so the site target was split 50/50 regardless of what either pack could do. Two things now feed it, and they are different kinds of thing: * a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own `bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty pack is not asked to discharge and its share goes to the sibling; * SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which `_fill()`'s last-resort pass will override the moment the sibling saturates. Measured (test_packshare.py part 8): at a load one pack cannot cover, the weighted arm and the shipped arm command the IDENTICAL watts into an empty pack and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it buys is not delivery - it is a reported `unmet_w` where the record used to read impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY. Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the sell side's no-op stated rather than hidden; and a 0 W trade refused by name, because a 0 W arbitrage claim is a park order that outranks the loop. SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a pack that said it could does not - and its end-to-end fixture moved as a result. Its finding is unchanged and is now stated on the two quantities its rejected discriminator actually reads. Not filed at all at a roster of one: at N=1 every source routes to the site, so the interval would bound the SITE TARGET, and SAFETY-25's decided Option A stands untouched on the live install. ⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied (`measured=False`), so every two-inverter figure here is arithmetic or two copies of A. No hardware was touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjUB1, the blocking review finding. §5 of the decision said: "A wedged unit reads a healthy SoC, so no envelope closes and no weight moves - OPT-09 cannot see it at all." The first clause is true and is about the ENVELOPE. The second clause is FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet without overlapping. A weight 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 card exists to exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531) names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w = 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the threshold, and the alarm stops firing on a unit still delivering nothing. So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's detection window for a low-headroom unit. That is a DETECTION REGRESSION at N=2. It is disclosed here, not fixed: making delinquent() relative to the ask rather than absolute is SAFETY-42's code and a design change, filed as its own card. 1. THE CORRECTION, in the three places that carried the claim * DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam accurately, quotes the sentence it is replacing, carries the measured rows and the window arithmetic, and names the residual in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not exist". Also drops "they meet and do not overlap" from the opening. * app/packshare.py - a new section in the module docstring, immediately after "weights are a comfort choice for stability and not for delivery", which is where the over-read starts. * CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the operator-facing sentence: a wedged inverter with less energy headroom than its sibling can now go unreported at ordinary house loads. test_packshare.py part 8's closing check and its "what this file cannot see" list repeated the same false clause; both now say the ENVELOPE cannot reach a wedged unit and point at part 8b for what the WEIGHTS do to it. 2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks The reviewer's probe, reproduced: real compute(), real plan(), real packshare.weights(), real delivery.account(), real delivery.delinquent(); the only model is the plant (A obeys within one cycle inside its rating, B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against its own 10 % floor, so envelope() does not close and account() explains nothing - a SAFETY-42 true positive by that card's own definition, which a PRECONDITION check asserts before anything is concluded from it. load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles, site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b] load 1500 W, B wedged at 15 %, OPT-09 weights named on 0/200 cycles, site target +1630 W, shares a+1505, b+125, grid -5 W, named=(nobody) load 6000 W, B wedged at 15 %, equal weights named on 199/200 cycles load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles suppression window: equal weights, site target < 1000 W; OPT-09 weights, site target < 6500 W of a 9600 W site THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that the weighted arm names NOBODY where the equal arm names the wedged unit. A check asserting the alarm still fires would fail, and papering over it would be worse than the original defect. The 6000 W row is there so the part is not pinning "the weighted arm never names anybody": past the healthy pack's own rating, _fill()'s last-resort pass hands the wedged unit 3000 W and both arms name it. The window is real and it has an edge. MUTATION - packshare.weights() keeps its shape and forces every weight to 1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree with python3 -B, not reasoned about: load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles, site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b] load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles, site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b] load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles, site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b] suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W FAIL AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...] FAIL ...and the arithmetic behind it, stated as a window [...] FAIL AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...] test_packshare.py exits 1. Restored: exits 0. 3. THREE PROC-01 PROSE DEFECTS * DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @ 464d398". The branch was rebased; git merge-base HEAD3368231is3368231. Corrected, and it says why the old number was there. * Same file:13 cited main.py:2272 for "resolution = resolve(...)". git show 3368231:goodwe_controller/app/main.py | grep -n says 2422. Corrected. The sibling citation arbiter.py:535 is right and is left. * DOCS.md:174 said "Since 0.7.x the split is driven by two separate things", which tells an operator the fix already shipped. config.yaml is 0.8.0 and the live install is 0.7.0, where the split is 50/50 - this card's own premise. Now "from 0.8.0 onwards", with an explicit warning that 0.7.0 and earlier are a fixed 50/50. NOT TOUCHED, deliberately: delinquent()'s absolute threshold (SAFETY-42's code, its own card), and app/main.py:2699's double-logged scope refusal (N4, filed separately). INVERTER B DOES NOT EXIST (expected ~2026-09-12). Every two-unit figure above is TWO COPIES OF A through a plant modelled in test_packshare.py; B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the serial. Nothing here was measured on hardware, and the added disclaimers say so in each place. Verified with the real harness per .gitea/workflows/checks.yml (goodwe-addon-checks:1): COLLECTED 24 files, ALLGREEN. Harness positive control run first - test_validate.py deliberately broken, harness reported "FAILED: test_validate.py" and exited 1; restored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU024a4f58ddto8d83ef0dfb