SAFETY-91: a held battery is drawn only after the others are full #165

Merged
admin merged 2 commits from safety-91-held-battery-last into release/1.0 2026-10-02 15:53:34 +02:00
Owner

SAFETY-91 (SAFETY-87 rig test F1, F2). DECISIONS: goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md. Tests: test_safety91.py.

Design (N>=2 only)

  • _held_back(): a battery at or under its own peak reserve, while another can still give (live, not refusing), weighs 0 in every split where the site gives. Before, this applied only on cycles where the peak guard asked for power.
  • _drain_rise(): while a battery is held back, a draining battery's maintenance pin becomes a floor at its drain rate. It rises towards its rating before the held battery gives. A site stop, a held battery's own drain and a battery that is not live are left alone.
  • tariff.next_quarter(): past a block's end, before the next block's first telegram, the guard grades the next quarter from the held reading. It is not used when that reading is too old (max_hold_s). This removes the one cycle where a HOLD's ceiling lapsed onto the held battery (F2).
  • SAFETY-25 option A is kept: once B is full, the loop still draws the held battery (the last-resort pass). Nothing is placed on a battery at its cut-out: OPT-09's pack limit already closes it, and B now takes the rest.

Before -> after (test_safety91.py, 2 x 2500 W, battery with a 10 s lag, 1 s tick)

case before after
B drain 1000, A held 15 %, house 4000 A 2500, B 1000 A 1500, B 2500
same, house 5500 A 2500, B 1000 A 2500, B 2500 (option A)
cycles where A was asked while B was not full 612 of 900 0
A at its cut-out, house 5500: B 1572 W mean 2500 W every cycle, A 0 W
HOLD, telegram 0.5 s late at the edge A 0 / B 2470 for 1 cycle A 2050 / B 0
idle, A held, house 2000 A 1005 / B 1005 A 0 / B 2020
N=1 HOLD edge and drain runs, late telegram rows identical with and without the fix

Re-pinned or changed tests (each one is an intended behaviour change)

  • test_modes.py n2 / n2_off: from t=1600 battery A is at its 20 % reserve, so B carries the house and A gives last (100 of 540 cycles differ). The test now also checks that with SAFETY-87's held rule these runs give back a5dddad's digest. n2_maint, n1* and resolve are unchanged.
  • test_safety87.py part 10 "without the raise": this run also turns off _drain_rise(). Otherwise SAFETY-91 raises B anyway and the non-vacuous check has nothing to see.
  • test_peakguard.py 10g and test_watchdog.py's two-inverter rig: the second battery now gets a cut-out, as rule 8 requires. A battery with an unknown cut-out counts as held (SAFETY-87), so A alone would carry the cycle. In 10g the broadcast check could then not see a split, and in the watchdog rig B would get no share to reverse.
  • No N=1 digest moved.

Mutations (each on a fresh copy of the tree, run in the CI image)

# mutation test_safety91.py
M1 _held_back = SAFETY-87's rule (held only for the peak) KILLED (6 checks; test_modes also)
M2 _drain_rise does nothing KILLED (5)
M3 edge roll call removed KILLED (HOLD edge)
M4 edge roll at N=1 too KILLED (N=1 HOLD edge identity)
M5 next_quarter has no staleness test KILLED
M6 next_quarter projects the old block's number KILLED
M7 next_quarter leaves block_start where it was KILLED
M8 _drain_rise ignores a site stop KILLED
M9 a held battery's own drain also rises KILLED
M10 a draining battery that is not live is also changed KILLED
M11 the rise floor is 0 W, not the drain rate KILLED (N=3)
M12 _held_back drops the "another can give" test KILLED
M13 a refusing battery counts as able KILLED
M14 a dropped battery counts as able KILLED
M15 resolve() still gets SAFETY-87's reserve KILLED (test_modes also)

Full suite (59 files) passes locally in goodwe-addon-checks:1.

