Fixes the three P3 findings from the OPT-11 P3 post-merge test on TEST-23 (5a4cdc2). Rebased by merge onto release/1.0 e3e4ba6 (release/1.0 merged in, head 065792a; no rebase, no force push). Every number below was measured on e3e4ba6 + this branch. No TEST-23 use: everything is the real Controller in the replay harness (goodwe-addon-checks:1, --network none).
What the merge changed (read this first)
Conflicts: 5 files, 9 hunks, both sides kept (p1.pyheld_t beside held_since; modetrade.pychargers(targets, probe) and a drain clearing the probe; main.py the roll inside SAFETY-90/91's _peak_proj chain, the hold beside SAFETY-95's drain-tail grid_w, P4 state beside the hold state; DOCS and DECISIONS both lines).
F1 at two inverters is now also done by SAFETY-91 (next_quarter() grades the next quarter past a block's end). With the old projection two inverters no longer stop at an edge, and rolling gives the same banked quarters (test_price_run_fixes part 1b). The roll is still what fixes one inverter, where the old projection stops the charge at every edge. It stays as designed; nothing is wrong or redundant enough to change the approach.
SAFETY-81 x F1. With modes on, the roll also removes the 1 s 0 W command at each quarter edge that tools/safety81_sim.py showed at one inverter, so that test's modes-on digests moved (re-pinned, with a note). Modes off is byte-identical to e3e4ba6 (new section 1b pins it). The alarm needed one adaptation: a rolled projection is not graded until the quarter's first sample, as the old "no seconds left" tick was not, so a standing overload is still notified once per quarter (M16).
test_modes_p4 part 1: the six scenarios with a grid charge (_ch) are re-pinned. With the hold and its reseed switched off they equal 5a4cdc2's again (roll and probe do not touch them); the others never moved.
test_price_run_fixes part 5: identity pins now e3e4ba6's. n1 and n2 are still 5a4cdc2's; n1_lag/n2_lag moved with SAFETY-90/91 (n2_lag now equals n2).
F1 — charge stopped at a quarter edge and latched (medium)
Cause (reproduced): the P1 averager opens a quarter only when a sample arrives and the HA source builds it 0.35 s after the reading, so the cycle on the boundary tick still saw the old block: "no seconds left", charge_headroom_w() None, "no trade (headroom)", and the latch (keyed to the wall-clock quarter) held the NEW quarter off. Fix: with modes on, the guard projects the quarter now is in (PeakTracker.project(roll=True), crediting the held sample as QuarterAverager.add() will, under max_hold_s). Second layer: only a measured stop latches; the first unmeasured one in a quarter stops that tick only, the second latches (a headroom missing every other cycle flipped the charge 1799 times in 30 min before this). On e3e4ba6: one inverter, old projection: a stop at each of the 6 edges crossed (headroom); rolled: none. Two inverters: none either way, banked quarters equal. Charge start still needs more than 500 W headroom; a real mid-quarter guard stop still latches.
F2 — charge-end export overshoot (medium)
Fix: after the reseed at E the loop is held there (no error integrated) until the meter reaches the target in the step's direction, EDGE_HOLD_S (18 s, up to 36 s while the battery reading still moves toward E), a new trade, or another claim moves the command. The replay gained Lag (command dead time, meter and battery reading age, P1 debounce, 1 s cycles).
lagged, meter 2 s, hold removed (= the old reseed)
−1370 W
−10 W
lagged variants (8: 1 s / 2 s cycles, meter 1/2/5 s, fresh battery, slow 8 s battery)
—
−10 … −51 W
Bound 0.15 × rating = 375 W; no import above the charge's own; settle ≤ 14 s (60 s for the slow battery). Unchanged from the numbers on 5a4cdc2.
Can the hold let a rising house push the quarter over the cap? No.
Lagged replay, two inverters, Cheap hours A charges, Solar first B takes over; house rises +1, +2, +3 kW at the charge end, edge placed 30 s to 15 s before the quarter's end. Banked quarter containing the edge (the next quarter is at most 144 W). Aim 3450 W, cap 3500 W.
slow battery (8 s, 2 s dead), 5 s meter, edge 885 s
+1 / +2 / +3 kW
3444.1 / 3450.1 / 3456.0
3443.5 / 3449.4 / 3455.4
3444.2 / 3450.1 / 3456.1
Other edges (30 s, 300 s, 600 s) are lower. The hold adds at most +33 W to a quarter against the loop without it (+33 W at house 800 W, +10 W at house 2000 W, +22 W with the slow plant); the highest quarter is 3430.5 W (1 s plant) and 3456.1 W (slow plant, the lag's own 1.4–6 W over the aim, same on e3e4ba6), 44 W under the cap. Why: the import during the hold is the rise, below the charge's own import it replaced; a rise past that is the peak floor's, which ends the hold at once (800 → 4500 W: grid 2000 W ten seconds later). One inverter: the hold is a no-op there (E is 0 W for a Cheap-hours battery), head = no hold, highest 3451.3 W (the known lag overshoot).
F3 — refusal inside a cheap run never probed (low)
Probe, as the card says. 200 W for ~34 s every 15 min under the same gates (guard clear, measured headroom > 500 W, not latched, idle, cheap hours), the others rest; an answered probe wins back the run. Outside the probe window the refusal wins. At one inverter SAFETY-52 already ends a charge refusal. On the new base: a monthly drain (SAFETY-78) comes first, and with a day plan (P4) a probing Leftover solar battery goes only to its planned level; energy() never counts a refuser, so run planning is unchanged.
Yes. The roll is inside the _peak_proj chain; at two inverters it duplicates next_quarter() (equal banked quarters), at one it is what fixes F1. Gap fill runs on the rolled projection (M21).
SAFETY-81 (Load too high alarm)
Yes, see above: modes-on digests re-pinned, alarm restart kept (M16).
No change to those paths. A drain clears the chargers, so no probe and no trade; a claim that moves the command ends the hold at once (_edge_grid(end), unit-tested; not run as a maintenance scenario); SAFETY-95's drain tail keeps its own grid, the hold sits on the other branch (M18). peak_fc and the charge floor read the forecast sensor, not the projection. SAFETY-111 is not in e3e4ba6 (no code, DECISIONS or PR), so not checked.
OPT-11 P4, OPT-17 (day plan, forecast lists)
The plan's levels (charge_to) now apply to a probing refuser; energy() and run sizing unchanged. The forecast lists feed dayplan only. test_modes_p4 modes-on _ch pins re-pinned (hold only).
Reused: the probe is the existing pin; F3 only makes the refuser a charger in its window.
SAFETY-112 (waiting for HA)
No. Display only; a skipped cycle lets the hold run out on the clock.
Tests
test_price_run_fixes.py (parts 1–5 as before, plus: F1 at one inverter and the two-inverter equivalence, drain vs probe, the day plan vs probe), test_modes_p3.py, test_safety81.py (+ modes-off pins), test_modes_p4.py (re-pins). Full suite in goodwe-addon-checks:1 (--network none, PYTHONDONTWRITEBYTECODE=1): 74/74 pass on the merged head.
Identity, tools/opt11p3_identity.py, e3e4ba6 and the merged head, equal in all six: n1 71245b22…, n2 56cf2801…, n1_off 71245b22…, n2_off 56cf2801…, n1_lag 86ef0f5a…, n2_lag 56cf2801…. SAFETY-81 sim modes off: const4500, edge4600, step6500, burst_end, twice7000, sine4500_fast equal.
With lagged readings a headroom-sized quarter banks up to 1.4 W over the aim (48.6 W under the cap); with a slow 8 s battery up to 6 W over (44 W under).
A battery reading older than the meter can make a charge start look like a house over the aim: a real guard stop, latched, plus a −1700 W edge in that lag variant.
DOCS (Battery modes, three lines) and DECISIONS/OPT-11-battery-modes.md (OPT-15 item) updated; no new user-facing text.
Fixes the three P3 findings from the OPT-11 P3 post-merge test on TEST-23 (5a4cdc2). **Rebased by merge onto release/1.0 e3e4ba6** (`release/1.0` merged in, head 065792a; no rebase, no force push). Every number below was measured on e3e4ba6 + this branch. No TEST-23 use: everything is the real Controller in the replay harness (`goodwe-addon-checks:1`, `--network none`).
## What the merge changed (read this first)
- **Conflicts: 5 files, 9 hunks, both sides kept** (`p1.py` `held_t` beside `held_since`; `modetrade.py` `chargers(targets, probe)` and a drain clearing the probe; `main.py` the roll inside SAFETY-90/91's `_peak_proj` chain, the hold beside SAFETY-95's drain-tail `grid_w`, P4 state beside the hold state; DOCS and DECISIONS both lines).
- **F1 at two inverters is now also done by SAFETY-91** (`next_quarter()` grades the next quarter past a block's end). With the old projection two inverters no longer stop at an edge, and rolling gives the same banked quarters (`test_price_run_fixes` part 1b). The roll is still what fixes **one inverter**, where the old projection stops the charge at every edge. It stays as designed; nothing is wrong or redundant enough to change the approach.
- **SAFETY-81 x F1.** With modes on, the roll also removes the 1 s 0 W command at each quarter edge that `tools/safety81_sim.py` showed at one inverter, so that test's modes-on digests moved (re-pinned, with a note). Modes off is byte-identical to e3e4ba6 (new section 1b pins it). The alarm needed one adaptation: a rolled projection is not graded until the quarter's first sample, as the old "no seconds left" tick was not, so a standing overload is still notified once per quarter (M16).
- `test_modes_p4` part 1: the six scenarios with a grid charge (`_ch`) are re-pinned. With the hold and its reseed switched off they equal 5a4cdc2's again (roll and probe do not touch them); the others never moved.
- `test_price_run_fixes` part 5: identity pins now e3e4ba6's. `n1` and `n2` are still 5a4cdc2's; `n1_lag`/`n2_lag` moved with SAFETY-90/91 (`n2_lag` now equals `n2`).
## F1 — charge stopped at a quarter edge and latched (medium)
**Cause (reproduced):** the P1 averager opens a quarter only when a sample arrives and the HA source builds it 0.35 s after the reading, so the cycle on the boundary tick still saw the old block: "no seconds left", `charge_headroom_w()` None, "no trade (headroom)", and the latch (keyed to the wall-clock quarter) held the NEW quarter off.
**Fix:** with modes on, the guard projects the quarter `now` is in (`PeakTracker.project(roll=True)`, crediting the held sample as `QuarterAverager.add()` will, under `max_hold_s`). Second layer: only a *measured* stop latches; the first unmeasured one in a quarter stops that tick only, the second latches (a headroom missing every other cycle flipped the charge 1799 times in 30 min before this).
**On e3e4ba6:** one inverter, old projection: a stop at each of the 6 edges crossed (`headroom`); rolled: none. Two inverters: none either way, banked quarters equal. Charge start still needs more than 500 W headroom; a real mid-quarter guard stop still latches.
## F2 — charge-end export overshoot (medium)
**Fix:** after the reseed at E the loop is held there (no error integrated) until the meter reaches the target in the step's direction, `EDGE_HOLD_S` (18 s, up to 36 s while the battery reading still moves toward E), a new trade, or another claim moves the command. The replay gained `Lag` (command dead time, meter and battery reading age, P1 debounce, 1 s cycles).
| charge end, 2.5 kW under an 800 W house | before | after |
|---|---|---|
| TEST-23 measured (5a4cdc2) | −1120 W (0.45×) | — |
| model, 5 s, instant readings | −257 W | −10 W |
| lagged, meter 2 s, hold removed (= the old reseed) | −1370 W | −10 W |
| lagged variants (8: 1 s / 2 s cycles, meter 1/2/5 s, fresh battery, slow 8 s battery) | — | −10 … −51 W |
Bound 0.15 × rating = 375 W; no import above the charge's own; settle ≤ 14 s (60 s for the slow battery). Unchanged from the numbers on 5a4cdc2.
### Can the hold let a rising house push the quarter over the cap? No.
Lagged replay, two inverters, Cheap hours A charges, Solar first B takes over; house rises +1, +2, +3 kW at the charge end, edge placed 30 s to 15 s before the quarter's end. Banked quarter containing the edge (the next quarter is at most 144 W). Aim 3450 W, cap 3500 W.
| plant, house 2000 W (charge sized to the aim) | rise | e3e4ba6 | head, no hold | head |
|---|---|---|---|---|
| 1 s plant, edge 03:59:00 (840 s) | +1 / +2 / +3 kW | 3240.6 / 3252.2 / 3317.0 | 3242.4 / 3251.5 / 3316.3 | 3234.5 / 3261.3 / 3326.1 |
| 1 s plant, edge 03:59:45 (885 s) | +1 / +2 / +3 kW | 3405.9 / 3409.7 / 3424.6 | 3406.3 / 3409.1 / 3423.9 | 3404.5 / 3415.7 / **3430.5** |
| slow battery (8 s, 2 s dead), 5 s meter, edge 885 s | +1 / +2 / +3 kW | 3444.1 / 3450.1 / 3456.0 | 3443.5 / 3449.4 / 3455.4 | 3444.2 / 3450.1 / **3456.1** |
Other edges (30 s, 300 s, 600 s) are lower. The hold adds at most +33 W to a quarter against the loop without it (+33 W at house 800 W, +10 W at house 2000 W, +22 W with the slow plant); the highest quarter is 3430.5 W (1 s plant) and 3456.1 W (slow plant, the lag's own 1.4–6 W over the aim, same on e3e4ba6), 44 W under the cap. Why: the import during the hold is the rise, below the charge's own import it replaced; a rise past that is the peak floor's, which ends the hold at once (800 → 4500 W: grid 2000 W ten seconds later). One inverter: the hold is a no-op there (E is 0 W for a Cheap-hours battery), head = no hold, highest 3451.3 W (the known lag overshoot).
## F3 — refusal inside a cheap run never probed (low)
**Probe, as the card says.** 200 W for ~34 s every 15 min under the same gates (guard clear, measured headroom > 500 W, not latched, idle, cheap hours), the others rest; an answered probe wins back the run. Outside the probe window the refusal wins. At one inverter SAFETY-52 already ends a charge refusal. **On the new base:** a monthly drain (SAFETY-78) comes first, and with a day plan (P4) a probing Leftover solar battery goes only to its planned level; `energy()` never counts a refuser, so run planning is unchanged.
## Interaction with what landed since 5a4cdc2
| change | interacts? |
|---|---|
| SAFETY-90 (gap fill), SAFETY-91 (`next_quarter`) | Yes. The roll is inside the `_peak_proj` chain; at two inverters it duplicates `next_quarter()` (equal banked quarters), at one it is what fixes F1. Gap fill runs on the rolled projection (M21). |
| SAFETY-81 (Load too high alarm) | Yes, see above: modes-on digests re-pinned, alarm restart kept (M16). |
| SAFETY-78/84/93/95/96/103, MAINT-13/14/15 (maintenance drain/charge/hold, `_charge_gate_open`, `maintenance_charge_floor`) | No change to those paths. A drain clears the chargers, so no probe and no trade; a claim that moves the command ends the hold at once (`_edge_grid(end)`, unit-tested; not run as a maintenance scenario); SAFETY-95's drain tail keeps its own grid, the hold sits on the other branch (M18). `peak_fc` and the charge floor read the forecast sensor, not the projection. SAFETY-111 is not in e3e4ba6 (no code, DECISIONS or PR), so not checked. |
| OPT-11 P4, OPT-17 (day plan, forecast lists) | The plan's levels (`charge_to`) now apply to a probing refuser; `energy()` and run sizing unchanged. The forecast lists feed `dayplan` only. `test_modes_p4` modes-on `_ch` pins re-pinned (hold only). |
| SAFETY-107 (refusal log, one inverter) | No. N=1 only; F3's probe reads `inv.probe_from` (N>1). |
| SAFETY-73 probe pin | Reused: the probe is the existing pin; F3 only makes the refuser a charger in its window. |
| SAFETY-112 (waiting for HA) | No. Display only; a skipped cycle lets the hold run out on the clock. |
## Tests
`test_price_run_fixes.py` (parts 1–5 as before, plus: F1 at one inverter and the two-inverter equivalence, drain vs probe, the day plan vs probe), `test_modes_p3.py`, `test_safety81.py` (+ modes-off pins), `test_modes_p4.py` (re-pins). Full suite in `goodwe-addon-checks:1` (`--network none`, `PYTHONDONTWRITEBYTECODE=1`): 74/74 pass on the merged head.
Identity, `tools/opt11p3_identity.py`, e3e4ba6 and the merged head, equal in all six: n1 71245b22…, n2 56cf2801…, n1_off 71245b22…, n2_off 56cf2801…, n1_lag 86ef0f5a…, n2_lag 56cf2801…. SAFETY-81 sim modes off: const4500, edge4600, step6500, burst_end, twice7000, sine4500_fast equal.
## Mutations (fresh copies, graded over test_price_run_fixes / test_modes_p3 unless noted; reds)
| # | mutation | reds |
|---|---|---|
| M1 | main: `roll=False` | 1 / 0 |
| M2 | main: an unmeasured headroom latches | 2 / 0 |
| M3 | main: a measured headroom stop does not latch | 1 / 1 |
| M4 | tariff: rolled projection credits past `max_hold_s` | 1 / 0 |
| M5 | tariff: rolled projection drops the held sample | 1 / 0 |
| M6 | main: no run-edge hold | 3 / 1 |
| M7 | main: hold ignores the meter reaching the target | 2 / 0 |
| M8 | main: hold never times out | 2 / 0 |
| M9 | main: no extension while the battery moves | 2 / 0 |
| M10 | main: a new trade / another claim does not end the hold | 2 / 0 |
| M11 | modetrade: `probe=False` | 5 / 0 |
| M12 | modetrade: a refuser is a charger outside its probe | 2 / 1 |
| M13 | modetrade: a probing battery in maintenance is a charger | 1 / 0 |
| M14 | main: `roll=True` (leaks into modes off) | 1 / 0 |
| M15 | main: a refuser counts as probing all the time | 1 / 0 (red since the review test; was equivalent on the first round) |
| M16 | merge: SAFETY-81 alarm grades the rolled projection (`test_safety81`) | 4 |
| M17 | merge: a drain no longer stops a probing refuser's charge | 1 / 0 |
| M18 | merge: the SAFETY-95 line reads `self.grid` on its non-tail branch, bypassing the hold | 3 / 0 |
| M19 | merge: P4 `targets` dropped from the decide() chargers call (+ `test_modes_p4`) | 1 / 0 + 4 |
| M20 | merge: the reseed's own move ends the hold at once (`moved = False` removed) | 2 / 1 |
| M21 | merge: SAFETY-90 gap fill skipped on the projection (+ `test_safety90`) | 3 / 0 + 29 |
## Not fixed (same on e3e4ba6), for the board
- With lagged readings a headroom-sized quarter banks up to 1.4 W over the aim (48.6 W under the cap); with a slow 8 s battery up to 6 W over (44 W under).
- A battery reading older than the meter can make a charge start look like a house over the aim: a real guard stop, latched, plus a −1700 W edge in that lag variant.
DOCS (Battery modes, three lines) and DECISIONS/OPT-11-battery-modes.md (OPT-15 item) updated; no new user-facing text.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
F1: with battery modes on, the peak guard projects the quarter `now` is in
even before its first P1 sample (PeakTracker.project(roll=True)), and only
a measured stop latches the period. The edge tick no longer reads "no
seconds left", so a charge is not stopped and latched at a quarter edge.
F2: after a run edge the loop is held at E until the meter shows the step
(EDGE_HOLD_S, longer while the battery still moves). The replay gained a
lag model (dead time, reading age, P1 debounce, 1 s cycles) that shows
TEST-23's overshoot without the hold.
F3: a charge refuser's SAFETY-73 probe inside a cheap run is a charger
pinned at PROBE_W, the others rest, so the probe actually runs.
Modes off stays byte-identical to 5a4cdc2 (new late-P1 identity scenarios).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A second unmeasured-headroom stop in a quarter latches the charge: a
headroom missing every other cycle flipped it 1799 times in 30 min.
- Unit test of the run-edge hold's end conditions and of a small step.
- Outside its probe a refuser does not make the others rest (M15 red).
- DECISIONS: one review line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts (5 files, 9 hunks) resolved keeping both sides:
- p1.py: held_t (OPT-15) beside held_since (SAFETY-90).
- modetrade.py: chargers() takes targets (P4) and probe (OPT-15); decide()
clears the chargers, a probing refuser included, while a battery drains
(SAFETY-78); the docstring lists both rules.
- main.py: the roll goes into the SAFETY-90/91 projection chain
(`_peak_proj`); the SAFETY-95 drain-tail grid_w keeps the run-edge hold on
its other branch; the P4 forecast state and the hold state both stay.
- DOCS.md, DECISIONS: both lines kept, OPT-15 item refreshed.
Adaptations the merge needs:
- SAFETY-81 x F1: a rolled projection is not graded by the saturation alarm
until the quarter's first sample, as the old edge tick was not, so a
standing overload is still notified once per quarter.
- test_safety81, test_modes_p4: modes-on pins re-pinned (with a note), modes
off pinned to e3e4ba6; test_price_run_fixes: identity pins on e3e4ba6,
F1 reproduced at one inverter (SAFETY-91 already holds two), tests for the
drain and the day plan against the probe.
Co-Authored-By: Claude <noreply@anthropic.com>
Two survivors of the review's mutations get a check each in
test_price_run_fixes.py (X1: a held export credited negative in the rolled
quarter; X4: the hold extended by a reading already past E). DECISIONS: the
slow-battery quarter (3456 W, same on e3e4ba6) and the replay of a
maintenance phase starting during the hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Verdict: correct. No blocker. Three low findings for the board (no code change in this PR). I merge once checks / unit is green on 7f0f00b.
What I re-ran (goodwe-addon-checks:1, --network none, PYTHONDONTWRITEBYTECODE=1)
Full suite on 065792a, hwsim required: 74/74 pass.
Identity (tools/opt11p3_identity.py), e3e4ba6 vs head: all six equal (n1 71245b22…, n2 56cf2801…, n1_off, n2_off, n1_lag 86ef0f5a…, n2_lag 56cf2801…).
SAFETY-81 sim, modes off: all 15 scenarios equal to e3e4ba6 (the test pins 6). Modes on: alarm times as pinned (1813 -> 1811 at the quarter edge, ramp6500 1316 -> 1317).
test_modes_p4_ch re-pins: with the old reseed and no hold, the six digests are exactly 5a4cdc2's old pins again. The re-pin is the hold, nothing else.
Item 4: re-ran house 2000 W, edges 840 s and 885 s, 1 s plant and slow plant, base / head without hold / head: dev's numbers reproduced exactly (highest 3430.5 W and 3456.1 W; slow plant at 840 s, not in the PR: 3386.4 W head vs 3370.2 W base).
Merge: all 9 conflicted hunks keep both sides (remerge-diff read hunk by hunk).
Maintenance during a hold (replayed, real Controller, lagged plant, edge + 3 s)
B drain, B hold, A drain, B maintenance charge, and a drain on the edge tick itself: the hold ends on the tick the maintenance claim appears in every case. With B hold or B charge, the head is smoother than without the hold (no 1600 W import bump).
Findings (low, follow-up cards, not blockers)
A clipped E holds to the timeout.main.py:2492 clips E to the loop bound; the meter then cannot reach the target, so the hold always runs to EDGE_HOLD_S. If the bound lifts during the hold, nothing ends it (moved stays false). Replayed: B's maintenance charge ends 3 s after the edge, and the site stays at 0 W, importing the 800 W house, for 15 s more. Without the hold the loop moves after 1 s. The same self.i_w = held_w (main.py:2520) overwrites a SAFETY-93/95/96 reseed (main.py:5083 and the next two calls) on the next tick. The cost is bounded: at most 18-36 s, the import is below the charge's own, about 13 W on the quarter. Suggested fix: no hold when E was clipped, or end the hold when a _reseed_* moves the target.
SAFETY-81 at two inverters. On e3e4ba6 the edge tick at N>=2 was graded on SAFETY-91's next-quarter projection. With modes on it is now skipped (main.py:5877), so the 10 s hold restarts at each edge there too. This changes the alarm cadence only, not control. Not measured, because safety81_sim has one inverter. The comment above that line is true at one inverter only.
Action log during F3 probes. Each probe writes two action-log lines, "ARBITRAGE_CHARGE … 200 W on inverter-a" and "arbitrage idle … no trade (size)" (hint "Trading: waiting for a better price"). That is 8 lines an hour while a refusal stands in a cheap run, in a 40-line log. e3e4ba6 writes none. "BATTERY REFUSES" and "… ENDED" are each written once, so SAFETY-73 and SAFETY-107 are not doubled. It is not a flood, but it is noise.
My mutations (not in M1-M21; graded over test_price_run_fixes / test_modes_p3 / test_safety81)
#
mutation
reds
X1
tariff: rolled acc credits a held export as negative
## Review of 065792a (+ 7f0f00b, tests and DECISIONS only)
**Verdict: correct. No blocker. Three low findings for the board (no code change in this PR).** I merge once `checks / unit` is green on 7f0f00b.
### What I re-ran (goodwe-addon-checks:1, `--network none`, `PYTHONDONTWRITEBYTECODE=1`)
- Full suite on 065792a, hwsim required: **74/74 pass**.
- Identity (`tools/opt11p3_identity.py`), e3e4ba6 vs head: **all six equal** (n1 71245b22…, n2 56cf2801…, n1_off, n2_off, n1_lag 86ef0f5a…, n2_lag 56cf2801…).
- SAFETY-81 sim, modes off: **all 15 scenarios equal** to e3e4ba6 (the test pins 6). Modes on: alarm times as pinned (1813 -> 1811 at the quarter edge, ramp6500 1316 -> 1317).
- `test_modes_p4` `_ch` re-pins: with the old reseed and no hold, the six digests are exactly 5a4cdc2's old pins again. The re-pin is the hold, nothing else.
- Item 4: re-ran house 2000 W, edges 840 s and 885 s, 1 s plant and slow plant, base / head without hold / head: dev's numbers reproduced exactly (highest 3430.5 W and 3456.1 W; slow plant at 840 s, not in the PR: 3386.4 W head vs 3370.2 W base).
- Merge: all 9 conflicted hunks keep both sides (remerge-diff read hunk by hunk).
### Maintenance during a hold (replayed, real Controller, lagged plant, edge + 3 s)
B drain, B hold, A drain, B maintenance charge, and a drain on the edge tick itself: **the hold ends on the tick the maintenance claim appears** in every case. With B hold or B charge, the head is smoother than without the hold (no 1600 W import bump).
### Findings (low, follow-up cards, not blockers)
1. **A clipped E holds to the timeout.** `main.py:2492` clips E to the loop bound; the meter then cannot reach the target, so the hold always runs to `EDGE_HOLD_S`. If the bound lifts during the hold, nothing ends it (`moved` stays false). Replayed: B's maintenance charge ends 3 s after the edge, and the site stays at 0 W, importing the 800 W house, for 15 s more. Without the hold the loop moves after 1 s. The same `self.i_w = held_w` (`main.py:2520`) overwrites a SAFETY-93/95/96 reseed (`main.py:5083` and the next two calls) on the next tick. The cost is bounded: at most 18-36 s, the import is below the charge's own, about 13 W on the quarter. Suggested fix: no hold when E was clipped, or end the hold when a `_reseed_*` moves the target.
2. **SAFETY-81 at two inverters.** On e3e4ba6 the edge tick at N>=2 was graded on SAFETY-91's next-quarter projection. With modes on it is now skipped (`main.py:5877`), so the 10 s hold restarts at each edge there too. This changes the alarm cadence only, not control. Not measured, because `safety81_sim` has one inverter. The comment above that line is true at one inverter only.
3. **Action log during F3 probes.** Each probe writes two action-log lines, "ARBITRAGE_CHARGE … 200 W on inverter-a" and "arbitrage idle … no trade (size)" (hint "Trading: waiting for a better price"). That is 8 lines an hour while a refusal stands in a cheap run, in a 40-line log. e3e4ba6 writes none. "BATTERY REFUSES" and "… ENDED" are each written once, so SAFETY-73 and SAFETY-107 are not doubled. It is not a flood, but it is noise.
### My mutations (not in M1-M21; graded over test_price_run_fixes / test_modes_p3 / test_safety81)
| # | mutation | reds |
|---|---|---|
| X1 | tariff: rolled `acc` credits a held export as negative | 0/0/0 at 065792a, **1** with 7f0f00b |
| X3 | main: hold ends on `abs(grid - tg) < db` (no step direction) | 1/0/0 |
| X4 | main: "moving" without "still >= db from E" | 0/0/0 at 065792a, **1** with 7f0f00b |
| X5 | main: "moving" never capped at 2 x EDGE_HOLD_S | 1/0/0 |
| X6 | modetrade: a probing refuser ignores the day plan's level | 1/0/0 |
| X8 | main: the second unmeasured stop no longer latches | 1/0/0 |
| X11 | main: steps under 150 W not held | 1/0/0 |
| X15 | tariff: roll also mid-quarter | 0/2/16 |
### Pushed (7f0f00b)
Two checks in `test_price_run_fixes.py` for X1 and X4. DECISIONS: the slow-battery quarter (3456 W) and the maintenance replay. No code change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
admin
merged commit 79dd0770f9 into release/1.02026-10-13 15:04:12 +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.
Fixes the three P3 findings from the OPT-11 P3 post-merge test on TEST-23 (
5a4cdc2). Rebased by merge onto release/1.0e3e4ba6(release/1.0merged in, head 065792a; no rebase, no force push). Every number below was measured one3e4ba6+ this branch. No TEST-23 use: everything is the real Controller in the replay harness (goodwe-addon-checks:1,--network none).What the merge changed (read this first)
p1.pyheld_tbesideheld_since;modetrade.pychargers(targets, probe)and a drain clearing the probe;main.pythe roll inside SAFETY-90/91's_peak_projchain, the hold beside SAFETY-95's drain-tailgrid_w, P4 state beside the hold state; DOCS and DECISIONS both lines).next_quarter()grades the next quarter past a block's end). With the old projection two inverters no longer stop at an edge, and rolling gives the same banked quarters (test_price_run_fixespart 1b). The roll is still what fixes one inverter, where the old projection stops the charge at every edge. It stays as designed; nothing is wrong or redundant enough to change the approach.tools/safety81_sim.pyshowed at one inverter, so that test's modes-on digests moved (re-pinned, with a note). Modes off is byte-identical toe3e4ba6(new section 1b pins it). The alarm needed one adaptation: a rolled projection is not graded until the quarter's first sample, as the old "no seconds left" tick was not, so a standing overload is still notified once per quarter (M16).test_modes_p4part 1: the six scenarios with a grid charge (_ch) are re-pinned. With the hold and its reseed switched off they equal 5a4cdc2's again (roll and probe do not touch them); the others never moved.test_price_run_fixespart 5: identity pins now e3e4ba6's.n1andn2are still 5a4cdc2's;n1_lag/n2_lagmoved with SAFETY-90/91 (n2_lagnow equalsn2).F1 — charge stopped at a quarter edge and latched (medium)
Cause (reproduced): the P1 averager opens a quarter only when a sample arrives and the HA source builds it 0.35 s after the reading, so the cycle on the boundary tick still saw the old block: "no seconds left",
charge_headroom_w()None, "no trade (headroom)", and the latch (keyed to the wall-clock quarter) held the NEW quarter off.Fix: with modes on, the guard projects the quarter
nowis in (PeakTracker.project(roll=True), crediting the held sample asQuarterAverager.add()will, undermax_hold_s). Second layer: only a measured stop latches; the first unmeasured one in a quarter stops that tick only, the second latches (a headroom missing every other cycle flipped the charge 1799 times in 30 min before this).On
e3e4ba6: one inverter, old projection: a stop at each of the 6 edges crossed (headroom); rolled: none. Two inverters: none either way, banked quarters equal. Charge start still needs more than 500 W headroom; a real mid-quarter guard stop still latches.F2 — charge-end export overshoot (medium)
Fix: after the reseed at E the loop is held there (no error integrated) until the meter reaches the target in the step's direction,
EDGE_HOLD_S(18 s, up to 36 s while the battery reading still moves toward E), a new trade, or another claim moves the command. The replay gainedLag(command dead time, meter and battery reading age, P1 debounce, 1 s cycles).5a4cdc2)5a4cdc2.Can the hold let a rising house push the quarter over the cap? No.
Lagged replay, two inverters, Cheap hours A charges, Solar first B takes over; house rises +1, +2, +3 kW at the charge end, edge placed 30 s to 15 s before the quarter's end. Banked quarter containing the edge (the next quarter is at most 144 W). Aim 3450 W, cap 3500 W.
e3e4ba6e3e4ba6), 44 W under the cap. Why: the import during the hold is the rise, below the charge's own import it replaced; a rise past that is the peak floor's, which ends the hold at once (800 → 4500 W: grid 2000 W ten seconds later). One inverter: the hold is a no-op there (E is 0 W for a Cheap-hours battery), head = no hold, highest 3451.3 W (the known lag overshoot).F3 — refusal inside a cheap run never probed (low)
Probe, as the card says. 200 W for ~34 s every 15 min under the same gates (guard clear, measured headroom > 500 W, not latched, idle, cheap hours), the others rest; an answered probe wins back the run. Outside the probe window the refusal wins. At one inverter SAFETY-52 already ends a charge refusal. On the new base: a monthly drain (SAFETY-78) comes first, and with a day plan (P4) a probing Leftover solar battery goes only to its planned level;
energy()never counts a refuser, so run planning is unchanged.Interaction with what landed since
5a4cdc2next_quarter)_peak_projchain; at two inverters it duplicatesnext_quarter()(equal banked quarters), at one it is what fixes F1. Gap fill runs on the rolled projection (M21)._charge_gate_open,maintenance_charge_floor)_edge_grid(end), unit-tested; not run as a maintenance scenario); SAFETY-95's drain tail keeps its own grid, the hold sits on the other branch (M18).peak_fcand the charge floor read the forecast sensor, not the projection. SAFETY-111 is not ine3e4ba6(no code, DECISIONS or PR), so not checked.charge_to) now apply to a probing refuser;energy()and run sizing unchanged. The forecast lists feeddayplanonly.test_modes_p4modes-on_chpins re-pinned (hold only).inv.probe_from(N>1).Tests
test_price_run_fixes.py(parts 1–5 as before, plus: F1 at one inverter and the two-inverter equivalence, drain vs probe, the day plan vs probe),test_modes_p3.py,test_safety81.py(+ modes-off pins),test_modes_p4.py(re-pins). Full suite ingoodwe-addon-checks:1(--network none,PYTHONDONTWRITEBYTECODE=1): 74/74 pass on the merged head.Identity,
tools/opt11p3_identity.py,e3e4ba6and the merged head, equal in all six: n1 71245b22…, n2 56cf2801…, n1_off 71245b22…, n2_off 56cf2801…, n1_lag 86ef0f5a…, n2_lag 56cf2801…. SAFETY-81 sim modes off: const4500, edge4600, step6500, burst_end, twice7000, sine4500_fast equal.Mutations (fresh copies, graded over test_price_run_fixes / test_modes_p3 unless noted; reds)
roll=Falsemax_hold_sprobe=Falseroll=True(leaks into modes off)test_safety81)self.gridon its non-tail branch, bypassing the holdtargetsdropped from the decide() chargers call (+test_modes_p4)moved = Falseremoved)test_safety90)Not fixed (same on
e3e4ba6), for the boardDOCS (Battery modes, three lines) and DECISIONS/OPT-11-battery-modes.md (OPT-15 item) updated; no new user-facing text.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
e3e4ba6) into opt15-price-run-fixesReview of
065792a(+7f0f00b, tests and DECISIONS only)Verdict: correct. No blocker. Three low findings for the board (no code change in this PR). I merge once
checks / unitis green on7f0f00b.What I re-ran (goodwe-addon-checks:1,
--network none,PYTHONDONTWRITEBYTECODE=1)065792a, hwsim required: 74/74 pass.tools/opt11p3_identity.py),e3e4ba6vs head: all six equal (n1 71245b22…, n2 56cf2801…, n1_off, n2_off, n1_lag 86ef0f5a…, n2_lag 56cf2801…).e3e4ba6(the test pins 6). Modes on: alarm times as pinned (1813 -> 1811 at the quarter edge, ramp6500 1316 -> 1317).test_modes_p4_chre-pins: with the old reseed and no hold, the six digests are exactly 5a4cdc2's old pins again. The re-pin is the hold, nothing else.Maintenance during a hold (replayed, real Controller, lagged plant, edge + 3 s)
B drain, B hold, A drain, B maintenance charge, and a drain on the edge tick itself: the hold ends on the tick the maintenance claim appears in every case. With B hold or B charge, the head is smoother than without the hold (no 1600 W import bump).
Findings (low, follow-up cards, not blockers)
main.py:2492clips E to the loop bound; the meter then cannot reach the target, so the hold always runs toEDGE_HOLD_S. If the bound lifts during the hold, nothing ends it (movedstays false). Replayed: B's maintenance charge ends 3 s after the edge, and the site stays at 0 W, importing the 800 W house, for 15 s more. Without the hold the loop moves after 1 s. The sameself.i_w = held_w(main.py:2520) overwrites a SAFETY-93/95/96 reseed (main.py:5083and the next two calls) on the next tick. The cost is bounded: at most 18-36 s, the import is below the charge's own, about 13 W on the quarter. Suggested fix: no hold when E was clipped, or end the hold when a_reseed_*moves the target.e3e4ba6the edge tick at N>=2 was graded on SAFETY-91's next-quarter projection. With modes on it is now skipped (main.py:5877), so the 10 s hold restarts at each edge there too. This changes the alarm cadence only, not control. Not measured, becausesafety81_simhas one inverter. The comment above that line is true at one inverter only.e3e4ba6writes none. "BATTERY REFUSES" and "… ENDED" are each written once, so SAFETY-73 and SAFETY-107 are not doubled. It is not a flood, but it is noise.My mutations (not in M1-M21; graded over test_price_run_fixes / test_modes_p3 / test_safety81)
acccredits a held export as negative065792a, 1 with7f0f00babs(grid - tg) < db(no step direction)065792a, 1 with7f0f00bPushed (
7f0f00b)Two checks in
test_price_run_fixes.pyfor X1 and X4. DECISIONS: the slow-battery quarter (3456 W) and the maintenance replay. No code change.🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC