SAFETY-96: a battery the loop cannot move does not freeze it #176

Merged
admin merged 2 commits from safety-96-stale-unit-site-total into release/1.0 2026-10-06 06:19:13 +02:00
Owner

Problem

At N=2 the saturation freeze compares the site target with the site sum of the battery readings. After A's link went stale, A was out of the split but its reading stayed in the sum. The SAFETY-93 rig run logged "saturation freeze (800 W vs -1903 W)" and held B at 1200 W for about 236 cycles, until the circulation trip. After a charge timeout or Off at 6000 W, the loop froze for about 14 s at 800 W while A came out of its charge. At a drop, B also took A's whole share in one cycle (a step of +1210 W).

The drop rule (N>=2 only; DECISIONS/SAFETY-96-dropped-battery.md)

In the freeze test, a battery counts at its command, not at its reading, while any of these holds:

  • it is out of the split (a stale link);
  • its last write was refused (its board may have gone to 0 W on its own watchdog);
  • it is settling after a step the loop did not make. That is a SAFETY-93 charge end (HOLD, timeout, Off, refusal, stale link) or a rejoin. A rejoin settles every battery, because the split is made again. Settling ends when the reading is within the deadband of the command, or after PLANT_SETTLE_DOWN_S (23 s, the measured plant settle).

Why: the meter still sees that battery's real power, and the loop follows the meter. So that power is neither lost nor counted twice. Only the freeze stops treating it as the loop's own power. The freeze still fires on the other battery's own lag and on watts that could not be placed.

Start point: at a drop, the battery's share leaves the loop's start point, so the others take over one slew step a cycle. At a rejoin, the battery enters at its reading. A charge end that is also a drop belongs to SAFETY-93's reseed, which now uses 0 W for a battery that is not live.

Not drops:

  • A missing reading or a stale register still stops the site (inputs_ok).
  • A SAFETY-73 refused battery (asked about 0 W, reads about 0 W) and a sign-watchdog stop (pinned at 0 W) stay in the sum.

New: no refusal is learned on a battery whose writes are refused. On base, the board's 0 W latched "BATTERY REFUSES" during the drop, and that kept A out of the split for 15 min after its link came back.

Before -> after

Plant: test_safety96.py, 2 x 2500 W, 800 W/s then a 10 s lag, 2 s meter, slew 400.

  • "stuck": the dropped battery holds its last command.
  • "board 0 W": its board goes to 0 W 25 s after the first refused write.
  • The base column was run on 1bf6b7f itself.
case freeze cycles not about the remaining battery's own lag, head / base all freeze cycles, head / base remaining battery's largest step W/cycle, head / base mean grid error W, head / base circulation trip, head / base
A drops while giving, stuck 0 / 128 0 / 130 0 / 1210 10 / 411 no / no
A drops while giving, board 0 W 0 / 0 4 / 13 300 / 1205 37 / 25 no / no
A drops while charging, stuck 0 / 78 13 / 83 400 / 400 2271 / 2751 yes / yes (1)
A drops while charging, board 0 W 0 / 5 5 / 10 400 / 400 643 / 678 no / no
B drops while giving, stuck 0 / 128 0 / 130 0 / 1210 10 / 411 no / no
B drops while giving, board 0 W 0 / 0 4 / 13 300 / 1205 37 / 25 no / no
both drop, stuck 0 / 0 0 / 132 1210 / 1210 (to 0 W) 10 / 10 no / no
both drop, board 0 W 0 / 3 0 / 5 1390 / 1390 (to 0 W) 2116 / 2116 no / no
A drops then rejoins, stuck 0 / 46 0 / 49 55 / 1210 11 / 161 (grid after the rejoin: max 180 W / over 1.1 kW) no / no
A drops then rejoins, board 0 W 0 / 0 4 / 13 1205 / - (2) 23 / 19 no / no
charge timeout at 6000 W 0 / 7 (B held at 400 W) 2 / 7 200 / 0 6446 / 6796 no / no
A switched Off at 6000 W 0 / 6 (B held at 800 W) 4 / 7 400 / 400 6572 / 6788 no / no

The remaining freeze cycles on head are the remaining battery's own lag (its command against its own reading), as at N=1.

(1) A keeps charging 2000 W on its own while B gives 2500 W. That is 4 kW really going round between the batteries, so the trip is correct. On a real install the board's own watchdog sets A to 0 W, which is the "board 0 W" row.