Open for the reviewer or owner

  • Option A in numbers: with the house over both ratings (5500 W against 2 x 2500 W), a held battery still gives its full 2500 W once B is full. In the rig, at that load, A ran 15 -> ~10.7 % in one quarter. That is the decided behaviour; this fix only changes the order.
  • With nobody held, a drain stays a pin (SAFETY-78). B draining at 1000 W beside an able A that is already full still imports the rest. This is not changed here.
  • The first cycle after a start, before any quarter has been measured, can still put the loop's first step (400 W) on a held battery during a HOLD. This happens in the same way before and after this fix.
  • N=1 keeps its one-cycle dip at a quarter's edge (SAFETY-86 rig). The roll is a one-line extension, but this ticket keeps N=1 byte-identical.
  • No user-facing strings changed. DOCS has three short lines.
  • Effect on other tickets: SAFETY-93 is not touched. _drain_over_reserve() (SAFETY-87) is now mostly overtaken at N=2, because B is free to rise anyway. It still sets B's floor at N=3.

🤖 Generated with Claude Code

https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr

SAFETY-91 (SAFETY-87 rig test F1, F2). DECISIONS: `goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md`. Tests: `test_safety91.py`. ## Design (N>=2 only) - `_held_back()`: a battery at or under its own peak reserve, while another can still give (live, not refusing), weighs 0 in **every** split where the site gives. Before, this applied only on cycles where the peak guard asked for power. - `_drain_rise()`: while a battery is held back, a draining battery's maintenance pin becomes a floor at its drain rate. It rises towards its rating before the held battery gives. A site stop, a held battery's own drain and a battery that is not live are left alone. - `tariff.next_quarter()`: past a block's end, before the next block's first telegram, the guard grades the next quarter from the held reading. It is not used when that reading is too old (`max_hold_s`). This removes the one cycle where a HOLD's ceiling lapsed onto the held battery (F2). - SAFETY-25 option A is kept: once B is full, the loop still draws the held battery (the last-resort pass). Nothing is placed on a battery at its cut-out: OPT-09's pack limit already closes it, and B now takes the rest. ## Before -> after (test_safety91.py, 2 x 2500 W, battery with a 10 s lag, 1 s tick) | case | before | after | |---|---|---| | B drain 1000, A held 15 %, house 4000 | A 2500, B 1000 | A 1500, B 2500 | | same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) | | cycles where A was asked while B was not full | 612 of 900 | 0 | | A at its cut-out, house 5500: B | 1572 W mean | 2500 W every cycle, A 0 W | | HOLD, telegram 0.5 s late at the edge | A 0 / B 2470 for 1 cycle | A 2050 / B 0 | | idle, A held, house 2000 | A 1005 / B 1005 | A 0 / B 2020 | | N=1 HOLD edge and drain runs, late telegram | | rows identical with and without the fix | ## Re-pinned or changed tests (each one is an intended behaviour change) - `test_modes.py` `n2` / `n2_off`: from t=1600 battery A is at its 20 % reserve, so B carries the house and A gives last (100 of 540 cycles differ). The test now also checks that with SAFETY-87's held rule these runs give back a5dddad's digest. `n2_maint`, `n1*` and `resolve` are unchanged. - `test_safety87.py` part 10 "without the raise": this run also turns off `_drain_rise()`. Otherwise SAFETY-91 raises B anyway and the non-vacuous check has nothing to see. - `test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig: the second battery now gets a cut-out, as rule 8 requires. A battery with an unknown cut-out counts as held (SAFETY-87), so A alone would carry the cycle. In 10g the broadcast check could then not see a split, and in the watchdog rig B would get no share to reverse. - No N=1 digest moved. ## Mutations (each on a fresh copy of the tree, run in the CI image) | # | mutation | test_safety91.py | |---|---|---| | M1 | `_held_back` = SAFETY-87's rule (held only for the peak) | KILLED (6 checks; test_modes also) | | M2 | `_drain_rise` does nothing | KILLED (5) | | M3 | edge roll call removed | KILLED (HOLD edge) | | M4 | edge roll at N=1 too | KILLED (N=1 HOLD edge identity) | | M5 | `next_quarter` has no staleness test | KILLED | | M6 | `next_quarter` projects the old block's number | KILLED | | M7 | `next_quarter` leaves `block_start` where it was | KILLED | | M8 | `_drain_rise` ignores a site stop | KILLED | | M9 | a held battery's own drain also rises | KILLED | | M10 | a draining battery that is not live is also changed | KILLED | | M11 | the rise floor is 0 W, not the drain rate | KILLED (N=3) | | M12 | `_held_back` drops the "another can give" test | KILLED | | M13 | a refusing battery counts as able | KILLED | | M14 | a dropped battery counts as able | KILLED | | M15 | `resolve()` still gets SAFETY-87's reserve | KILLED (test_modes also) | Full suite (59 files) passes locally in `goodwe-addon-checks:1`. ## Open for the reviewer or owner - Option A in numbers: with the house over both ratings (5500 W against 2 x 2500 W), a held battery still gives its full 2500 W once B is full. In the rig, at that load, A ran 15 -> ~10.7 % in one quarter. That is the decided behaviour; this fix only changes the order. - With nobody held, a drain stays a pin (SAFETY-78). B draining at 1000 W beside an able A that is already full still imports the rest. This is not changed here. - The first cycle after a start, before any quarter has been measured, can still put the loop's first step (400 W) on a held battery during a HOLD. This happens in the same way before and after this fix. - N=1 keeps its one-cycle dip at a quarter's edge (SAFETY-86 rig). The roll is a one-line extension, but this ticket keeps N=1 byte-identical. - No user-facing strings changed. DOCS has three short lines. - Effect on other tickets: SAFETY-93 is not touched. `_drain_over_reserve()` (SAFETY-87) is now mostly overtaken at N=2, because B is free to rise anyway. It still sets B's floor at N=3. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
admin added 1 commit 2026-10-02 14:04:19 +02:00
SAFETY-91: a held battery is drawn only after the others are full
checks / unit (pull_request) Successful in 26m19s
infra-pin / fresh (pull_request) Successful in 1s
6872353e2e
At N>=2 a battery at or under its own peak reserve now weighs 0 whenever
the site gives (not only for the peak), a draining battery's pin becomes
a floor at its drain rate so it rises before the held one gives, and a
quarter's edge is graded on the next quarter when its first telegram is
late. SAFETY-25 option A stays: the held battery is still drawn once the
others are full. N=1 unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

