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.pyn2 / 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.
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
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>
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).
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>
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.02026-10-02 15:53:34 +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.
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).Before -> after (test_safety91.py, 2 x 2500 W, battery with a 10 s lag, 1 s tick)
Re-pinned or changed tests (each one is an intended behaviour change)
test_modes.pyn2/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*andresolveare unchanged.test_safety87.pypart 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.py10g andtest_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.Mutations (each on a fresh copy of the tree, run in the CI image)
_held_back= SAFETY-87's rule (held only for the peak)_drain_risedoes nothingnext_quarterhas no staleness testnext_quarterprojects the old block's numbernext_quarterleavesblock_startwhere it was_drain_riseignores a site stop_held_backdrops the "another can give" testresolve()still gets SAFETY-87's reserveFull suite (59 files) passes locally in
goodwe-addon-checks:1.Open for the reviewer or owner
_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 review, round 1
Base not moved (release/1.0 still
8c438ab). Design read against the owner rules; probes and mutants were run ingoodwe-addon-checks:1on the 10 s lag plant (scratchSAFETY-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 %: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
lo=p>0LIMIT, 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)._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_byusers 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.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._held_backalone (graded in the test). The 87 part-10 and fixture cut-outs (rule 8) changed for the stated reasons.Mutants re-run (test_safety91 / 87 / modes)
M2, M3, M4, M6, M8, M12, M13 killed (M12/M13 only by the direct
_held_backcheck). 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 2: fixes pushed (
4cd604a)_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 intest_safety91.pypart 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".next_quarter()staleness is now graded at 29.5 s (rolled) and 30.5 s (unchanged).goodwe-addon-checks:1with the pinnedadmin/goodwehwsim (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 on4cd604a.