OPT-20: a refusal probe asks only PROBE_W, not the whole trade #191

Merged
admin merged 2 commits from OPT-20 into release/1.0 2026-10-15 14:56:17 +02:00
Owner

Fixes OPT-20 (TEST-23 OPT-15 g7, finding F-A): a refusal probe asked the whole trade, and the part B's pin did not hold landed on a battery in its maintenance charge. Base: release/1.0 bf9019e. Everything measured in the real Controller in the lagged replay (goodwe-addon-checks:1, --network none); no TEST-23 use.

Cause

modetrade.decide() set the trade ask to the sum of the ratings of every charger, the probing refuser included (modetrade.py, ask = -sum(abs(p.rating_w) for p in ch)), and size() capped it only by the loop bound and the headroom. B's SAFETY-73 pin (main.py Claim.set("refused", P_SAFETY, -PROBE_W)) holds 200 W of it; the allocator hands the rest to a unit it can reach. A battery in a maintenance phase gets no mode range (main.py: "never on a battery in any maintenance phase"), so nothing kept the other 2300 W off A. Replayed on bf9019e: A -2300 W for 33 s, grid 3100 W, the trade line "2500 W on inverter-b".

Fix

  • modetrade.decide(): the ask is PROBE_W for the probing battery plus what the other chargers would have had without the probe (none: PROBE_W alone). Tick.probe_w carries the probe's part.
  • modetrade.size(): the other chargers' share is sized as before (bound, headroom), the probe's PROBE_W is added on top. A probe alone is PROBE_W exactly, or no trade when the headroom or the bound is under it (a smaller ask would void the pin and the allocator would place the rest elsewhere).
  • main._mode_trade(): the ask carries PROBE_W for exactly the pins filed this tick. decide() runs before the pin is filed and the window's last cycle has no pin, so on the first and last cycle of a window another battery's share moved by 200 W (a normal charger A: -2300 W instead of -2500 W on the first cycle; a maintenance A: the whole ask, -2500 W, on the cycle after the window).
  • Probe timing, the refusal-ended detection, the REST roles and everything else OPT-15 does are untouched. No option, no string template.

Results (replay, 1 s cycles, lagged readings)