SAFETY-91 review, round 1

Base not moved (release/1.0 still 8c438ab). Design read against the owner rules; probes and mutants were run in goodwe-addon-checks:1 on the 10 s lag plant (scratch SAFETY-91/review/).

Findings

R1-1 (fix needed): the split ping-pongs when A's SoC reading flips across its reserve. _held_back() has no hysteresis. Before this PR, the reserve only changed the split while the peak guard asked for power. Now it changes every self-consumption cycle. If A's integer SoC alternates 20/21 (reserve 20), the split swaps about 1000 W between A and B on every flip. Idle, house 2000, B 60 %:

A SoC flips every lags after: A cmd jumps >300 W (480 s) grid rms grid swing
10 s both 10 s 47 10 W small
10 s A 10 s, B 3 s 47 319 W +499 / -516 W
30 s A 10 s, B 3 s 15 121 W +287 / -299 W
(before the PR) A 10 s, B 3 s 0 10 W 0

Inverter B is a different unit, so equal lags cannot be assumed. A single real crossing is clean: A drawn from 21.4 % reaches 20 % and stops, with one jump (before the PR it ran on to 10.5 %). Fix (I push it): a battery that _held_back() returned stays held back until its SoC reads more than 1 point above its own reserve. This touches only the split order. The guard's own permission (reserve_split, SAFETY-87) is unchanged.

R1-2 (test gap): the staleness boundary of next_quarter() is not graded. Mutant "staleness allowance +30 s" survives: the only stale case is 80 s uncovered against 30 s. I add 29.5 / 30.5 s cases.