(2) At the rejoin the allocator splits again at once: A 0 -> 1205 W, B 2410 -> 1205 W. The site total does not step and the grid stays within 30 W. On base, A never rejoins in this run: a refusal was latched (see "New" above).

Identity

  • N=1 and N=2 with no drop: rows byte-identical with and without SAFETY-96 (test_safety96.py part 6).
  • All 46 digests of tools/*identity*.py are byte-identical to 1bf6b7f. Nothing was re-pinned there.

Changed existing test (pinned value)

test_safety78.py part 2, the dropped-drain case: B now rests at 1510 W instead of 1500 W. Both values are inside the 15 W deadband, and 1510 W puts the grid on its -10 W aim.

  • Why it moved: at the drop, A's 2000 W drain share now leaves the start point. B therefore goes 1730 -> 1600 -> 1510 W, where on base it went 1730 -> 2500 -> ... -> 1500 W.
  • Unchanged: the check's claim, that B covers the house and not the drain rate.

Mutations

Run on purged copies (no __pycache__), mounted :ro, with python3 -B. Graded by test_safety96, test_safety93 and test_refusal_units. The control run (unmutated) is green on all three. 17 of 17 killed.

# mutation result
M1 a dropped battery not held out red (safety96 1)
M2 a battery with refused writes not held out red (safety96 1)
M3 a settling battery not held out red (safety96 8)
M4 a charge end starts no settling red (safety96 4)
M5 a drop leaves the start point as it was red (safety96 6)
M6 a rejoin enters at 0 W, not its reading red (safety96 2)
M7 a rejoin settles only the returning battery red (safety96 2)
M8 a refusal is learned while writes are refused red (safety96 1)
M9 settling ends within saturation_w, not the deadband red (safety96 2)
M10 settling has no time limit red (safety96 1)
M11 settling may end on the cycle it starts red (safety96 1)
M12 the held-out command ignores a reseeded start red (safety96 1)
M13 the charge-end reseed runs before the drop reseed red (safety96 2, safety93 1)
M14 the held-out watts are not added red (safety96 12)
M15 liveness not tracked while the loop is stopped red (safety96 1)
M16 a drop also reseeds a battery whose charge ended red (safety96 3, safety93 2)
M17 a charge end ignores that its battery dropped red (safety96 1)

Checks run

  • Full suite, 65 test files: all pass in goodwe-addon-checks:1, with admin/goodwe at the infra pin.
  • Offline gates (--network none): version_gate, docs, defaults_pin, validate, tool_inputs, ci_gate, messages, translations, ui_text and safety96 all pass.

Notes

  • Text: no user-facing text changed (EN/NL untouched). DOCS.md has one line. There is no version or CHANGELOG change.
  • Pin at the call site: test_inverter.py part 5 pins the call site site_batt = self.site_batt_w. That assignment is unchanged, and the held-out watts are added on the next line (+=). So actual_w is still a site-level sum.

Handed back

  • Rejoin re-split: at a rejoin the allocator splits again in one cycle, so each battery steps even though the site does not. A per-battery phase-in would be an allocator change.
  • Trade end: a trade end (_reseed_run_edge) still seeds from the full site sum, including a dropped battery's reading. This is rare: a trade must end while a battery is dropped.
  • Edges between loop runs: a drop or rejoin that happens while the loop is not running (control stopped or a reading missing) is not reseeded. The allocator's own split applies, as on base.
  • Output check noise: a dropped battery still raises the output-check ("gives +14 W of the +1210 W asked") and keeps its last-write "standing" value. This is pre-existing noise, not changed here.
  • Not tested on hardware: the rig (TEST-23) should repeat the SAFETY-93 c4sh, c3t6h and c3o6h runs, plus a link drop and rejoin with the board's own watchdog.

🤖 Generated with Claude Code

https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr

## Problem At N=2 the saturation freeze compares the site target with the site sum of the battery readings. After A's link went stale, A was out of the split but its reading stayed in the sum. The SAFETY-93 rig run logged "saturation freeze (800 W vs -1903 W)" and held B at 1200 W for about 236 cycles, until the circulation trip. After a charge timeout or Off at 6000 W, the loop froze for about 14 s at 800 W while A came out of its charge. At a drop, B also took A's whole share in one cycle (a step of +1210 W). ## The drop rule (N>=2 only; DECISIONS/SAFETY-96-dropped-battery.md) In the freeze test, a battery counts at its command, not at its reading, while any of these holds: - it is out of the split (a stale link); - its last write was refused (its board may have gone to 0 W on its own watchdog); - it is settling after a step the loop did not make. That is a SAFETY-93 charge end (HOLD, timeout, Off, refusal, stale link) or a rejoin. A rejoin settles every battery, because the split is made again. Settling ends when the reading is within the deadband of the command, or after `PLANT_SETTLE_DOWN_S` (23 s, the measured plant settle). **Why:** the meter still sees that battery's real power, and the loop follows the meter. So that power is neither lost nor counted twice. Only the freeze stops treating it as the loop's own power. The freeze still fires on the other battery's own lag and on watts that could not be placed. **Start point:** at a drop, the battery's share leaves the loop's start point, so the others take over one slew step a cycle. At a rejoin, the battery enters at its reading. A charge end that is also a drop belongs to SAFETY-93's reseed, which now uses 0 W for a battery that is not live. **Not drops:** - A missing reading or a stale register still stops the site (`inputs_ok`). - A SAFETY-73 refused battery (asked about 0 W, reads about 0 W) and a sign-watchdog stop (pinned at 0 W) stay in the sum. **New:** no refusal is learned on a battery whose writes are refused. On base, the board's 0 W latched "BATTERY REFUSES" during the drop, and that kept A out of the split for 15 min after its link came back. ## Before -> after Plant: `test_safety96.py`, 2 x 2500 W, 800 W/s then a 10 s lag, 2 s meter, slew 400. - "stuck": the dropped battery holds its last command. - "board 0 W": its board goes to 0 W 25 s after the first refused write. - The base column was run on 1bf6b7f itself. | case | freeze cycles not about the remaining battery's own lag, head / base | all freeze cycles, head / base | remaining battery's largest step W/cycle, head / base | mean grid error W, head / base | circulation trip, head / base | |---|---|---|---|---|---| | A drops while giving, stuck | 0 / 128 | 0 / 130 | 0 / 1210 | 10 / 411 | no / no | | A drops while giving, board 0 W | 0 / 0 | 4 / 13 | 300 / 1205 | 37 / 25 | no / no | | A drops while charging, stuck | 0 / 78 | 13 / 83 | 400 / 400 | 2271 / 2751 | yes / yes (1) | | A drops while charging, board 0 W | 0 / 5 | 5 / 10 | 400 / 400 | 643 / 678 | no / no | | B drops while giving, stuck | 0 / 128 | 0 / 130 | 0 / 1210 | 10 / 411 | no / no | | B drops while giving, board 0 W | 0 / 0 | 4 / 13 | 300 / 1205 | 37 / 25 | no / no | | both drop, stuck | 0 / 0 | 0 / 132 | 1210 / 1210 (to 0 W) | 10 / 10 | no / no | | both drop, board 0 W | 0 / 3 | 0 / 5 | 1390 / 1390 (to 0 W) | 2116 / 2116 | no / no | | A drops then rejoins, stuck | 0 / 46 | 0 / 49 | 55 / 1210 | 11 / 161 (grid after the rejoin: max 180 W / over 1.1 kW) | no / no | | A drops then rejoins, board 0 W | 0 / 0 | 4 / 13 | 1205 / - (2) | 23 / 19 | no / no | | charge timeout at 6000 W | 0 / 7 (B held at 400 W) | 2 / 7 | 200 / 0 | 6446 / 6796 | no / no | | A switched Off at 6000 W | 0 / 6 (B held at 800 W) | 4 / 7 | 400 / 400 | 6572 / 6788 | no / no | The remaining freeze cycles on head are the remaining battery's own lag (its command against its own reading), as at N=1. (1) A keeps charging 2000 W on its own while B gives 2500 W. That is 4 kW really going round between the batteries, so the trip is correct. On a real install the board's own watchdog sets A to 0 W, which is the "board 0 W" row. (2) At the rejoin the allocator splits again at once: A 0 -> 1205 W, B 2410 -> 1205 W. The site total does not step and the grid stays within 30 W. On base, A never rejoins in this run: a refusal was latched (see "New" above). ## Identity - N=1 and N=2 with no drop: rows byte-identical with and without SAFETY-96 (`test_safety96.py` part 6). - All 46 digests of `tools/*identity*.py` are byte-identical to 1bf6b7f. Nothing was re-pinned there. ## Changed existing test (pinned value) `test_safety78.py` part 2, the dropped-drain case: B now rests at **1510 W** instead of 1500 W. Both values are inside the 15 W deadband, and 1510 W puts the grid on its -10 W aim. - **Why it moved:** at the drop, A's 2000 W drain share now leaves the start point. B therefore goes 1730 -> 1600 -> 1510 W, where on base it went 1730 -> 2500 -> ... -> 1500 W. - **Unchanged:** the check's claim, that B covers the house and not the drain rate. ## Mutations Run on purged copies (no `__pycache__`), mounted `:ro`, with `python3 -B`. Graded by test_safety96, test_safety93 and test_refusal_units. The control run (unmutated) is green on all three. **17 of 17 killed.** | # | mutation | result | |---|---|---| | M1 | a dropped battery not held out | red (safety96 1) | | M2 | a battery with refused writes not held out | red (safety96 1) | | M3 | a settling battery not held out | red (safety96 8) | | M4 | a charge end starts no settling | red (safety96 4) | | M5 | a drop leaves the start point as it was | red (safety96 6) | | M6 | a rejoin enters at 0 W, not its reading | red (safety96 2) | | M7 | a rejoin settles only the returning battery | red (safety96 2) | | M8 | a refusal is learned while writes are refused | red (safety96 1) | | M9 | settling ends within saturation_w, not the deadband | red (safety96 2) | | M10 | settling has no time limit | red (safety96 1) | | M11 | settling may end on the cycle it starts | red (safety96 1) | | M12 | the held-out command ignores a reseeded start | red (safety96 1) | | M13 | the charge-end reseed runs before the drop reseed | red (safety96 2, safety93 1) | | M14 | the held-out watts are not added | red (safety96 12) | | M15 | liveness not tracked while the loop is stopped | red (safety96 1) | | M16 | a drop also reseeds a battery whose charge ended | red (safety96 3, safety93 2) | | M17 | a charge end ignores that its battery dropped | red (safety96 1) | ## Checks run - Full suite, 65 test files: all pass in `goodwe-addon-checks:1`, with admin/goodwe at the infra pin. - Offline gates (`--network none`): version_gate, docs, defaults_pin, validate, tool_inputs, ci_gate, messages, translations, ui_text and safety96 all pass. ## Notes - **Text:** no user-facing text changed (EN/NL untouched). DOCS.md has one line. There is no version or CHANGELOG change. - **Pin at the call site:** `test_inverter.py` part 5 pins the call site `site_batt = self.site_batt_w`. That assignment is unchanged, and the held-out watts are added on the next line (`+=`). So `actual_w` is still a site-level sum. ## Handed back - **Rejoin re-split:** at a rejoin the allocator splits again in one cycle, so each battery steps even though the site does not. A per-battery phase-in would be an allocator change. - **Trade end:** a trade end (`_reseed_run_edge`) still seeds from the full site sum, including a dropped battery's reading. This is rare: a trade must end while a battery is dropped. - **Edges between loop runs:** a drop or rejoin that happens while the loop is not running (control stopped or a reading missing) is not reseeded. The allocator's own split applies, as on base. - **Output check noise:** a dropped battery still raises the output-check ("gives +14 W of the +1210 W asked") and keeps its last-write "standing" value. This is pre-existing noise, not changed here. - **Not tested on hardware:** the rig (TEST-23) should repeat the SAFETY-93 c4sh, c3t6h and c3o6h runs, plus a link drop and rejoin with the board's own watchdog. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
admin added 1 commit 2026-10-06 04:24:45 +02:00
SAFETY-96: a battery the loop cannot move does not freeze it
checks / unit (pull_request) Successful in 31m44s
infra-pin / fresh (pull_request) Successful in 2s
88b9cd470a
At N>=2 a battery out of the split (stale link), with refused writes, or
settling after a step the loop did not make (a charge end, a rejoin) now
counts in the freeze test at its command, not its reading. Its real power
still reaches the loop through the meter. At a drop its share leaves the
loop's start point (the others take over one slew step a cycle); at a
rejoin it enters at its reading. No refusal is learned while a battery's
writes are refused. N=1 and the 46 identity digests are byte-identical.

test_safety78 part 2: the dropped-drain case now rests at B 1510 W (grid at
its -10 W aim) instead of 1500 W; the drop no longer sends B to 2500 W.

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

Review round 1 (SAFETY-96)

Checked on head 88b9cd4 in goodwe-addon-checks:1, admin/goodwe at the infra pin 3b4ff84. Probes and logs are in scratch/SAFETY-96/review/.

Findings

F1, must fix: the refusal count is reset on every refused write. _unit_refusals() sets inv.ref_count = 0 while rejected_since is set. Suppose a battery really refuses (it reads 0 W whatever it is told) and its link refuses 1 write in 2, 3 or 5. Then the count never reaches SETTLE_CYCLES, and the refusal is never learned. Probe probes/attack.py, with a heartbeat verdict each cycle and A at 0 W from 300 s:

writes refused head: refusal learned head: end state with the count kept
none 346 s A 0, B 2410 346 s
1 in 2 never A asked 2400 W and gives 0, site target 4800 W 370 s
1 in 3 never the same 358 s
1 in 5 never the same 352 s

The freeze cannot catch this either, because A is held out on its refused cycles and so sat_count resets. The loop winds A's share up to 2400 W of watts it never gives. If A starts giving again, that comes out as an export step. Fix: keep the count (continue without the reset), as _gave_nothing already does across a reading gap. The board-0-W case that motivated the skip still learns nothing, because the count does not grow while writes are refused.

F2, test gap: one of my mutations survived. R7 skips refusal learning only once the battery is dropped (not live), not while its writes are refused. test_safety96, 93, refusal_units and 78 all stayed green. No case has a board that reads about 0 W before its link goes stale: with a 10 s lag and the board going to 0 W at 25 s, the reading never gets below the refusal band within 30 s. Fix: add a case with a fast battery (lag 1 s) whose board goes to 0 W 1 s after the first refused write. It must learn no refusal and must rejoin within 4 s.

Checked, no change needed

  • Consumers of the changed sum. site_batt is local. It feeds only compute(actual_w=), that is the freeze, the freeze release (unasked_power) and the SAFETY-52 site law at N>1 (_site_refused(): every unit refused the same way). It also feeds the freeze log line. _refusal_update is N=1 only. The peak guard (lines 5603/5733), the house estimate, the circulation guard, the sign watchdog and delivery all read site_batt_w or per-unit readings, so they are unchanged. The loop error is grid, which carries the dropped battery's real power once, and prev_w is the live shares after the reseed. Nothing is counted twice and nothing is ignored. A runaway on a dropped battery still trips the circulation guard (case 2 "stuck"), and a live battery is held out only while its writes are refused (at most LINK_STALE_S) or while it settles (at most 23 s).
  • Flapping link (attack on the settling window). A refused for 31 s, back for 3 s, repeating, while B cannot give more than 600 W. B settles in 253 of 400 cycles, and the longest run is 13 cycles (26 s). The freeze still fires in 80 cycles (394 on base), and the grid max is 666 W against 1709 W on base. This is bounded: each rejoin needs a new drop, which takes at least 30 s, and settling ends by 23 s. It is not unbounded.
  • N=1. _unit_refusals and both new methods run only at N>=2. All 46 tools/*identity*.py digests are byte-identical, head against base, each run in its own process. My own five N=1 scenarios are byte-identical on the real base tree (not the monkeypatch): a moving house, a flapping link, refusals at 300-420 s, a refusing battery with flaky writes, and a 600 W cap with a step house.
  • test_safety78 1500 -> 1510 W. Traced on both trees. On base, B steps to 2500 W at the drop and then settles at 1500 W. On head, B goes 1600 -> 1540 -> 1530 -> 1510 W. Both values are inside the 15 W deadband, and the claim of the check is unchanged. No other test was changed or removed.
  • Rejoin. At a rejoin each battery steps by |B's command - A's reading| / 2, up to one rating, but the site command does not step. The peak guard's inputs are unchanged. A rejoin during a maintenance charge (A resumes at -2000 W, B goes to 0 W in one cycle, grid 6 kW at a 4 kW house) behaves the same on base: that is the charge, not this PR.
  • Docs. One line in DOCS.md and a 57-line DECISIONS file. No option and no EN/NL text changed.

I am pushing F1 and F2 to the branch myself.

## Review round 1 (SAFETY-96) Checked on head 88b9cd4 in `goodwe-addon-checks:1`, admin/goodwe at the infra pin 3b4ff84. Probes and logs are in `scratch/SAFETY-96/review/`. ### Findings **F1, must fix: the refusal count is reset on every refused write.** `_unit_refusals()` sets `inv.ref_count = 0` while `rejected_since` is set. Suppose a battery really refuses (it reads 0 W whatever it is told) and its link refuses 1 write in 2, 3 or 5. Then the count never reaches `SETTLE_CYCLES`, and the refusal is never learned. Probe `probes/attack.py`, with a heartbeat verdict each cycle and A at 0 W from 300 s: | writes refused | head: refusal learned | head: end state | with the count kept | |---|---|---|---| | none | 346 s | A 0, B 2410 | 346 s | | 1 in 2 | never | A asked 2400 W and gives 0, site target 4800 W | 370 s | | 1 in 3 | never | the same | 358 s | | 1 in 5 | never | the same | 352 s | The freeze cannot catch this either, because A is held out on its refused cycles and so `sat_count` resets. The loop winds A's share up to 2400 W of watts it never gives. If A starts giving again, that comes out as an export step. **Fix:** keep the count (`continue` without the reset), as `_gave_nothing` already does across a reading gap. The board-0-W case that motivated the skip still learns nothing, because the count does not grow while writes are refused. **F2, test gap: one of my mutations survived.** R7 skips refusal learning only once the battery is dropped (not live), not while its writes are refused. test_safety96, 93, refusal_units and 78 all stayed green. No case has a board that reads about 0 W before its link goes stale: with a 10 s lag and the board going to 0 W at 25 s, the reading never gets below the refusal band within 30 s. **Fix:** add a case with a fast battery (lag 1 s) whose board goes to 0 W 1 s after the first refused write. It must learn no refusal and must rejoin within 4 s. ### Checked, no change needed - **Consumers of the changed sum.** `site_batt` is local. It feeds only `compute(actual_w=)`, that is the freeze, the freeze release (`unasked_power`) and the SAFETY-52 site law at N>1 (`_site_refused()`: every unit refused the same way). It also feeds the freeze log line. `_refusal_update` is N=1 only. The peak guard (lines 5603/5733), the house estimate, the circulation guard, the sign watchdog and delivery all read `site_batt_w` or per-unit readings, so they are unchanged. The loop error is `grid`, which carries the dropped battery's real power once, and `prev_w` is the live shares after the reseed. Nothing is counted twice and nothing is ignored. A runaway on a dropped battery still trips the circulation guard (case 2 "stuck"), and a live battery is held out only while its writes are refused (at most `LINK_STALE_S`) or while it settles (at most 23 s). - **Flapping link (attack on the settling window).** A refused for 31 s, back for 3 s, repeating, while B cannot give more than 600 W. B settles in 253 of 400 cycles, and the longest run is 13 cycles (26 s). The freeze still fires in 80 cycles (394 on base), and the grid max is 666 W against 1709 W on base. This is bounded: each rejoin needs a new drop, which takes at least 30 s, and settling ends by 23 s. It is not unbounded. - **N=1.** `_unit_refusals` and both new methods run only at N>=2. All 46 `tools/*identity*.py` digests are byte-identical, head against base, each run in its own process. My own five N=1 scenarios are byte-identical on the real base tree (not the monkeypatch): a moving house, a flapping link, refusals at 300-420 s, a refusing battery with flaky writes, and a 600 W cap with a step house. - **test_safety78 1500 -> 1510 W.** Traced on both trees. On base, B steps to 2500 W at the drop and then settles at 1500 W. On head, B goes 1600 -> 1540 -> 1530 -> 1510 W. Both values are inside the 15 W deadband, and the claim of the check is unchanged. No other test was changed or removed. - **Rejoin.** At a rejoin each battery steps by |B's command - A's reading| / 2, up to one rating, but the site command does not step. The peak guard's inputs are unchanged. A rejoin during a maintenance charge (A resumes at -2000 W, B goes to 0 W in one cycle, grid 6 kW at a 4 kW house) behaves the same on base: that is the charge, not this PR. - **Docs.** One line in DOCS.md and a 57-line DECISIONS file. No option and no EN/NL text changed. I am pushing F1 and F2 to the branch myself.
admin added 1 commit 2026-10-06 05:46:35 +02:00
SAFETY-96 review: keep the refusal count over a refused write; test a fast board
checks / unit (pull_request) Successful in 31m53s
infra-pin / fresh (pull_request) Successful in 2s
956711b801
F1: resetting the count on each refused write hid a battery that really
refuses behind a link refusing 1 write in 3 (never learned; learned at
358 s with the count kept). F2: a board at 0 W before its link is stale
learned no refusal untested (mutation R7 survived); now a 1 s-lag case.

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

Review round 2: F1 and F2 fixed in 956711b

  • F1: _unit_refusals() now keeps the count (continue, with no reset) while a battery's writes are refused. That is the same rule as across a reading gap. DECISIONS has one added sentence.
  • F2, test_safety96 adds two checks:
    • A really refuses from 300 s while its link refuses 1 write in 3. The refusal is learned at 358 s and B carries the house (2410 W). On 88b9cd4 it is never learned: A is asked for 2400 W and B stands at 2400 W.
    • A fast battery (1 s lag) whose board goes to 0 W 1 s after the first refused write. No refusal is learned, and A rejoins within 4 s.

Verification, in goodwe-addon-checks:1 with the infra pin:

  • Full suite: 65 of 65 pass. test_named_absence needed the new reads routed through first()/present(), and that is done.
  • Identity: all 46 tools/*identity*.py digests are byte-identical to base 1bf6b7f.

Mutations on 956711b:

# mutation result
F7 refusal learning skipped only once the battery is dropped, not while its writes are refused (R7) red (the fast-battery check)
F8 no skip at all red, 3 checks
F9 the count reset again (88b9cd4's rule) red (the 1-in-3 check)
R0 control, no mutation green

My mutations on 88b9cd4, none on dev's list (graded by safety96, 93, refusal_units and 78):

# mutation result
R1 the held-out sign flipped red, 20 checks
R2 a drop or rejoin does not reseed the integrator red: safety96 7, safety78 1
R3 held-out also active at N=1 red, 2 checks (N=1 identity)
R4 the start point not cleared each cycle red, 2 checks
R5 settling ends against the allocator share, not the reseeded command red, 1 check
R7 see F7 survived, now killed
R8 a rejoining battery enters at its reading clamped to >= 0 W red, 1 check
## Review round 2: F1 and F2 fixed in 956711b - **F1:** `_unit_refusals()` now keeps the count (`continue`, with no reset) while a battery's writes are refused. That is the same rule as across a reading gap. DECISIONS has one added sentence. - **F2, test_safety96 adds two checks:** - A really refuses from 300 s while its link refuses 1 write in 3. The refusal is learned at 358 s and B carries the house (2410 W). On 88b9cd4 it is never learned: A is asked for 2400 W and B stands at 2400 W. - A fast battery (1 s lag) whose board goes to 0 W 1 s after the first refused write. No refusal is learned, and A rejoins within 4 s. **Verification, in `goodwe-addon-checks:1` with the infra pin:** - **Full suite:** 65 of 65 pass. test_named_absence needed the new reads routed through `first()`/`present()`, and that is done. - **Identity:** all 46 `tools/*identity*.py` digests are byte-identical to base 1bf6b7f. **Mutations on 956711b:** | # | mutation | result | |---|---|---| | F7 | refusal learning skipped only once the battery is dropped, not while its writes are refused (R7) | red (the fast-battery check) | | F8 | no skip at all | red, 3 checks | | F9 | the count reset again (88b9cd4's rule) | red (the 1-in-3 check) | | R0 | control, no mutation | green | **My mutations on 88b9cd4, none on dev's list** (graded by safety96, 93, refusal_units and 78): | # | mutation | result | |---|---|---| | R1 | the held-out sign flipped | red, 20 checks | | R2 | a drop or rejoin does not reseed the integrator | red: safety96 7, safety78 1 | | R3 | held-out also active at N=1 | red, 2 checks (N=1 identity) | | R4 | the start point not cleared each cycle | red, 2 checks | | R5 | settling ends against the allocator share, not the reseeded command | red, 1 check | | R7 | see F7 | **survived**, now killed | | R8 | a rejoining battery enters at its reading clamped to >= 0 W | red, 1 check |
admin merged commit b2f55732b6 into release/1.0 2026-10-06 06:19:13 +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#176