SAFETY-103 (from the SAFETY-95 rig test, check 5, F1).
The fault
While a battery settles from its ended drain, SAFETY-95 steers on grid + every battery's reading. A register can be 10 s older than the meter. On the rig, A's reading still said 1238 W while A was at 616 W, and B swung 860 -> 1650 -> 870 W.
The rule (N>=2, only while a drain's tail settles)
Per battery, the range its power can be in: its last changed reading, widened by every command on its wire since. A new reading starts the range again.
A reading that changed this cycle is taken as it reads; so is the side a battery refuses (SAFETY-73).
The house point is held (from the loop's start) and moved only as far as the meter proves: to the nearest value of grid + the ranges - aim.
Why not the tester's rule (count a battery at its command while its reading is older than that command): B's command changes almost every cycle, so that is the plain meter, which chases A's tail. Measured: 1420 W swing, release/1.0 760 W. A lag model needs the plant's time constant; with it 2x off the swing is up to 600 W. Holding the whole window costs house steps (kettle 14.4 Wh vs 11.5). Numbers: DECISIONS/SAFETY-103-old-readings-after-a-drain.md.
Results (test_safety103.py, plant tools/safety103_plant.py: 10 s lag, 1 s meter, 10 s registers, B idles at -100 W)
case
release/1.0
this PR
rig check 5: N=2, 1200 W, A Off
B 1140 -> 850 -> 1630 -> 870 (swing 760 W)
B holds 1140 W
N=3, 1200 / 4000 W
680 / 630 W
0 / 0 W
surplus, N=2 / N=3
890 / 740 W
130 / 130 W
lag 5/20 s, registers 5/10 s
at most 10 W
kettle / sun step inside the window
11.6 / 8.5 Wh
11.4 / 9.1 Wh (followed)
1 s registers; refusal end; N=1
rows byte-identical
N=1: also byte-identical against the real release/1.0 tree (24 plant runs + tools/opt11_identity.py, n2_maint digest unchanged).
Not covered
The swing after a lost link or a refusal (under a surplus up to 1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW even without one) is the loop's own on a lagging plant: the same with 1 s registers and on release/1.0 (review: rows byte-identical with the release/1.0 tree).
Mutations
Purged copies (tools/mutation_run.sh), control green. Stage 1 test_safety103.py; a survivor also gets test_safety95/96/93 and test_modes.
#
mutant
killed by
M1
no hold (release/1.0's point estimate)
rig case + 20 checks
M2
pure hold, never moved by the meter
"moved as far as the meter proves", house-step checks
M3
a fresh reading not taken as it reads
1 s registers byte-identical
M4
commands since the reading do not widen the range
range checks
M5
a new reading does not restart the range
"a new reading starts the range again"
M6
hold not let go at the tail's end
"the tail's end lets the hold go"
M7
first window cycle from the meter's midpoint
"held: the first window cycle"
M8
refusal side not taken at the reading
refusal check + refusal rows byte-identical
M9
refusal sides swapped
refusal check
M10
tracking also at N=1
"N=1: nothing is tracked"
M11
tracking not called in the cycle
rig case + 14 checks
M12
range from an older reading used
"a range from an older reading ... is not used"
M13
a battery out of the split counted
survived round 1; new check "a battery out of the split is not counted" kills it
M14
tracking uses the loop's share, not the command on the wire
range checks
All 14 killed. Local full suite 67/67 (goodwe-addon-checks:1).
No user-facing strings change. DOCS gets one sentence. CHANGELOG is left for the release step.
SAFETY-103 (from the SAFETY-95 rig test, check 5, F1).
## The fault
While a battery settles from its ended drain, SAFETY-95 steers on grid + every battery's reading. A register can be 10 s older than the meter. On the rig, A's reading still said 1238 W while A was at 616 W, and B swung 860 -> 1650 -> 870 W.
## The rule (N>=2, only while a drain's tail settles)
- Per battery, the range its power can be in: its last changed reading, widened by every command on its wire since. A new reading starts the range again.
- A reading that changed this cycle is taken as it reads; so is the side a battery refuses (SAFETY-73).
- The house point is held (from the loop's start) and moved only as far as the meter proves: to the nearest value of grid + the ranges - aim.
Why not the tester's rule (count a battery at its command while its reading is older than that command): B's command changes almost every cycle, so that is the plain meter, which chases A's tail. Measured: 1420 W swing, release/1.0 760 W. A lag model needs the plant's time constant; with it 2x off the swing is up to 600 W. Holding the whole window costs house steps (kettle 14.4 Wh vs 11.5). Numbers: `DECISIONS/SAFETY-103-old-readings-after-a-drain.md`.
## Results (`test_safety103.py`, plant `tools/safety103_plant.py`: 10 s lag, 1 s meter, 10 s registers, B idles at -100 W)
| case | release/1.0 | this PR |
|---|---|---|
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870 (swing 760 W) | B holds 1140 W |
| N=3, 1200 / 4000 W | 680 / 630 W | 0 / 0 W |
| surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
| lag 5/20 s, registers 5/10 s | | at most 10 W |
| kettle / sun step inside the window | 11.6 / 8.5 Wh | 11.4 / 9.1 Wh (followed) |
| 1 s registers; refusal end; N=1 | | rows byte-identical |
N=1: also byte-identical against the real release/1.0 tree (24 plant runs + `tools/opt11_identity.py`, n2_maint digest unchanged).
## Not covered
The swing after a lost link or a refusal (under a surplus up to 1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW even without one) is the loop's own on a lagging plant: the same with 1 s registers and on release/1.0 (review: rows byte-identical with the release/1.0 tree).
## Mutations
Purged copies (`tools/mutation_run.sh`), control green. Stage 1 test_safety103.py; a survivor also gets test_safety95/96/93 and test_modes.
| # | mutant | killed by |
|---|---|---|
| M1 | no hold (release/1.0's point estimate) | rig case + 20 checks |
| M2 | pure hold, never moved by the meter | "moved as far as the meter proves", house-step checks |
| M3 | a fresh reading not taken as it reads | 1 s registers byte-identical |
| M4 | commands since the reading do not widen the range | range checks |
| M5 | a new reading does not restart the range | "a new reading starts the range again" |
| M6 | hold not let go at the tail's end | "the tail's end lets the hold go" |
| M7 | first window cycle from the meter's midpoint | "held: the first window cycle" |
| M8 | refusal side not taken at the reading | refusal check + refusal rows byte-identical |
| M9 | refusal sides swapped | refusal check |
| M10 | tracking also at N=1 | "N=1: nothing is tracked" |
| M11 | tracking not called in the cycle | rig case + 14 checks |
| M12 | range from an older reading used | "a range from an older reading ... is not used" |
| M13 | a battery out of the split counted | survived round 1; new check "a battery out of the split is not counted" kills it |
| M14 | tracking uses the loop's share, not the command on the wire | range checks |
All 14 killed. Local full suite 67/67 (goodwe-addon-checks:1).
No user-facing strings change. DOCS gets one sentence. CHANGELOG is left for the release step.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
While a battery settles from its ended drain (SAFETY-95), the house point
was grid + every battery's reading. A register can be 10 s older than the
meter, so B swung 860 -> 1650 -> 870 W on A's old reading (rig check 5).
Now the house point is held and moved only as far as the meter proves:
each battery is between its last changed reading and its commands since
(a reading that changed this cycle, or a refused side, as it reads).
N=1 and 1 s registers are byte-identical to release/1.0.
DECISIONS/SAFETY-103-old-readings-after-a-drain.md, test_safety103.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mutation M13 (a battery out of the split counted in the house point)
survived; a direct check now kills it. win[0] is read through a guard
(test_named_absence).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test table gives 1.5 kW at N=2 and 3.9 kW summed at N=3 under a
surplus, and about 1 kW after a lost link without one; not 1.9 kW.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Code. The rule does what the DECISIONS entry says. Hold starts at the loop's start, clamped every cycle into grid + Σ ranges − aim; reset only when the tail empties (the only place _drain_tail shrinks). The peak guard is a separate P_PEAK floor and does not read the loop's grid, so it still wins.
Checked on tools/safety103_plant.py, head against release/1.0's rule (probes in scratch/SAFETY-103/review/probes/):
Meter gap in the window (E+3..6 and E+3..20, with or without a ±2 kW house step during the gap), N=2/3: no swing, or the same as release/1.0. Energy off aim is within 0.2 Wh. A long gap goes to 0 W on both.
House step inside the window (+800/−800 at E+2, +1500 at E+8, +600 at E+12): followed once a reading or the meter allows it. Worst case is +1.4 Wh export (N=3, −800 W), with no swing. That matches the "Cost" paragraph.
B refuses or loses its link at E+5: grid and energy are the same as release/1.0, with no swing (release/1.0 swings up to 680 W).
Second drain end in the window (N=3, C drains from E and is switched Off at E+8 or E+15; 1200, 4000 and −1500 W): head swings 0–130 W against 290–950 W on release/1.0. The hold carries over correctly.
Peak guard with P1 at 10 s registers (4000 and 3200 W, plus a +2800 W step at E+4; N=2/3): the site stays at or above the floor. At N=2 the rows match release/1.0. At N=3 import is lower (0.21 against 1.01 Wh).
Byte-identity against the real release/1.0 tree (git archive): N=1 for every end at 1 s and 10 s registers, N=2/3 with no drain (moving house, B dead), and opt11_identity. All identical.
Plant vs the rig's plant96.py: the same ramp (80 W per 0.1 s), lag, house noise, register phases (A 0.6, B 0.16) and B offset −100. The differences are minor: in-process the registers publish on a 0.1 s grid instead of the rig's 1 s tick, there is no ±1 W reading noise, no SoC limit and no board watchdog, and the meter has 0 s age. OK for this card.
Rejected alternatives: the 1420/1020/1600 W (tester's rule), 70/140/80/580 W (lag model, tau 10 s) and 600 W (tau 5 s on a 20 s plant) figures all match the dev's sweep output.
Findings
Fixed in c6f23a6. DECISIONS "Not covered" (and the PR body) said the swing after a refusal or lost link is "up to 1.9 kW at N=3 under a surplus". The test table says 1.5 kW at N=2 and 3.1 kW (refusal) or 3.9 kW (lost link) at N=3, and about 1 kW after a lost link with no surplus at all (1.5 kW at N=3, 4000 W).
Open question 1 is confirmed pre-existing and not made worse. Rows for refusal and lost-link ends are byte-identical against the real release/1.0 tree at N=2 and N=3 for 1200, 4000 and −1500 W. It needs its own card, and it is not only under a surplus.
Not this card, pre-existing. With 10 s registers the saturation freeze (SAFETY-96) keeps the loop still for about 8–10 s after a +2 kW house step inside the window, on head and on release/1.0 alike. Example: B's reading is −100 W while it is commanded 1140 W. So part of the first seconds of "hold" is the freeze, not this rule.
Considered and refuted: a stale _drain_house leaking into a later window. It needs a window cut short by a loop-not-running gap and a new drain end on the first cycle back. Even then the hold is clamped into the meter's range, and that range is narrow after a gap of 23 s or more (readings caught up, commands steady). No change.
Mutation results follow in the next comment.
## Review (SAFETY-103), round 1
**Code.** The rule does what the DECISIONS entry says. Hold starts at the loop's start, clamped every cycle into grid + Σ ranges − aim; reset only when the tail empties (the only place `_drain_tail` shrinks). The peak guard is a separate P_PEAK floor and does not read the loop's grid, so it still wins.
**Checked on `tools/safety103_plant.py`, head against release/1.0's rule** (probes in `scratch/SAFETY-103/review/probes/`):
- **Meter gap in the window** (E+3..6 and E+3..20, with or without a ±2 kW house step during the gap), N=2/3: no swing, or the same as release/1.0. Energy off aim is within 0.2 Wh. A long gap goes to 0 W on both.
- **House step inside the window** (+800/−800 at E+2, +1500 at E+8, +600 at E+12): followed once a reading or the meter allows it. Worst case is +1.4 Wh export (N=3, −800 W), with no swing. That matches the "Cost" paragraph.
- **B refuses or loses its link at E+5:** grid and energy are the same as release/1.0, with no swing (release/1.0 swings up to 680 W).
- **Second drain end in the window** (N=3, C drains from E and is switched Off at E+8 or E+15; 1200, 4000 and −1500 W): head swings 0–130 W against 290–950 W on release/1.0. The hold carries over correctly.
- **Peak guard with P1 at 10 s registers** (4000 and 3200 W, plus a +2800 W step at E+4; N=2/3): the site stays at or above the floor. At N=2 the rows match release/1.0. At N=3 import is lower (0.21 against 1.01 Wh).
- **Byte-identity against the real release/1.0 tree** (git archive): N=1 for every end at 1 s and 10 s registers, N=2/3 with no drain (moving house, B dead), and `opt11_identity`. All identical.
- **Plant vs the rig's `plant96.py`:** the same ramp (80 W per 0.1 s), lag, house noise, register phases (A 0.6, B 0.16) and B offset −100. The differences are minor: in-process the registers publish on a 0.1 s grid instead of the rig's 1 s tick, there is no ±1 W reading noise, no SoC limit and no board watchdog, and the meter has 0 s age. OK for this card.
- **Rejected alternatives:** the 1420/1020/1600 W (tester's rule), 70/140/80/580 W (lag model, tau 10 s) and 600 W (tau 5 s on a 20 s plant) figures all match the dev's sweep output.
**Findings**
1. **Fixed in c6f23a6.** DECISIONS "Not covered" (and the PR body) said the swing after a refusal or lost link is "up to 1.9 kW at N=3 under a surplus". The test table says 1.5 kW at N=2 and 3.1 kW (refusal) or 3.9 kW (lost link) at N=3, and about 1 kW after a lost link with no surplus at all (1.5 kW at N=3, 4000 W).
2. **Open question 1 is confirmed pre-existing and not made worse.** Rows for refusal and lost-link ends are byte-identical against the real release/1.0 tree at N=2 and N=3 for 1200, 4000 and −1500 W. It needs its own card, and it is not only under a surplus.
3. **Not this card, pre-existing.** With 10 s registers the saturation freeze (SAFETY-96) keeps the loop still for about 8–10 s after a +2 kW house step inside the window, on head and on release/1.0 alike. Example: B's reading is −100 W while it is commanded 1140 W. So part of the first seconds of "hold" is the freeze, not this rule.
4. **Considered and refuted:** a stale `_drain_house` leaking into a later window. It needs a window cut short by a loop-not-running gap and a new drain end on the first cycle back. Even then the hold is clamped into the meter's range, and that range is narrow after a gap of 23 s or more (readings caught up, commands steady). No change.
Mutation results follow in the next comment.
8 mutants of my own, beyond M1–M14. Each ran on a purged copy (tools/mutation_run.sh) against test_safety103, test_safety95 and test_safety96. The control run was green.
#
mutant
result
R1
first window cycle starts at the meter's low bound
killed ("held: the first window cycle", test_safety95)
R2
the meter only moves the point up (no upper clamp)
killed ("...and down", refusal sides)
R3
the meter only moves the point down (no lower clamp)
killed (5 unit checks, test_safety95)
R4
commands widen the range upward only
killed (range checks)
R5
aim dropped from the returned grid (a 10 W bias)
killed (8 unit checks, test_safety95)
R6
no memory: held restarts at the loop's target each cycle
killed ("held, then kept")
R7
tracking only at N>=3 (N=2 back to release/1.0)
killed (other register phases, solar step N=2, part 5)
R8
every non-refusing battery capped at its reading on the give side
killed (4 unit checks)
All 8 killed; none survive. Logs: scratch/SAFETY-103/review/mut/.
Note: R2, R4, R6 and R8 are killed only by part 5's unit checks, not by any plant run. The plant cases are not sensitive to those parts. The unit checks are exact, so this is acceptable.
## Review (SAFETY-103): mutation results
8 mutants of my own, beyond M1–M14. Each ran on a purged copy (`tools/mutation_run.sh`) against test_safety103, test_safety95 and test_safety96. The control run was green.
| # | mutant | result |
|---|---|---|
| R1 | first window cycle starts at the meter's low bound | killed ("held: the first window cycle", test_safety95) |
| R2 | the meter only moves the point up (no upper clamp) | killed ("...and down", refusal sides) |
| R3 | the meter only moves the point down (no lower clamp) | killed (5 unit checks, test_safety95) |
| R4 | commands widen the range upward only | killed (range checks) |
| R5 | aim dropped from the returned grid (a 10 W bias) | killed (8 unit checks, test_safety95) |
| R6 | no memory: held restarts at the loop's target each cycle | killed ("held, then kept") |
| R7 | tracking only at N>=3 (N=2 back to release/1.0) | killed (other register phases, solar step N=2, part 5) |
| R8 | every non-refusing battery capped at its reading on the give side | killed (4 unit checks) |
All 8 killed; none survive. Logs: `scratch/SAFETY-103/review/mut/`.
Note: R2, R4, R6 and R8 are killed only by part 5's unit checks, not by any plant run. The plant cases are not sensitive to those parts. The unit checks are exact, so this is acceptable.
All points are fixed (finding 1, c6f23a6) or refuted/out of scope (findings 2-4: pre-existing, for their own cards). No mutant survives (14 + 8). Local suite 67/67 on the head. run 1210 is green on c6f23a6 (a real run: the add-on tests step succeeded).
Approved. Merging, with the head pinned to c6f23a69d4. No tag, no deploy.
## Review (SAFETY-103): verdict
All points are fixed (finding 1, c6f23a6) or refuted/out of scope (findings 2-4: pre-existing, for their own cards). No mutant survives (14 + 8). Local suite 67/67 on the head. run 1210 is green on c6f23a6 (a real run: the add-on tests step succeeded).
Approved. Merging, with the head pinned to c6f23a69d44d211c1643a09d0d3a8289bc527029. No tag, no deploy.
admin
merged commit b527e99f13 into release/1.02026-10-08 04:48:27 +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-103 (from the SAFETY-95 rig test, check 5, F1).
The fault
While a battery settles from its ended drain, SAFETY-95 steers on grid + every battery's reading. A register can be 10 s older than the meter. On the rig, A's reading still said 1238 W while A was at 616 W, and B swung 860 -> 1650 -> 870 W.
The rule (N>=2, only while a drain's tail settles)
Why not the tester's rule (count a battery at its command while its reading is older than that command): B's command changes almost every cycle, so that is the plain meter, which chases A's tail. Measured: 1420 W swing, release/1.0 760 W. A lag model needs the plant's time constant; with it 2x off the swing is up to 600 W. Holding the whole window costs house steps (kettle 14.4 Wh vs 11.5). Numbers:
DECISIONS/SAFETY-103-old-readings-after-a-drain.md.Results (
test_safety103.py, planttools/safety103_plant.py: 10 s lag, 1 s meter, 10 s registers, B idles at -100 W)N=1: also byte-identical against the real release/1.0 tree (24 plant runs +
tools/opt11_identity.py, n2_maint digest unchanged).Not covered
The swing after a lost link or a refusal (under a surplus up to 1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW even without one) is the loop's own on a lagging plant: the same with 1 s registers and on release/1.0 (review: rows byte-identical with the release/1.0 tree).
Mutations
Purged copies (
tools/mutation_run.sh), control green. Stage 1 test_safety103.py; a survivor also gets test_safety95/96/93 and test_modes.All 14 killed. Local full suite 67/67 (goodwe-addon-checks:1).
No user-facing strings change. DOCS gets one sentence. CHANGELOG is left for the release step.
🤖 Generated with Claude Code
Review (SAFETY-103), round 1
Code. The rule does what the DECISIONS entry says. Hold starts at the loop's start, clamped every cycle into grid + Σ ranges − aim; reset only when the tail empties (the only place
_drain_tailshrinks). The peak guard is a separate P_PEAK floor and does not read the loop's grid, so it still wins.Checked on
tools/safety103_plant.py, head against release/1.0's rule (probes inscratch/SAFETY-103/review/probes/):opt11_identity. All identical.plant96.py: the same ramp (80 W per 0.1 s), lag, house noise, register phases (A 0.6, B 0.16) and B offset −100. The differences are minor: in-process the registers publish on a 0.1 s grid instead of the rig's 1 s tick, there is no ±1 W reading noise, no SoC limit and no board watchdog, and the meter has 0 s age. OK for this card.Findings
c6f23a6. DECISIONS "Not covered" (and the PR body) said the swing after a refusal or lost link is "up to 1.9 kW at N=3 under a surplus". The test table says 1.5 kW at N=2 and 3.1 kW (refusal) or 3.9 kW (lost link) at N=3, and about 1 kW after a lost link with no surplus at all (1.5 kW at N=3, 4000 W)._drain_houseleaking into a later window. It needs a window cut short by a loop-not-running gap and a new drain end on the first cycle back. Even then the hold is clamped into the meter's range, and that range is narrow after a gap of 23 s or more (readings caught up, commands steady). No change.Mutation results follow in the next comment.
Review (SAFETY-103): mutation results
8 mutants of my own, beyond M1–M14. Each ran on a purged copy (
tools/mutation_run.sh) against test_safety103, test_safety95 and test_safety96. The control run was green.All 8 killed; none survive. Logs:
scratch/SAFETY-103/review/mut/.Note: R2, R4, R6 and R8 are killed only by part 5's unit checks, not by any plant run. The plant cases are not sensitive to those parts. The unit checks are exact, so this is acceptable.
Review (SAFETY-103): verdict
All points are fixed (finding 1,
c6f23a6) or refuted/out of scope (findings 2-4: pre-existing, for their own cards). No mutant survives (14 + 8). Local suite 67/67 on the head. run 1210 is green onc6f23a6(a real run: the add-on tests step succeeded).Approved. Merging, with the head pinned to
c6f23a69d4. No tag, no deploy.