Confirmed / refuted

  • Held last at N≥2 in normal, drain, HOLD and peak. "Another can give": refusing (discharge side only), dropped and under-reserve are excluded, and a charge refusal still counts as able (direct checks, mutants R3/R13 killed). Off beside held: A held, B Off, house 2000: A 2010 / B 0, the same as before. With the peak at 5500: A 2500 / B 0, the same as before (SAFETY-87 Open, out of scope). Not a regression.
  • No opposite-direction split: the rise is a lo=p>0 LIMIT, and the SAFETY-78 drain floor (lo = original pin) still stands, so the site target is ≥ p. Low house (300 W): B 1000 W, export unchanged. Site stop: the pin stays (M8 killed).
  • No double raise with _drain_over_reserve(): that runs first and moves the SET value. _drain_rise() then only turns that value into a floor. No later consumer reads the maintenance pin (pinned_by users are all upstream). SAFETY-84's charge ceiling and SAFETY-86's hold raise run on _peak_reserve() and are not changed. SAFETY-85: B is now loop-shared, so it is graded as the loop's battery, which is correct.
  • Edge projection: it is pure, so no banked value is touched (PeakTracker is not involved). If the telegram never comes, the guard is graded on the held reading only for max_hold_s (the same credit the averager gives inside a block). After that the old block is blind, as before. SAFETY-90 is not worsened: the roll applies only before the next block opens. The SAFETY-81 alarm block key moves to the new quarter, which is consistent.
  • Behaviour change "A 0 / B 2020" at idle is intended (owner 2026-10-01). It cannot starve B: once B reaches its own reserve, nobody is able and the split is normal. The ping-pong is R1-1.
  • Re-pins: n2/n2_off are reproduced exactly by reverting _held_back alone (graded in the test). The 87 part-10 and fixture cut-outs (rule 8) changed for the stated reasons.
  • Docs: sized fine, history is in DECISIONS, and there is no version/CHANGELOG change.

Mutants re-run (test_safety91 / 87 / modes)

M2, M3, M4, M6, M8, M12, M13 killed (M12/M13 only by the direct _held_back check). Own: R1 rise with nothing held: killed. R3 charge-refusal counts as unable: killed. R4 pin raised to the rating: killed. R8 roll 2 s early: killed. R13 under-reserve counts as able: killed. R6 staleness +30 s: survived (R1-2).

## SAFETY-91 review, round 1 Base not moved (release/1.0 still 8c438ab). Design read against the owner rules; probes and mutants were run in `goodwe-addon-checks:1` on the 10 s lag plant (scratch `SAFETY-91/review/`). ### Findings **R1-1 (fix needed): the split ping-pongs when A's SoC reading flips across its reserve.** `_held_back()` has no hysteresis. Before this PR, the reserve only changed the split while the peak guard asked for power. Now it changes every self-consumption cycle. If A's integer SoC alternates 20/21 (reserve 20), the split swaps about 1000 W between A and B on every flip. Idle, house 2000, B 60 %: | A SoC flips every | lags | after: A cmd jumps >300 W (480 s) | grid rms | grid swing | |---|---|---|---|---| | 10 s | both 10 s | 47 | 10 W | small | | 10 s | A 10 s, B 3 s | 47 | 319 W | +499 / -516 W | | 30 s | A 10 s, B 3 s | 15 | 121 W | +287 / -299 W | | (before the PR) | A 10 s, B 3 s | 0 | 10 W | 0 | Inverter B is a different unit, so equal lags cannot be assumed. A single real crossing is clean: A drawn from 21.4 % reaches 20 % and stops, with one jump (before the PR it ran on to 10.5 %). Fix (I push it): a battery that `_held_back()` returned stays held back until its SoC reads more than 1 point above its own reserve. This touches only the split order. The guard's own permission (`reserve_split`, SAFETY-87) is unchanged. **R1-2 (test gap): the staleness boundary of `next_quarter()` is not graded.** Mutant "staleness allowance +30 s" survives: the only stale case is 80 s uncovered against 30 s. I add 29.5 / 30.5 s cases. ### Confirmed / refuted - Held last at N≥2 in normal, drain, HOLD and peak. "Another can give": refusing (discharge side only), dropped and under-reserve are excluded, and a charge refusal still counts as able (direct checks, mutants R3/R13 killed). **Off beside held:** A held, B Off, house 2000: A 2010 / B 0, the same as before. With the peak at 5500: A 2500 / B 0, the same as before (SAFETY-87 Open, out of scope). Not a regression. - No opposite-direction split: the rise is a `lo=p>0` LIMIT, and the SAFETY-78 drain floor (lo = original pin) still stands, so the site target is ≥ p. Low house (300 W): B 1000 W, export unchanged. Site stop: the pin stays (M8 killed). - No double raise with `_drain_over_reserve()`: that runs first and moves the SET value. `_drain_rise()` then only turns that value into a floor. No later consumer reads the maintenance pin (`pinned_by` users are all upstream). SAFETY-84's charge ceiling and SAFETY-86's hold raise run on `_peak_reserve()` and are not changed. SAFETY-85: B is now loop-shared, so it is graded as the loop's battery, which is correct. - Edge projection: it is pure, so no banked value is touched (PeakTracker is not involved). If the telegram never comes, the guard is graded on the held reading only for `max_hold_s` (the same credit the averager gives inside a block). After that the old block is blind, as before. SAFETY-90 is not worsened: the roll applies only before the next block opens. The SAFETY-81 alarm block key moves to the new quarter, which is consistent. - Behaviour change "A 0 / B 2020" at idle is intended (owner 2026-10-01). It cannot starve B: once B reaches its own reserve, nobody is able and the split is normal. The ping-pong is R1-1. - Re-pins: n2/n2_off are reproduced exactly by reverting `_held_back` alone (graded in the test). The 87 part-10 and fixture cut-outs (rule 8) changed for the stated reasons. - Docs: sized fine, history is in DECISIONS, and there is no version/CHANGELOG change. ### Mutants re-run (test_safety91 / 87 / modes) M2, M3, M4, M6, M8, M12, M13 killed (M12/M13 only by the direct `_held_back` check). Own: R1 rise with nothing held: killed. R3 charge-refusal counts as unable: killed. R4 pin raised to the rating: killed. R8 roll 2 s early: killed. R13 under-reserve counts as able: killed. **R6 staleness +30 s: survived** (R1-2).
admin added 1 commit 2026-10-02 15:09:30 +02:00
SAFETY-91 review: a held-back battery is released 1 point above its reserve
checks / unit (pull_request) Successful in 26m26s
infra-pin / fresh (pull_request) Successful in 1s
4cd604afcb
A SoC reading flipping across the reserve swapped ~1000 W between the
batteries on each flip (grid +-500 W with unequal lags). Also grades
next_quarter()'s staleness boundary (29.5 / 30.5 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