case (B refuses, probed) bf9019e this branch
(a) A in its maintenance charge phase A -2300 W for 33 s and -2500 W for 1 cycle after, grid 3300 W A 0 W, B -200 W, grid 800 W
(a') same, maintenance charge shaped to 0 W same A 0 W, B -200 W
(b) A idle at 0 W A 0 W, B -200 W same
(c) A a normal charger in the run A -2300 W on the first cycle A -2500 W throughout, B -200 W
g7 (drain 18:00 to 18:17:04, then maintenance charge at 0 W) A -2300 W, grid 3300 W A 0 W, B -200 W, grid 800 W

Not changed (read before review)

  • Out of scope, untouched: the base P4 F4 path (a normal trade placed on a battery in maintenance charge) and the action-log wording (UX-38). The only visible change in the log: a probe alone says "200 W on inverter-b" where it said "2500 W" (same template, the number is the size). Reported, not widened.
  • A that gives the house still rests for the window (OPT-11 owner answer 3: while one battery charges from the grid none gives). The grid then rises by what A gave, as on bf9019e (test part 3, case b2). The brief's "at most PROBE_W" holds when A is idle, charging or in maintenance, not when A was giving power. Not a regression; it is the one-sign rule.
  • Beside a normal charger whose share is headroom-limited, the probe's 200 W sits on top of the headroom for the 33 s window (about 7 W on the quarter) instead of that charger giving 200 W up. Chosen so the charger's command is unchanged (brief, point 1); the guard measures every cycle.

Identity

  • Modes off: tools/opt11p3_identity.py (6 scenarios, N=1 and 2, late P1 sample) and the pinned digests in test_price_run_fixes: byte-equal to bf9019e.
  • Modes on, no probe: tools/opt11p4_identity.py (12 scenarios) byte-equal to bf9019e, and tools/opt20_scen.py (3 scripted cheap runs, 1.2 h, 2 inverters, lagged: A Solar first, A charging, A in maintenance charge; every share, grid, plant power, role and trade sentence) pinned in test_opt20.py part 4.

Tests

test_opt20.py: modetrade ask and size (8 checks), the pin sync in _mode_trade (3), the real Controller for (a), (a'), (b), (c), the g7 sequence and (b2) (5 to 6 checks each), identity (1). On bf9019e the replay part fails 12 checks. 12 mutations, each caught by test_opt20.py (table in the dev report). DECISIONS: OPT-11-battery-modes.md, OPT-20 bullet.

Mutations (each caught by test_opt20.py; reds = failed checks there)

# breakage test_opt20 reds others
M1 modetrade.py: the probing battery is asked its whole rating again (the TEST-23 ask) 16 none
M2 modetrade.py: a normal charger counts as a prober (asked PROBE_W) 2 test_modes_p3 1
M3 modetrade.py: a probe under PROBE_W is filed (voids the pin) 1 none
M4 modetrade.py: the probe is not added on top of the other chargers' share 4 none
M5 modetrade.py: the other chargers give 200 W up when headroom binds 1 none
M6 main.py: no pin/ask sync at all 7 none
M7 main.py: sync only when the window has closed (last cycle) 2 none
M8 main.py: sync only when the pin is new (first cycle) 5 none
M9 modetrade.py: the probe is asked twice PROBE_W 3 none
M10 modetrade.py: the bound does not reserve the probe's PROBE_W 1 none
M11 main.py: pins counted by the wrong sign 5 test_price_run_fixes 2
M12 modetrade.py: the tick forgets which part of the ask is the probe 13 none

Co-Authored-By: Claude noreply@anthropic.com

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC

Fixes OPT-20 (TEST-23 OPT-15 g7, finding F-A): a refusal probe asked the whole trade, and the part B's pin did not hold landed on a battery in its maintenance charge. Base: release/1.0 bf9019e. Everything measured in the real Controller in the lagged replay (`goodwe-addon-checks:1`, `--network none`); no TEST-23 use. ## Cause `modetrade.decide()` set the trade ask to the sum of the ratings of every charger, the probing refuser included (`modetrade.py`, `ask = -sum(abs(p.rating_w) for p in ch)`), and `size()` capped it only by the loop bound and the headroom. B's SAFETY-73 pin (`main.py` `Claim.set("refused", P_SAFETY, -PROBE_W)`) holds 200 W of it; the allocator hands the rest to a unit it can reach. A battery in a maintenance phase gets no mode range (`main.py`: "never on a battery in any maintenance phase"), so nothing kept the other 2300 W off A. Replayed on bf9019e: A -2300 W for 33 s, grid 3100 W, the trade line "2500 W on inverter-b". ## Fix - `modetrade.decide()`: the ask is PROBE_W for the probing battery plus what the other chargers would have had without the probe (none: PROBE_W alone). `Tick.probe_w` carries the probe's part. - `modetrade.size()`: the other chargers' share is sized as before (bound, headroom), the probe's PROBE_W is added on top. A probe alone is PROBE_W exactly, or no trade when the headroom or the bound is under it (a smaller ask would void the pin and the allocator would place the rest elsewhere). - `main._mode_trade()`: the ask carries PROBE_W for exactly the pins filed this tick. `decide()` runs before the pin is filed and the window's last cycle has no pin, so on the first and last cycle of a window another battery's share moved by 200 W (a normal charger A: -2300 W instead of -2500 W on the first cycle; a maintenance A: the whole ask, -2500 W, on the cycle after the window). - Probe timing, the refusal-ended detection, the REST roles and everything else OPT-15 does are untouched. No option, no string template. ## Results (replay, 1 s cycles, lagged readings) | case (B refuses, probed) | bf9019e | this branch | |---|---|---| | (a) A in its maintenance charge phase | A -2300 W for 33 s and -2500 W for 1 cycle after, grid 3300 W | A 0 W, B -200 W, grid 800 W | | (a') same, maintenance charge shaped to 0 W | same | A 0 W, B -200 W | | (b) A idle at 0 W | A 0 W, B -200 W | same | | (c) A a normal charger in the run | A -2300 W on the first cycle | A -2500 W throughout, B -200 W | | g7 (drain 18:00 to 18:17:04, then maintenance charge at 0 W) | A -2300 W, grid 3300 W | A 0 W, B -200 W, grid 800 W | ## Not changed (read before review) - **Out of scope, untouched:** the base P4 F4 path (a normal trade placed on a battery in maintenance charge) and the action-log wording (UX-38). The only visible change in the log: a probe alone says "200 W on inverter-b" where it said "2500 W" (same template, the number is the size). Reported, not widened. - **A that gives the house still rests for the window** (OPT-11 owner answer 3: while one battery charges from the grid none gives). The grid then rises by what A gave, as on bf9019e (test part 3, case b2). The brief's "at most PROBE_W" holds when A is idle, charging or in maintenance, not when A was giving power. Not a regression; it is the one-sign rule. - Beside a normal charger whose share is headroom-limited, the probe's 200 W sits on top of the headroom for the 33 s window (about 7 W on the quarter) instead of that charger giving 200 W up. Chosen so the charger's command is unchanged (brief, point 1); the guard measures every cycle. ## Identity - Modes off: `tools/opt11p3_identity.py` (6 scenarios, N=1 and 2, late P1 sample) and the pinned digests in test_price_run_fixes: byte-equal to bf9019e. - Modes on, no probe: `tools/opt11p4_identity.py` (12 scenarios) byte-equal to bf9019e, and `tools/opt20_scen.py` (3 scripted cheap runs, 1.2 h, 2 inverters, lagged: A Solar first, A charging, A in maintenance charge; every share, grid, plant power, role and trade sentence) pinned in test_opt20.py part 4. ## Tests `test_opt20.py`: modetrade ask and size (8 checks), the pin sync in `_mode_trade` (3), the real Controller for (a), (a'), (b), (c), the g7 sequence and (b2) (5 to 6 checks each), identity (1). On bf9019e the replay part fails 12 checks. 12 mutations, each caught by test_opt20.py (table in the dev report). DECISIONS: OPT-11-battery-modes.md, OPT-20 bullet. ## Mutations (each caught by test_opt20.py; reds = failed checks there) | # | breakage | test_opt20 reds | others | |---|---|---|---| | M1 | `modetrade.py`: the probing battery is asked its whole rating again (the TEST-23 ask) | 16 | none | | M2 | `modetrade.py`: a normal charger counts as a prober (asked PROBE_W) | 2 | test_modes_p3 1 | | M3 | `modetrade.py`: a probe under PROBE_W is filed (voids the pin) | 1 | none | | M4 | `modetrade.py`: the probe is not added on top of the other chargers' share | 4 | none | | M5 | `modetrade.py`: the other chargers give 200 W up when headroom binds | 1 | none | | M6 | `main.py`: no pin/ask sync at all | 7 | none | | M7 | `main.py`: sync only when the window has closed (last cycle) | 2 | none | | M8 | `main.py`: sync only when the pin is new (first cycle) | 5 | none | | M9 | `modetrade.py`: the probe is asked twice PROBE_W | 3 | none | | M10 | `modetrade.py`: the bound does not reserve the probe's PROBE_W | 1 | none | | M11 | `main.py`: pins counted by the wrong sign | 5 | test_price_run_fixes 2 | | M12 | `modetrade.py`: the tick forgets which part of the ask is the probe | 13 | none | Co-Authored-By: Claude <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
admin added 1 commit 2026-10-15 13:09:36 +02:00
OPT-20: a refusal probe asks only PROBE_W, not the whole trade
checks / unit (pull_request) Failing after 40m47s
infra-pin / fresh (pull_request) Successful in 1s
06339a2446
The probe's trade was sized at the probing battery's rating. Its SAFETY-73
pin held 200 W of that, and the allocator put the rest on a battery with no
mode range (one in a maintenance phase): TEST-23 OPT-15 g7, -2300 W on A in
its maintenance charge for 34 s, grid 3100 W.

modetrade.decide() now asks PROBE_W for the probing battery plus what the
other chargers would have had without the probe; size() adds the probe on top
of their share, or files PROBE_W exactly, or nothing. _mode_trade() carries
the pin only on the ticks it is filed, so the window's first and last cycle
do not move another battery's share either. Modes off and modes on with no
probe are unchanged (bf9019e digests). Tests: test_opt20.py.

Co-Authored-By: Claude <noreply@anthropic.com>
admin added 1 commit 2026-10-15 13:12:24 +02:00
OPT-20: test_opt20 points at MUTATION.md (test_bytecode part 5)
checks / unit (pull_request) Successful in 40m25s
infra-pin / fresh (pull_request) Successful in 1s
ccdb9dc17a
Co-Authored-By: Claude <noreply@anthropic.com>
Author
Owner

Review of OPT-20 at ccdb9dc (base bf9019e)

Verdict: correct. No blocking findings. Merging with head_commit_id pinned to ccdb9dc.

What I ran (goodwe-addon-checks:1, --network none, PYTHONDONTWRITEBYTECODE=1, own uid)

  • Full suite on ccdb9dc: 78 of 78 pass.
  • test_opt20 part 3 on bf9019e (parts 1, 2 and 4 use the new API): 12 checks fail, the same 12 the dev report names. (b) and (b2) pass there, as expected.
  • Identity, bf9019e vs ccdb9dc, byte-equal: opt11_identity (9 lines), opt11p3_identity (6, modes off), opt11p4_identity (12, modes on, no probe). opt20_scen.py on bf9019e prints the 3 digests pinned in test_opt20 part 4.
  • CI on ccdb9dc: run 1289 checks / unit (pull_request) success; run 1290 infra-pin / fresh success.

My own scenarios (real Controller, lagged replay), ccdb9dc vs bf9019e

scenario ccdb9dc bf9019e
R1 g7, with the drain ending 10 s into a probe window (on the rig the probe came 2 s after the drain) A stays at 0 W, grid 800 W at most A -2500 W, grid 3300 W
R2 A is a normal charger whose share is limited by headroom (house 2600 W) A stays at about -850 W (±6 W loop noise). Quarters 3451/3451 W A drops to -672 W. Quarters 3451/3451 W
R3 probe beside A in maintenance charge. The house steps from 800 to 4200 W 10 s into the window (the guard engages) A stays at 0 W. The trade stops and the pin is voided A -2500 W, grid 6700 W
R4 probe beside A in a maintenance HOLD A stays at 0 W A stays at 0 W
R5 R2, but the house rises 700 W in the middle of the window A follows the shrinking headroom. Quarter means equal to base the same

Mutations (mine, not in the dev's table), against test_opt20.py

# breakage reds
R3 sync adds the pin's watts but never removes the stale probe (ask_w - pins) 5
R4 sync moves the ask but leaves probe_w unchanged 6
R5 decide sets probe_w but leaves the probe out of the ask 6
R6 size: a probe alone needs more than PROBE_W of room (> for >=) 3
R7 size: the others' share counts the probe in the ask (-ask_w without - pw) 11
R8 size: the total is held under the headroom (min(rest + pw, room), so the other charger gives up 200 W) 1
R10 size: the probe branch is skipped for a single probe (pw > PROBE_W) 2
R1, R2 sync: drop the source == "refused" filter, or the kind == "set" filter 0, equivalent: no other charge SET claim exists at that point (maintenance SETs are drain >0 or hold 0), and refusal limits are not set

Findings (none blocking)

  1. Info (dev report only, not code): the "residual" of a P_PEAK floor clamping the trade below PROBE_W cannot happen today. peakguard.claim() files a floor only while the guard is engaged. That guard is decided this cycle (main.py:4728), before _mode_roles() (main.py:5055). An engaged guard makes charge_ok False (main.py:2361), so no trade is filed in that cycle and the pin is voided at a target >= 0. R3 confirms this: no leak. DECISIONS does not claim the residual, so no change is needed.
  2. Low, test strength: the refusing plant never draws the probe's 200 W, so the replay's "grid up by at most PROBE_W" check covers A's leak only. The design choice of probing beside a headroom-bound charger is covered by the unit size check alone (M5, R8). R2 above shows it holds in the integrated path. Not worth a CI cycle now.
  3. Info: the g7 replay is a re-scripted sequence, not a trace replay. Its first probe after the drain is at 18:31, while the rig's was at 18:19:02, 2 s after the drain. R1 covers that edge: on ccdb9dc A stays at 0 W.

The deviations

  • (a) A gives power to the house during a probe: A rests for the window and the grid rises by what A gave, the same as on bf9019e. Recommendation: accept, do not skip the probe.
    • The headroom does not depend on the battery (peakguard wanted), so the probe is only filed when the quarter can take the house without A plus 200 W.
    • The cost is about 800 W × 34 s ≈ 7.5 Wh per probe, at the cheap-run price.
    • Skipping the probe while another battery gives would leave a refusing battery unprobed for most of a night cheap run. That is OPT-15 F3 again.
    • Letting A keep giving while B charges is the opposite-direction split that SAFETY-73 forbids.
  • (b) The probe sits up to 200 W above the headroom: safe.
    • The worst case is about 7 W on the quarter, inside the 50 W peak_margin_w. The over-headroom part flows only if B really draws 200 W, and then the probe is answered and the refusal ends.
    • The headroom is fed forward from the quarter's banked energy, so a charge that continues absorbs the overshoot later in the quarter. R2 and R5 bank the same as base.
    • The loop bound is never exceeded (rest <= -lo - pw).
  • (c) P_PEAK clamp: not reproducible (finding 1). At the owner's site (2 × 2500 W) the bound with a probe is -2700 W. The probe alone is filed only with room >= 200 W, and a floor never coexists with a trade. Likelihood: none through P_PEAK today. It would take a new claim above P_ARBITRAGE that clamps a filed charge to between 0 and -200 W.

Nothing pushed.

## Review of OPT-20 at ccdb9dc (base bf9019e) **Verdict: correct. No blocking findings. Merging with head_commit_id pinned to ccdb9dc.** ### What I ran (goodwe-addon-checks:1, --network none, PYTHONDONTWRITEBYTECODE=1, own uid) - Full suite on ccdb9dc: **78 of 78 pass**. - test_opt20 part 3 on bf9019e (parts 1, 2 and 4 use the new API): **12 checks fail**, the same 12 the dev report names. (b) and (b2) pass there, as expected. - Identity, bf9019e vs ccdb9dc, byte-equal: `opt11_identity` (9 lines), `opt11p3_identity` (6, modes off), `opt11p4_identity` (12, modes on, no probe). `opt20_scen.py` on bf9019e prints the 3 digests pinned in test_opt20 part 4. - CI on ccdb9dc: run 1289 `checks / unit (pull_request)` success; run 1290 `infra-pin / fresh` success. ### My own scenarios (real Controller, lagged replay), ccdb9dc vs bf9019e | | scenario | ccdb9dc | bf9019e | |---|---|---|---| | R1 | g7, with the drain ending 10 s into a probe window (on the rig the probe came 2 s after the drain) | A stays at 0 W, grid 800 W at most | A -2500 W, grid 3300 W | | R2 | A is a normal charger whose share is limited by headroom (house 2600 W) | A stays at about -850 W (±6 W loop noise). Quarters 3451/3451 W | A drops to -672 W. Quarters 3451/3451 W | | R3 | probe beside A in maintenance charge. The house steps from 800 to 4200 W 10 s into the window (the guard engages) | A stays at 0 W. The trade stops and the pin is voided | A -2500 W, grid 6700 W | | R4 | probe beside A in a maintenance HOLD | A stays at 0 W | A stays at 0 W | | R5 | R2, but the house rises 700 W in the middle of the window | A follows the shrinking headroom. Quarter means equal to base | the same | ### Mutations (mine, not in the dev's table), against test_opt20.py | # | breakage | reds | |---|---|---| | R3 | sync adds the pin's watts but never removes the stale probe (`ask_w - pins`) | 5 | | R4 | sync moves the ask but leaves `probe_w` unchanged | 6 | | R5 | decide sets probe_w but leaves the probe out of the ask | 6 | | R6 | size: a probe alone needs more than PROBE_W of room (`>` for `>=`) | 3 | | R7 | size: the others' share counts the probe in the ask (`-ask_w` without `- pw`) | 11 | | R8 | size: the total is held under the headroom (`min(rest + pw, room)`, so the other charger gives up 200 W) | 1 | | R10 | size: the probe branch is skipped for a single probe (`pw > PROBE_W`) | 2 | | R1, R2 | sync: drop the `source == "refused"` filter, or the `kind == "set"` filter | 0, **equivalent**: no other charge SET claim exists at that point (maintenance SETs are drain >0 or hold 0), and refusal limits are not `set` | ### Findings (none blocking) 1. **Info (dev report only, not code): the "residual" of a P_PEAK floor clamping the trade below PROBE_W cannot happen today.** `peakguard.claim()` files a floor only while the guard is engaged. That guard is decided this cycle (main.py:4728), before `_mode_roles()` (main.py:5055). An engaged guard makes `charge_ok` False (main.py:2361), so no trade is filed in that cycle and the pin is voided at a target >= 0. R3 confirms this: no leak. DECISIONS does not claim the residual, so no change is needed. 2. **Low, test strength:** the refusing plant never draws the probe's 200 W, so the replay's "grid up by at most PROBE_W" check covers A's leak only. The design choice of probing beside a headroom-bound charger is covered by the unit `size` check alone (M5, R8). R2 above shows it holds in the integrated path. Not worth a CI cycle now. 3. **Info:** the g7 replay is a re-scripted sequence, not a trace replay. Its first probe after the drain is at 18:31, while the rig's was at 18:19:02, 2 s after the drain. R1 covers that edge: on ccdb9dc A stays at 0 W. ### The deviations - **(a) A gives power to the house during a probe:** A rests for the window and the grid rises by what A gave, the same as on bf9019e. **Recommendation: accept, do not skip the probe.** - The headroom does not depend on the battery (peakguard `wanted`), so the probe is only filed when the quarter can take the house without A plus 200 W. - The cost is about 800 W × 34 s ≈ 7.5 Wh per probe, at the cheap-run price. - Skipping the probe while another battery gives would leave a refusing battery unprobed for most of a night cheap run. That is OPT-15 F3 again. - Letting A keep giving while B charges is the opposite-direction split that SAFETY-73 forbids. - **(b) The probe sits up to 200 W above the headroom:** safe. - The worst case is about 7 W on the quarter, inside the 50 W `peak_margin_w`. The over-headroom part flows only if B really draws 200 W, and then the probe is answered and the refusal ends. - The headroom is fed forward from the quarter's banked energy, so a charge that continues absorbs the overshoot later in the quarter. R2 and R5 bank the same as base. - The loop bound is never exceeded (`rest <= -lo - pw`). - **(c) P_PEAK clamp:** not reproducible (finding 1). At the owner's site (2 × 2500 W) the bound with a probe is -2700 W. The probe alone is filed only with room >= 200 W, and a floor never coexists with a trade. Likelihood: none through P_PEAK today. It would take a new claim above P_ARBITRAGE that clamps a filed charge to between 0 and -200 W. Nothing pushed.
admin merged commit 004f859ec7 into release/1.0 2026-10-15 14:56:17 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#191