SAFETY-91 review, round 2: fixes pushed (4cd604a)

  • R1-1 fixed. _held_back(): a battery it returned stays held back until its SoC reads more than 1 point above its own reserve (_held_was). It is idempotent within a cycle, and the memory clears when nobody is held back. Only the split order changes. reserve_split (SAFETY-87, the guard's permission) is untouched. DECISIONS has 4 lines on it. New checks in test_safety91.py part 4, on the lag plant with A 10 s / B 3 s: A's reading flips 20/21 % every 10 s. Result: 0 A jumps and grid rms 10 W; without the hysteresis, 47 jumps and 319 W. Held at 20, still held at 21, released at 22. A battery kept by the hysteresis does not count as "can give".
  • R1-2 fixed. next_quarter() staleness is now graded at 29.5 s (rolled) and 30.5 s (unchanged).
  • Mutants on the new tree: H1 band 0 killed, H2 band 2 killed, H3 sticky battery counts as able killed, R6 staleness +30 s now killed.
  • Full suite 59/59 in goodwe-addon-checks:1 with the pinned admin/goodwe hwsim (as CI). The n2/n2_off digests are unchanged by the fix.

No open points left from my side. Merging when checks / unit (pull_request) is green on 4cd604a.

## SAFETY-91 review, round 2: fixes pushed (4cd604a) - **R1-1 fixed.** `_held_back()`: a battery it returned stays held back until its SoC reads more than 1 point above its own reserve (`_held_was`). It is idempotent within a cycle, and the memory clears when nobody is held back. Only the split order changes. `reserve_split` (SAFETY-87, the guard's permission) is untouched. DECISIONS has 4 lines on it. New checks in `test_safety91.py` part 4, on the lag plant with A 10 s / B 3 s: A's reading flips 20/21 % every 10 s. Result: 0 A jumps and grid rms 10 W; without the hysteresis, 47 jumps and 319 W. Held at 20, still held at 21, released at 22. A battery kept by the hysteresis does not count as "can give". - **R1-2 fixed.** `next_quarter()` staleness is now graded at 29.5 s (rolled) and 30.5 s (unchanged). - Mutants on the new tree: H1 band 0 killed, H2 band 2 killed, H3 sticky battery counts as able killed, R6 staleness +30 s **now killed**. - Full suite 59/59 in `goodwe-addon-checks:1` with the pinned `admin/goodwe` hwsim (as CI). The n2/n2_off digests are unchanged by the fix. No open points left from my side. Merging when `checks / unit (pull_request)` is green on 4cd604a.
admin merged commit f3e409cf76 into release/1.0 2026-10-02 15:53:34 +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#165