Root cause. When the peak guard (or a maintenance charge) hands the wire back, cycle() re-seeds the integrator from that owner's command (main.py:5186-5188) and compute() adds gain * error (control.py:586) from a meter that does not show that command yet. The ask crosses the rail (control.py:641 i_clamped) for a cycle or two. It beats the guard's one-sided floor, so the loop is the winner and SAFETY-85 rule 1 counts it as the loop's own bound: WARNING OUTPUT_CLAMP and goodwe_integrator_bound. The battery gives what it is told; nothing is short.
Fix. After a hand-back, the loop's integrator bound is not counted until the plant's settle has passed (SHORT_CYCLES cycles and SHORT_SPAN_S, SAFETY-85's window). Across all hand-backs it is hidden on at most a budget of cycles in any 15 minutes (ROLLING_BUDGET, ROLLING_S); a cycle over the budget counts. The budget follows the measured cycle length: 10 up to 3.5 s, 8 up to 11 s, 6 up to 16 s, 4 beyond; 8 while the cycle length is unknown; without a monotonic clock it never refills. An over-rating always counts on a cycle the loop decided. No command changes, no text changes.
Round 3 (owner decision 2026-10-20, option a): rolling budget. Round 1 restarted the window at every hand-back (guard and charge taking turns hid the bound for good). Round 2 gave each run its own budget, which restarts every quarter at 10 s and 15 s meters (0 warnings in 2 h, 14 without the fix).
Round 3c: why the budget follows the cycle length. The brief asked for one N from a sweep. No single N met both cases (sweep of 3 to 10, 2 h each, 1 to 30 s meters): 3 to 6 let the quarter-edge false alarm back at 2 s; 7 on 10 of 30 seeds at 2 s; 8 on 1 of 30 at 2 s (seed 25) and warned in only 1 to 3 of 7 quarters at 15 s; 9 and 10 miss quarters at 10 s. Round 3b shipped 8 anyway; that was wrong and is replaced here. A slow cycle needs a small budget to warn, a fast one a large budget to stay quiet, so each cycle-length band takes the largest budget that still warns (details in DECISIONS).
What holds (measured, 2 h each).
Quarter edge (N=1, house 800 W, cap 2500 W, no forecast), 30 seeds per meter: 0 notifications at 1, 2, 3, 4, 5, 10, 12, 15, 20 and 30 s (without the fix: 2 to 11 per run at 1 to 15 s on seeds 1-3 and 25, one 2 s seed silent).
Repeated hand-backs (N=2, 2 x 2500 W, cheap-window charge, no usable forecast, cap 5000 W, house 5050 and 5100 W, constant and with noise, 8 runs per meter):
meter
warnings in 2 h
quarters warned (of 7 after the first)
without the fix
1 s
56 to 63
7
56 to 63
2 s
49 to 56
7
54 to 56
5 s
19 to 23
7
28
10 s
7 to 10
7
14
15 s
4 to 10
4 to 7, never two quiet in a row
14
30 s
3 to 7
3 to 5, up to two quiet in a row
7 to 11
30 s is outside the brief's meters; no budget does better there (4 leaves two quiet quarters, 3 brings back the false alarm on 14 of 30 seeds).
Single hand-back, loop at its rail, first warning compared with no fix: 10 s at 1 s, 20 s at 2 s, 40 s at 5 s (default), 80 s at 10 s, 90 s at 15 s, 80 s at 20 s, 120 s at 30 s, 240 s at 60 s. At 60 s the rig is a different regime (house not over the cap in the never case): nothing more is claimed.
Every command is the same with and without the fix. The cap held at 1 to 10 s in the quarter edge and 1 to 5 s in the never case; slower, the meter's lag puts quarters over it, the same without the fix.
Limits: these runs have one cycle per meter reading, at a steady interval. A 20 or 30 s cycle needs cycle_floor_s raised; at the default 15 s, a slower meter adds cycles on an old reading. Measured that way by the reviewer (11 seeds): a 15 s meter that varies by up to 30 % false-alarmed on 5 seeds, a 30 s meter on 10 (0 to 4 in 2 h, 8 to 15 without the fix); a 20 s meter on none. The repeated hand-back case still warned every quarter there.
Tests.test_safety119.py: the rig's quarter edge; the quarter edge at 2, 5, 10 and 15 s (seeds 1-3) and at 2 s seed 25, where a fixed budget of 8 warns (pinned); a genuine bound still warns; a usable forecast changes nothing; the never case at 2, 5, 10 (every quarter) and 15 s (at least every other quarter); a blind spell is not a hand-back; the rolling budget at its boundaries (budget per cycle length, refill at 900 s, not per run, a gap or a stop is not a cycle length, no clock, over-rating does not use it); test_safety84/87 digests differ only in OUTPUT_CLAMP log rows.
Mutations (purged copies, each caught by test_safety119.py except R5, which test_safety85.py catches): the reviewer's R1-R7 re-aimed (bound hidden for good, a stop arms a hand-back, over-rating settled, span only, rule 2 muted, settle halved, budget doubled) and: budget never refills, window 5 min, window 30 min, budget per run, a fixed budget of 8, 8 at fast cycles, 10 at 5-10 s, 8 at 15 s, 6 beyond 16 s, an over-rating that uses the budget, a settle that never ends, the no-clock budget refilling, no gap guard on the cycle length, a stop kept as a cycle length.
SAFETY-119 (Trello 6ad2cf29e3188283c038c575). Reports: `reports/SAFETY-119-dev-report.md`, `-dev2-report.md`, `-dev3-report.md` (round 3, round 3b, round 3c).
**Root cause.** When the peak guard (or a maintenance charge) hands the wire back, `cycle()` re-seeds the integrator from that owner's command (main.py:5186-5188) and `compute()` adds `gain * error` (control.py:586) from a meter that does not show that command yet. The ask crosses the rail (control.py:641 `i_clamped`) for a cycle or two. It beats the guard's one-sided floor, so the loop is the winner and SAFETY-85 rule 1 counts it as the loop's own bound: WARNING `OUTPUT_CLAMP` and `goodwe_integrator_bound`. The battery gives what it is told; nothing is short.
**Fix.** After a hand-back, the loop's integrator bound is not counted until the plant's settle has passed (`SHORT_CYCLES` cycles and `SHORT_SPAN_S`, SAFETY-85's window). Across all hand-backs it is hidden on at most a budget of cycles in any 15 minutes (`ROLLING_BUDGET`, `ROLLING_S`); a cycle over the budget counts. The budget follows the measured cycle length: 10 up to 3.5 s, 8 up to 11 s, 6 up to 16 s, 4 beyond; 8 while the cycle length is unknown; without a monotonic clock it never refills. An over-rating always counts on a cycle the loop decided. No command changes, no text changes.
**Round 3 (owner decision 2026-10-20, option a): rolling budget.** Round 1 restarted the window at every hand-back (guard and charge taking turns hid the bound for good). Round 2 gave each run its own budget, which restarts every quarter at 10 s and 15 s meters (0 warnings in 2 h, 14 without the fix).
**Round 3c: why the budget follows the cycle length.** The brief asked for one N from a sweep. No single N met both cases (sweep of 3 to 10, 2 h each, 1 to 30 s meters): 3 to 6 let the quarter-edge false alarm back at 2 s; 7 on 10 of 30 seeds at 2 s; 8 on 1 of 30 at 2 s (seed 25) and warned in only 1 to 3 of 7 quarters at 15 s; 9 and 10 miss quarters at 10 s. Round 3b shipped 8 anyway; that was wrong and is replaced here. A slow cycle needs a small budget to warn, a fast one a large budget to stay quiet, so each cycle-length band takes the largest budget that still warns (details in DECISIONS).
**What holds (measured, 2 h each).**
- Quarter edge (N=1, house 800 W, cap 2500 W, no forecast), 30 seeds per meter: 0 notifications at 1, 2, 3, 4, 5, 10, 12, 15, 20 and 30 s (without the fix: 2 to 11 per run at 1 to 15 s on seeds 1-3 and 25, one 2 s seed silent).
- Repeated hand-backs (N=2, 2 x 2500 W, cheap-window charge, no usable forecast, cap 5000 W, house 5050 and 5100 W, constant and with noise, 8 runs per meter):
| meter | warnings in 2 h | quarters warned (of 7 after the first) | without the fix |
|---|---|---|---|
| 1 s | 56 to 63 | 7 | 56 to 63 |
| 2 s | 49 to 56 | 7 | 54 to 56 |
| 5 s | 19 to 23 | 7 | 28 |
| 10 s | 7 to 10 | 7 | 14 |
| 15 s | 4 to 10 | 4 to 7, never two quiet in a row | 14 |
| 30 s | 3 to 7 | 3 to 5, up to two quiet in a row | 7 to 11 |
- 30 s is outside the brief's meters; no budget does better there (4 leaves two quiet quarters, 3 brings back the false alarm on 14 of 30 seeds).
- Single hand-back, loop at its rail, first warning compared with no fix: 10 s at 1 s, 20 s at 2 s, 40 s at 5 s (default), 80 s at 10 s, 90 s at 15 s, 80 s at 20 s, 120 s at 30 s, 240 s at 60 s. At 60 s the rig is a different regime (house not over the cap in the never case): nothing more is claimed.
- Every command is the same with and without the fix. The cap held at 1 to 10 s in the quarter edge and 1 to 5 s in the never case; slower, the meter's lag puts quarters over it, the same without the fix.
- Limits: these runs have one cycle per meter reading, at a steady interval. A 20 or 30 s cycle needs `cycle_floor_s` raised; at the default 15 s, a slower meter adds cycles on an old reading. Measured that way by the reviewer (11 seeds): a 15 s meter that varies by up to 30 % false-alarmed on 5 seeds, a 30 s meter on 10 (0 to 4 in 2 h, 8 to 15 without the fix); a 20 s meter on none. The repeated hand-back case still warned every quarter there.
**Tests.** `test_safety119.py`: the rig's quarter edge; the quarter edge at 2, 5, 10 and 15 s (seeds 1-3) and at 2 s seed 25, where a fixed budget of 8 warns (pinned); a genuine bound still warns; a usable forecast changes nothing; the never case at 2, 5, 10 (every quarter) and 15 s (at least every other quarter); a blind spell is not a hand-back; the rolling budget at its boundaries (budget per cycle length, refill at 900 s, not per run, a gap or a stop is not a cycle length, no clock, over-rating does not use it); test_safety84/87 digests differ only in OUTPUT_CLAMP log rows.
**Mutations** (purged copies, each caught by `test_safety119.py` except R5, which `test_safety85.py` catches): the reviewer's R1-R7 re-aimed (bound hidden for good, a stop arms a hand-back, over-rating settled, span only, rule 2 muted, settle halved, budget doubled) and: budget never refills, window 5 min, window 30 min, budget per run, a fixed budget of 8, 8 at fast cycles, 10 at 5-10 s, 8 at 15 s, 6 beyond 16 s, an over-rating that uses the budget, a settle that never ends, the no-clock budget refilling, no gap guard on the cycle length, a stop kept as a cycle length.
Also: `test_safety87.py:543` shortened (round 2).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
When the peak guard or a maintenance charge hands the wire back, the loop's
integrator is re-seeded from that owner's command and kicked by a meter that
does not show it yet. It crossed its rail for a cycle or two and raised
goodwe_integrator_bound while the battery gave what it was told.
The loop's integrator bound is not counted until the plant's settle
(SHORT_CYCLES, SHORT_SPAN_S) has passed since the hand-back. An over-rating
always counts; a bound that lasts warns after the settle. No command
changes and no text changes. test_safety119.py; DECISIONS entry.
Co-Authored-By: Claude <noreply@anthropic.com>
The re-pinned digests differ only in OUTPUT_CLAMP log rows; every command
and claim is the same as without the fix (test_safety119.py part 5).
DECISIONS: the delay at a 5 s meter and the open swap case.
Co-Authored-By: Claude <noreply@anthropic.com>
The delay is 11 cycles, so up to 11 min at a 60 s cycle. A loop that loses
the wire again within 11 cycles is never counted; review measured one such
case (N=2, cheap-window charge, no forecast, house just over the rating).
The over-rating rule applies on cycles the loop decided. Text only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Verdict: ready to merge at b9ff6ac, once the owner accepts the trade-off below. Not merged. CI is green on b9ff6ac: unit run 1344 (80/80) and infra-pin run 1345.
Head reviewed:b0c2e59. Since then I pushed two commits, 98026f9 and b9ff6ac. Both change only the text of the DECISIONS entry. See F2.
What I checked
Root cause: confirmed. All three rig events match, each 2-3 s after a guard step-in, at a quarter's end, at the +2500 W limit:
h 20:14:57 and 20:44:59;
be 20:29:59.
No command change: confirmed against the real base, 75f0804, with no monkeypatch.
In the 12 s84/s87 scenarios every command and every claim is identical.
All 218 log rows that differ are OUTPUT_CLAMP rows.
Mutations: 12 tried. 6 of them are the dev's, re-run. All 12 are caught by the full suite. The one test_safety119.py misses is R5 (the window also mutes SAFETY-85 rule 2). test_safety85.py catches it.
The delay. After another owner hands the battery back, a real "Battery is not keeping up" now comes 11 meter cycles later. That is:
22 s at a 2 s meter;
55 s at the default 5 s;
about 2 min at 10 s;
up to 11 min at the slowest setting (60 s).
The case where it never comes. If the guard and a maintenance charge keep taking the battery back within those 11 cycles, the loop's bound is never counted.
Measured: 2 inverters (2 x 2500 W), 5 s meter, cheap-window charge, no usable forecast, house 5050-5100 W, so 50-100 W over the 5000 W rating. 0 notifications in 2 h, against 28 without this PR. The cap held (4950-4984 W). The battery did what it was told.
Bigger houses still warn: from 200 W over, at least once a quarter. At 7.4 or 11 kW, as before plus 55 s.
Other alarms are not touched. A command over the rating on a cycle the loop decided always counts. The write-path clamp always counts. A battery that gives less than another owner asked still alarms (SAFETY-85 rule 2).
Findings
F1 (medium, owner decision) goodwe_controller/app/main.py:5863-5865.
Problem: every cycle the loop does not own restarts the window, which causes the never case above.
Option if the owner does not accept it: allow at most one window per stretch of hand-backs. Arm a new window only if the last one began well over SHORT_SPAN_S ago. Or count the owned cycles since the first hand-back.
Effect: the quarter-edge kick, about every 15 min, stays suppressed.
Cost: a dev round. Not pushed.
F2 (low, text, fixed in 98026f9 and b9ff6ac) goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md:20-35.
Was: "Delay, not loss", and "a swap needs a claim that keeps outranking it". Neither was true.
Now: the 11-cycle delay, up to 11 min. The measured never case. "Always counts" is limited to cycles the loop decided.
F3 (low, info) goodwe_controller/app/main.py:5873-5874. The delay is cycle-based, so it grows with the meter period. The PR body and the card give only 22 s and 55 s.
F4 (info). When the guard and the charge take turns and the loop is truly bound, re-notifications drop from every 30-150 s to about once a quarter. Example: N=1, house 2550 W, 5 s meter: 1 in 2 h, against 102.
F5 (info, tests). Some mutations are caught only by unit-level checks, not by a behavioural one: R2 (a stop counts as a hand-back) and R4 (the cycle count dropped).
F6 (nit) goodwe_controller/test_safety87.py:543. The check-name line is 110 characters long.
Full report: reports/SAFETY-119-review-report.md.
## Review of SAFETY-119
**Verdict: ready to merge at b9ff6ac**, once the owner accepts the trade-off below. **Not merged.** CI is green on b9ff6ac: unit run 1344 (80/80) and infra-pin run 1345.
**Head reviewed:** b0c2e59. Since then I pushed two commits, 98026f9 and b9ff6ac. Both change only the text of the DECISIONS entry. See F2.
### What I checked
- **Root cause:** confirmed. All three rig events match, each 2-3 s after a guard step-in, at a quarter's end, at the +2500 W limit:
- h 20:14:57 and 20:44:59;
- be 20:29:59.
- **No command change:** confirmed against the real base, 75f0804, with no monkeypatch.
- In the 12 s84/s87 scenarios every command and every claim is identical.
- All 218 log rows that differ are OUTPUT_CLAMP rows.
- **Mutations:** 12 tried. 6 of them are the dev's, re-run. All 12 are caught by the full suite. The one `test_safety119.py` misses is R5 (the window also mutes SAFETY-85 rule 2). `test_safety85.py` catches it.
- **Offline suite:** 80/80 on b0c2e59, with the hwsim.
- **Text:** no user text changed.
### Check 2 in plain words, for the owner
- **The delay.** After another owner hands the battery back, a real "Battery is not keeping up" now comes 11 meter cycles later. That is:
- 22 s at a 2 s meter;
- **55 s at the default 5 s**;
- about 2 min at 10 s;
- **up to 11 min at the slowest setting (60 s).**
- **The case where it never comes.** If the guard and a maintenance charge keep taking the battery back within those 11 cycles, the loop's bound is never counted.
- **Measured:** 2 inverters (2 x 2500 W), 5 s meter, cheap-window charge, no usable forecast, house 5050-5100 W, so 50-100 W over the 5000 W rating. **0 notifications in 2 h, against 28 without this PR.** The cap held (4950-4984 W). The battery did what it was told.
- **Bigger houses still warn:** from 200 W over, at least once a quarter. At 7.4 or 11 kW, as before plus 55 s.
- **Other alarms are not touched.** A command over the rating on a cycle the loop decided always counts. The write-path clamp always counts. A battery that gives less than another owner asked still alarms (SAFETY-85 rule 2).
### Findings
- **F1 (medium, owner decision) `goodwe_controller/app/main.py:5863-5865`.**
- **Problem:** every cycle the loop does not own restarts the window, which causes the never case above.
- **Option if the owner does not accept it:** allow at most one window per stretch of hand-backs. Arm a new window only if the last one began well over `SHORT_SPAN_S` ago. Or count the owned cycles since the first hand-back.
- **Effect:** the quarter-edge kick, about every 15 min, stays suppressed.
- **Cost:** a dev round. Not pushed.
- **F2 (low, text, fixed in 98026f9 and b9ff6ac) `goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md:20-35`.**
- **Was:** "Delay, not loss", and "a swap needs a claim that keeps outranking it". Neither was true.
- **Now:** the 11-cycle delay, up to 11 min. The measured never case. "Always counts" is limited to cycles the loop decided.
- **F3 (low, info) `goodwe_controller/app/main.py:5873-5874`.** The delay is cycle-based, so it grows with the meter period. The PR body and the card give only 22 s and 55 s.
- **F4 (info).** When the guard and the charge take turns and the loop is truly bound, re-notifications drop from every 30-150 s to about once a quarter. Example: N=1, house 2550 W, 5 s meter: 1 in 2 h, against 102.
- **F5 (info, tests).** Some mutations are caught only by unit-level checks, not by a behavioural one: R2 (a stop counts as a hand-back) and R4 (the cycle count dropped).
- **F6 (nit) `goodwe_controller/test_safety87.py:543`.** The check-name line is 110 characters long.
Full report: `reports/SAFETY-119-review-report.md`.
A hand-back used to restart the window, so a guard and a charge taking
turns hid the loop's integrator bound for good (0 warnings in 2 h; 28
before the fix). A run of hand-backs (the wire away less than a settle
between them) now hides the bound for at most 11 bound cycles and 22 s in
all, then it counts. The quarter-edge kick stays hidden. No command or text
changes. Tests for the two reviewer mutants that only unit checks caught
(a restart counted as a hand-back; span only), DECISIONS gives the delay at
2 s, 5 s and 60 s. test_safety87.py:543 shortened.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Verdict: needs changes. Not merged. The owner asked for at least one warning per 15-minute period. At a 10 s or 15 s meter cycle, the "never" case is back.
What holds
The never case at the default 5 s meter. N=2, house 5050 W: 14 notifications in 2 h, two in every quarter after the first. At 5100 W: 21. Without the fix: 28. Round 1: 0.
The quarter-edge case stays silent. N=1, house 800 W, three seeds, at 1 s, 5 s and 10 s meters: 0 notifications, cap held (highest quarter 2293-2444 W). Without the fix it is 2-10 per seed. At 5 s and 10 s the false alarm comes every quarter, so the fix matters more there than at 1 s.
Commands are identical to 75f0804 in all 12 s84/s87 scenarios. I checked this across trees, with no monkeypatch. The log rows are identical to round 1, so the pinned digests are unchanged. test_safety87.py:543 is now wrapped.
Mutations: R1-R6 re-aimed at the new code, the dev's N1, N5 and N6, and my R7 (the budget doubled). All are caught by test_safety119.py, except R5. R5 is caught by test_safety85.py, as before.
Suite and CI: offline suite 80/80 on 85542e4, with the hwsim. CI is green: run 1348 (80 PASS) and run 1349.
Finding (high for the owner's requirement) goodwe_controller/app/main.py:5866-5902 (_settling)
What breaks: the hidden budget is per run of hand-backs, and a run ends after one settle with the wire away. That is 12 cycles, so 120 s at a 10 s meter. The guard and the charge leave the loop alone for that long between quarter edges, so each quarter starts a fresh run. Each run sees only about 8 bound cycles, fewer than the 11 it may hide.
Measured on the never case, N=2, house 5050 W, 2 h:
meter
with the fix
without the fix
5 s
14
28
10 s
0
14
15 s
0
14
The DECISIONS table and the PR body say 1320 s at 10 s and "a bound that stays warns after at most 11 of its cycles, however often the wire changes hands". That holds within one run only.
Is 10-15 s real?
Yes. A cycle is one meter update, or cycle_floor_s if that comes first (default 15 s).
A sensor that updates every 10 s gives 10 s cycles. DOCS expects 5-10 s.
A meter that repeats its value runs at the 15 s floor.
60 s is not typical. It needs an installer to raise cycle_floor_s to 60, which must stay under meter_max_age_s (default 30), on a meter that slow.
Can it be bounded without bringing back the quarter-edge alarm?
I tried three variants on top of the dev's rule:
Time cap since the first hidden bound cycle of a run, 60 s or 120 s. This brings the quarter-edge false alarm back at 5 s and 10 s (1-8 per seed in 2 h).
Time cap of 300 s or 600 s. Quarter edge stays 0, but the never case at 10 s and 15 s stays 0 too. A run never lasts that long.
A rolling budget: at most 6 hidden bound cycles per 15 min, across runs. Quarter edge: 0 at 1, 5 and 10 s, all three seeds. Never case: every quarter at 5 s (23) and 10 s (9), every other quarter at 15 s (4).
At a budget of 3, the quarter-edge alarm came back at 5 s and 10 s.
So the rolling budget is a candidate with a narrow margin. It is not proven.
Plain answer: it can be bounded up to about a 10 s meter, but not shown safely above that. At 15 s and slower, the never case and the quarter-edge kick look alike to a counter: a few bound cycles in short turns at a quarter edge. The owner should decide one of these:
(a) take a rolling budget like the one above, accepting "at least every other quarter" at 15 s;
(b) accept no warning at 10-15 s in this narrow case (house just over the rating, guard and charge taking turns, cap held);
(c) ask for a different signal for slow meters.
Scripts and logs: scratch/SAFETY-119/review/exp/e2.py, exp/r2-*.log, mut2/, suite-85542e4.log. Full report: reports/SAFETY-119-review-report.md, round 2.
## Review round 2 of SAFETY-119, head 85542e4
**Verdict: needs changes. Not merged.** The owner asked for at least one warning per 15-minute period. At a 10 s or 15 s meter cycle, the "never" case is back.
### What holds
- **The never case at the default 5 s meter.** N=2, house 5050 W: 14 notifications in 2 h, two in every quarter after the first. At 5100 W: 21. Without the fix: 28. Round 1: 0.
- **The quarter-edge case stays silent.** N=1, house 800 W, three seeds, at 1 s, 5 s and 10 s meters: 0 notifications, cap held (highest quarter 2293-2444 W). Without the fix it is 2-10 per seed. At 5 s and 10 s the false alarm comes every quarter, so the fix matters more there than at 1 s.
- **Commands are identical** to 75f0804 in all 12 s84/s87 scenarios. I checked this across trees, with no monkeypatch. The log rows are identical to round 1, so the pinned digests are unchanged. `test_safety87.py:543` is now wrapped.
- **Mutations:** R1-R6 re-aimed at the new code, the dev's N1, N5 and N6, and my R7 (the budget doubled). All are caught by `test_safety119.py`, except R5. R5 is caught by `test_safety85.py`, as before.
- **Suite and CI:** offline suite 80/80 on 85542e4, with the hwsim. CI is green: run 1348 (80 PASS) and run 1349.
### Finding (high for the owner's requirement) `goodwe_controller/app/main.py:5866-5902` (`_settling`)
**What breaks:** the hidden budget is per run of hand-backs, and a run ends after one settle with the wire away. That is 12 cycles, so 120 s at a 10 s meter. The guard and the charge leave the loop alone for that long between quarter edges, so each quarter starts a fresh run. Each run sees only about 8 bound cycles, fewer than the 11 it may hide.
**Measured on the never case, N=2, house 5050 W, 2 h:**
| meter | with the fix | without the fix |
|---|---|---|
| 5 s | 14 | 28 |
| **10 s** | **0** | 14 |
| **15 s** | **0** | 14 |
The DECISIONS table and the PR body say 1320 s at 10 s and "a bound that stays warns after at most 11 of its cycles, however often the wire changes hands". That holds within one run only.
### Is 10-15 s real?
**Yes.** A cycle is one meter update, or `cycle_floor_s` if that comes first (default 15 s).
- A sensor that updates every 10 s gives 10 s cycles. DOCS expects 5-10 s.
- A meter that repeats its value runs at the 15 s floor.
**60 s is not typical.** It needs an installer to raise `cycle_floor_s` to 60, which must stay under `meter_max_age_s` (default 30), on a meter that slow.
### Can it be bounded without bringing back the quarter-edge alarm?
I tried three variants on top of the dev's rule:
- **Time cap since the first hidden bound cycle of a run, 60 s or 120 s.** This brings the quarter-edge false alarm back at 5 s and 10 s (1-8 per seed in 2 h).
- **Time cap of 300 s or 600 s.** Quarter edge stays 0, but the never case at 10 s and 15 s stays 0 too. A run never lasts that long.
- **A rolling budget: at most 6 hidden bound cycles per 15 min, across runs.** Quarter edge: 0 at 1, 5 and 10 s, all three seeds. Never case: every quarter at 5 s (23) and 10 s (9), every other quarter at 15 s (4).
- At a budget of 3, the quarter-edge alarm came back at 5 s and 10 s.
- So the rolling budget is a candidate with a narrow margin. It is not proven.
**Plain answer:** it can be bounded up to about a 10 s meter, but not shown safely above that. At 15 s and slower, the never case and the quarter-edge kick look alike to a counter: a few bound cycles in short turns at a quarter edge. The owner should decide one of these:
- (a) take a rolling budget like the one above, accepting "at least every other quarter" at 15 s;
- (b) accept no warning at 10-15 s in this narrow case (house just over the rating, guard and charge taking turns, cap held);
- (c) ask for a different signal for slow meters.
Scripts and logs: `scratch/SAFETY-119/review/exp/e2.py`, `exp/r2-*.log`, `mut2/`, `suite-85542e4.log`. Full report: `reports/SAFETY-119-review-report.md`, round 2.
At most 8 hidden bound cycles in any 900 s across all hand-backs, replacing
the per-run budget. Chosen from a sweep (N 3 to 10, 1 to 15 s meters, both
cases); DECISIONS has the table. Tests: never case at 5, 10, 15 s, quarter
edge at 2, 10, 15 s, the rolling budget at its boundaries.
Co-Authored-By: Claude <noreply@anthropic.com>
The rolling budget now holds without a monotonic clock (it never refills).
Quarter-edge tests use seeds 1-3 at 2, 5, 10 and 15 s (no hand-picked seeds).
Re-pin comments point at part 6. DECISIONS lists the limits the owner
accepted with N=8 on 2026-10-20.
Co-Authored-By: Claude <noreply@anthropic.com>
No single rolling budget met both cases in the sweep (8 false-alarmed on
2 s seed 25 and warned in 1 to 3 of 7 quarters at 15 s). The budget is
now 10 cycles up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond,
per rolling 15 minutes; 8 while the cycle length is unknown. A stop or a
gap over two minutes is not a cycle length. The unsourced "owner
accepted N=8" paragraph is removed from DECISIONS. test_safety119 part 4
now runs 2 s seed 25 and shows a fixed budget of 8 warns there; the
never case runs at 2 s and checks every other quarter at 15 s.
Co-Authored-By: Claude <noreply@anthropic.com>
The quarter-edge numbers were measured with one cycle per reading at a
steady interval. With the default 15 s cycle floor, a meter that is
sometimes slower adds cycles on an old reading and the false alarm can
come back (reviewer's runs, 11 seeds). Text only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 3 of SAFETY-119, head 6fd0fa0 (then 96375ec)
Verdict: right. Merging at 96375ec (the dev's 6fd0fa0 plus my text-only commit). The owner accepted the rule-shape change in principle, the evidence holds on the pushed code, and the safety direction is sound. Two follow-ups are below (F2, F3); neither blocks the merge.
I pushed one text-only commit, 96375ec. It adds a "Limits" paragraph to DECISIONS and a matching line to the PR body (finding F1). CI on 96375ec: checks / unit success, run 1358 job 2508 (80/80, hwsim line, result 96375ec); infra-pin / fresh success, run 1359.
Check 1 in plain words (for the owner)
Is there a way a real overload never warns? Not in any setup I could build that a real site can have.
With 1-10 s meters, the "guard and charge take turns" case warns every quarter.
With 15 s meters, it warns at least every other quarter.
With the 30 s cycle (only possible if cycle_floor_s is raised), there can be up to two quiet quarters.
The worst first warning I measured came 26 min after the house went over the rating (one jittery 12 s run). Without the fix, the worst was 12 min.
Where never is possible in principle: a bound that shows on no more cycles than the budget in any 15 minutes, all right after hand-backs. That case looks exactly like the quarter-edge kick, so by design it stays hidden.
A single hand-back delays the warning by the budget in cycles: 10 s at 1 s, 40 s at 5 s (the default), 80 s at 10 s, 90 s at 15 s, 240 s at 60 s.
Can the cycle-length estimate be wrong in the unsafe direction? Only if cycles look shorter than they are. If I force the 10-cycle budget at 15 s or 30 s, it never warns (0-1 in 2 h). A real install cannot get there: a cycle needs a new meter reading or the floor, and one meter entity cannot make cycles faster than it updates. Jitter, a slow Home Assistant, a stop, or a meter interval that changes at runtime all measured fine, or make the budget smaller.
Over the rating: a command over the rating still always counts, on every cycle the loop decided, and it does not use the budget. The write-path clamp is untouched.
Check 1 table
case
real site?
worst delay measured
never warns?
single hand-back, loop at its rail
yes
10 s (1 s), 20 s (2 s), 40 s (5 s), 80 s (10 s), 90 s (15 s), 120 s (30 s), 240 s (60 s)
no
guard and charge take turns, house 50-100 W over the rating (steady 2-10 s)
yes
first warning ≤ 820 s after the house goes up (without the fix 650 s), then every quarter
no
the same, 15 s steady or ±10-30 %
yes
≤ 885 s, then at least every other quarter
no
the same, jittery 12 s / 20 s
yes
1552 s / 1556 s (one quiet quarter)
no
the same, 30 s cycle (cycle_floor_s raised)
non-default
up to two quiet quarters
no
the same, 20-30 s meter at the default 15 s floor
yes
≤ 771 s, every quarter
no
cycle length read too short at a slow meter (forced budget 10)
not reachable with one meter entity
—
yes (0-1 in 2 h)
gap over 120 s keeps the last length
only if an HA read hangs > 2 min; P1 goes to "inputs missing" first
in theory a fast budget at a slow cycle
not measured
stop / restart
yes
a stop keeps the budget used (correct)
no
unknown length (8), no clock (no refill)
first cycle only / test-only
smaller or equal budget
no
over the rating
—
counts at once, no budget
no
Findings
F1 (medium, text, fixed in 96375ec)DECISIONS/SAFETY-119-bound-after-hand-back.md:50-51 and the PR body. "0 notifications at 1-30 s" was measured with one cycle per reading at a steady interval.
A 20 or 30 s cycle needs cycle_floor_s raised.
At the default 15 s floor, a meter that is sometimes slower adds cycles on an old reading. Then the quarter-edge false alarm comes back on some seeds:
a 15 s meter varying by ±30 %: 5 of 11 seeds;
a 30 s meter: 10 of 11 seeds;
with the fix it is 0-4 in 2 h, against 8-15 without.
The repeated hand-back case still warns every quarter there.
This does not affect safety. It is a limit on the "no false alarm" claim.
F2 (low, tests)test_safety119.py part 5 (code: app/main.py:5829-5833, 5900-5910). Two of my mutations survive:
X3, a stop resets the used budget;
X4, the shortest cycle length ever seen is kept.
The code is right today. No check pins either property, and X3 is on the safety side. Follow-up: a part 5 check for each.
F3 (info)app/main.py:5884-5886 (_settling). A gap over 120 s keeps the last measured length instead of falling back to "unknown" (8). It is not reachable with cycle_floor_s ≤ 60 unless reads hang.
Other checks
Evidence (check 2): reproduced on the pushed code (533 + 117 runs, 2 h each).
Quarter edge at 1, 2, 5, 10, 15 and 30 s, seeds 1-10 and 25: 0 false alarms.
2 s seed 25: 0, and a fixed budget of 8 warns at 1806 s.
Never case at 2, 5 and 10 s: every quarter.
Never case at 15 s: 4-7 of 7 quarters, never two quiet in a row.
Never case at 30 s: 3-5 of 7 quarters, up to two quiet in a row.
Delay table: exact.
No command change (check 3): commands are identical with and without the fix in 258 run pairs. The 12 s84/s87 scenarios are identical across trees (75f0804 vs 6fd0fa0). Only 218 OUTPUT_CLAMP log rows differ, the same rows as round 2, so the pins are unchanged.
Mutations (check 4): the dev's M3, M4, M6, M12 and M13 are all caught. My X1 (band one up: hides more) and X2 (cycle length from meter_poll_s) are caught. X3 and X4 survive (F2). The test seeds are the brief's 1-3, plus seed 25, which is kept to show the old rule failing. Nothing is cherry-picked.
Text (check 5): no user text changed. DECISIONS and the PR body state the rule-shape change ("why not one number") and the 30 s limit.
Suite (check 6): 80/80 PASS, exit 0, on git archive 6fd0fa0 in goodwe-addon-checks:1 (--network none, read-only mount, GOODWE_REQUIRE_HWSIM=1, admin/goodwe at the pin 3b4ff84; the hwsim line is there). 96375ec changes only DECISIONS, and CI run 1358 covers it.
## Review round 3 of SAFETY-119, head 6fd0fa0 (then 96375ec)
**Verdict: right. Merging at 96375ec** (the dev's 6fd0fa0 plus my text-only commit). The owner accepted the rule-shape change in principle, the evidence holds on the pushed code, and the safety direction is sound. Two follow-ups are below (F2, F3); neither blocks the merge.
I pushed one text-only commit, **96375ec**. It adds a "Limits" paragraph to DECISIONS and a matching line to the PR body (finding F1). CI on 96375ec: `checks / unit` success, run 1358 job 2508 (80/80, hwsim line, result 96375ec); `infra-pin / fresh` success, run 1359.
### Check 1 in plain words (for the owner)
- **Is there a way a real overload never warns?** Not in any setup I could build that a real site can have.
- With 1-10 s meters, the "guard and charge take turns" case warns every quarter.
- With 15 s meters, it warns at least every other quarter.
- With the 30 s cycle (only possible if `cycle_floor_s` is raised), there can be up to two quiet quarters.
- The worst first warning I measured came 26 min after the house went over the rating (one jittery 12 s run). Without the fix, the worst was 12 min.
- **Where never is possible in principle:** a bound that shows on no more cycles than the budget in any 15 minutes, all right after hand-backs. That case looks exactly like the quarter-edge kick, so by design it stays hidden.
- **A single hand-back** delays the warning by the budget in cycles: 10 s at 1 s, 40 s at 5 s (the default), 80 s at 10 s, 90 s at 15 s, 240 s at 60 s.
- **Can the cycle-length estimate be wrong in the unsafe direction?** Only if cycles look shorter than they are. If I force the 10-cycle budget at 15 s or 30 s, it never warns (0-1 in 2 h). A real install cannot get there: a cycle needs a new meter reading or the floor, and one meter entity cannot make cycles faster than it updates. Jitter, a slow Home Assistant, a stop, or a meter interval that changes at runtime all measured fine, or make the budget smaller.
- **Over the rating:** a command over the rating still always counts, on every cycle the loop decided, and it does not use the budget. The write-path clamp is untouched.
### Check 1 table
| case | real site? | worst delay measured | never warns? |
|---|---|---|---|
| single hand-back, loop at its rail | yes | 10 s (1 s), 20 s (2 s), 40 s (5 s), 80 s (10 s), 90 s (15 s), 120 s (30 s), 240 s (60 s) | no |
| guard and charge take turns, house 50-100 W over the rating (steady 2-10 s) | yes | first warning ≤ 820 s after the house goes up (without the fix 650 s), then every quarter | no |
| the same, 15 s steady or ±10-30 % | yes | ≤ 885 s, then at least every other quarter | no |
| the same, jittery 12 s / 20 s | yes | 1552 s / 1556 s (one quiet quarter) | no |
| the same, 30 s cycle (`cycle_floor_s` raised) | non-default | up to two quiet quarters | no |
| the same, 20-30 s meter at the default 15 s floor | yes | ≤ 771 s, every quarter | no |
| cycle length read too short at a slow meter (forced budget 10) | not reachable with one meter entity | — | **yes** (0-1 in 2 h) |
| gap over 120 s keeps the last length | only if an HA read hangs > 2 min; P1 goes to "inputs missing" first | in theory a fast budget at a slow cycle | not measured |
| stop / restart | yes | a stop keeps the budget used (correct) | no |
| unknown length (8), no clock (no refill) | first cycle only / test-only | smaller or equal budget | no |
| over the rating | — | counts at once, no budget | no |
### Findings
- **F1 (medium, text, fixed in 96375ec)** `DECISIONS/SAFETY-119-bound-after-hand-back.md:50-51` and the PR body. "0 notifications at 1-30 s" was measured with one cycle per reading at a steady interval.
- A 20 or 30 s cycle needs `cycle_floor_s` raised.
- At the default 15 s floor, a meter that is sometimes slower adds cycles on an old reading. Then the quarter-edge false alarm comes back on some seeds:
- a 15 s meter varying by ±30 %: 5 of 11 seeds;
- a 30 s meter: 10 of 11 seeds;
- with the fix it is 0-4 in 2 h, against 8-15 without.
- The repeated hand-back case still warns every quarter there.
- This does not affect safety. It is a limit on the "no false alarm" claim.
- **F2 (low, tests)** `test_safety119.py` part 5 (code: `app/main.py:5829-5833`, 5900-5910). Two of my mutations survive:
- **X3**, a stop resets the used budget;
- **X4**, the shortest cycle length ever seen is kept.
- The code is right today. No check pins either property, and X3 is on the safety side. Follow-up: a part 5 check for each.
- **F3 (info)** `app/main.py:5884-5886` (`_settling`). A gap over 120 s keeps the last measured length instead of falling back to "unknown" (8). It is not reachable with `cycle_floor_s` ≤ 60 unless reads hang.
### Other checks
- **Evidence (check 2):** reproduced on the pushed code (533 + 117 runs, 2 h each).
- Quarter edge at 1, 2, 5, 10, 15 and 30 s, seeds 1-10 and 25: 0 false alarms.
- 2 s seed 25: 0, and a fixed budget of 8 warns at 1806 s.
- Never case at 2, 5 and 10 s: every quarter.
- Never case at 15 s: 4-7 of 7 quarters, never two quiet in a row.
- Never case at 30 s: 3-5 of 7 quarters, up to two quiet in a row.
- Delay table: exact.
- **No command change (check 3):** commands are identical with and without the fix in 258 run pairs. The 12 s84/s87 scenarios are identical across trees (75f0804 vs 6fd0fa0). Only 218 OUTPUT_CLAMP log rows differ, the same rows as round 2, so the pins are unchanged.
- **Mutations (check 4):** the dev's M3, M4, M6, M12 and M13 are all caught. My X1 (band one up: hides more) and X2 (cycle length from `meter_poll_s`) are caught. X3 and X4 survive (F2). The test seeds are the brief's 1-3, plus seed 25, which is kept to show the old rule failing. Nothing is cherry-picked.
- **Text (check 5):** no user text changed. DECISIONS and the PR body state the rule-shape change ("why not one number") and the 30 s limit.
- **Suite (check 6):** 80/80 PASS, exit 0, on `git archive 6fd0fa0` in `goodwe-addon-checks:1` (`--network none`, read-only mount, `GOODWE_REQUIRE_HWSIM=1`, admin/goodwe at the pin 3b4ff84; the hwsim line is there). 96375ec changes only DECISIONS, and CI run 1358 covers it.
admin
merged commit bee7431793 into release/1.02026-10-21 03:32:39 +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-119 (Trello 6ad2cf29e3188283c038c575). Reports:
reports/SAFETY-119-dev-report.md,-dev2-report.md,-dev3-report.md(round 3, round 3b, round 3c).Root cause. When the peak guard (or a maintenance charge) hands the wire back,
cycle()re-seeds the integrator from that owner's command (main.py:5186-5188) andcompute()addsgain * error(control.py:586) from a meter that does not show that command yet. The ask crosses the rail (control.py:641i_clamped) for a cycle or two. It beats the guard's one-sided floor, so the loop is the winner and SAFETY-85 rule 1 counts it as the loop's own bound: WARNINGOUTPUT_CLAMPandgoodwe_integrator_bound. The battery gives what it is told; nothing is short.Fix. After a hand-back, the loop's integrator bound is not counted until the plant's settle has passed (
SHORT_CYCLEScycles andSHORT_SPAN_S, SAFETY-85's window). Across all hand-backs it is hidden on at most a budget of cycles in any 15 minutes (ROLLING_BUDGET,ROLLING_S); a cycle over the budget counts. The budget follows the measured cycle length: 10 up to 3.5 s, 8 up to 11 s, 6 up to 16 s, 4 beyond; 8 while the cycle length is unknown; without a monotonic clock it never refills. An over-rating always counts on a cycle the loop decided. No command changes, no text changes.Round 3 (owner decision 2026-10-20, option a): rolling budget. Round 1 restarted the window at every hand-back (guard and charge taking turns hid the bound for good). Round 2 gave each run its own budget, which restarts every quarter at 10 s and 15 s meters (0 warnings in 2 h, 14 without the fix).
Round 3c: why the budget follows the cycle length. The brief asked for one N from a sweep. No single N met both cases (sweep of 3 to 10, 2 h each, 1 to 30 s meters): 3 to 6 let the quarter-edge false alarm back at 2 s; 7 on 10 of 30 seeds at 2 s; 8 on 1 of 30 at 2 s (seed 25) and warned in only 1 to 3 of 7 quarters at 15 s; 9 and 10 miss quarters at 10 s. Round 3b shipped 8 anyway; that was wrong and is replaced here. A slow cycle needs a small budget to warn, a fast one a large budget to stay quiet, so each cycle-length band takes the largest budget that still warns (details in DECISIONS).
What holds (measured, 2 h each).
cycle_floor_sraised; at the default 15 s, a slower meter adds cycles on an old reading. Measured that way by the reviewer (11 seeds): a 15 s meter that varies by up to 30 % false-alarmed on 5 seeds, a 30 s meter on 10 (0 to 4 in 2 h, 8 to 15 without the fix); a 20 s meter on none. The repeated hand-back case still warned every quarter there.Tests.
test_safety119.py: the rig's quarter edge; the quarter edge at 2, 5, 10 and 15 s (seeds 1-3) and at 2 s seed 25, where a fixed budget of 8 warns (pinned); a genuine bound still warns; a usable forecast changes nothing; the never case at 2, 5, 10 (every quarter) and 15 s (at least every other quarter); a blind spell is not a hand-back; the rolling budget at its boundaries (budget per cycle length, refill at 900 s, not per run, a gap or a stop is not a cycle length, no clock, over-rating does not use it); test_safety84/87 digests differ only in OUTPUT_CLAMP log rows.Mutations (purged copies, each caught by
test_safety119.pyexcept R5, whichtest_safety85.pycatches): the reviewer's R1-R7 re-aimed (bound hidden for good, a stop arms a hand-back, over-rating settled, span only, rule 2 muted, settle halved, budget doubled) and: budget never refills, window 5 min, window 30 min, budget per run, a fixed budget of 8, 8 at fast cycles, 10 at 5-10 s, 8 at 15 s, 6 beyond 16 s, an over-rating that uses the budget, a settle that never ends, the no-clock budget refilling, no gap guard on the cycle length, a stop kept as a cycle length.Also:
test_safety87.py:543shortened (round 2).🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Review of SAFETY-119
Verdict: ready to merge at
b9ff6ac, once the owner accepts the trade-off below. Not merged. CI is green onb9ff6ac: unit run 1344 (80/80) and infra-pin run 1345.Head reviewed:
b0c2e59. Since then I pushed two commits,98026f9andb9ff6ac. Both change only the text of the DECISIONS entry. See F2.What I checked
75f0804, with no monkeypatch.test_safety119.pymisses is R5 (the window also mutes SAFETY-85 rule 2).test_safety85.pycatches it.b0c2e59, with the hwsim.Check 2 in plain words, for the owner
Findings
goodwe_controller/app/main.py:5863-5865.SHORT_SPAN_Sago. Or count the owned cycles since the first hand-back.98026f9andb9ff6ac)goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md:20-35.goodwe_controller/app/main.py:5873-5874. The delay is cycle-based, so it grows with the meter period. The PR body and the card give only 22 s and 55 s.goodwe_controller/test_safety87.py:543. The check-name line is 110 characters long.Full report:
reports/SAFETY-119-review-report.md.Review round 2 of SAFETY-119, head
85542e4Verdict: needs changes. Not merged. The owner asked for at least one warning per 15-minute period. At a 10 s or 15 s meter cycle, the "never" case is back.
What holds
75f0804in all 12 s84/s87 scenarios. I checked this across trees, with no monkeypatch. The log rows are identical to round 1, so the pinned digests are unchanged.test_safety87.py:543is now wrapped.test_safety119.py, except R5. R5 is caught bytest_safety85.py, as before.85542e4, with the hwsim. CI is green: run 1348 (80 PASS) and run 1349.Finding (high for the owner's requirement)
goodwe_controller/app/main.py:5866-5902(_settling)What breaks: the hidden budget is per run of hand-backs, and a run ends after one settle with the wire away. That is 12 cycles, so 120 s at a 10 s meter. The guard and the charge leave the loop alone for that long between quarter edges, so each quarter starts a fresh run. Each run sees only about 8 bound cycles, fewer than the 11 it may hide.
Measured on the never case, N=2, house 5050 W, 2 h:
The DECISIONS table and the PR body say 1320 s at 10 s and "a bound that stays warns after at most 11 of its cycles, however often the wire changes hands". That holds within one run only.
Is 10-15 s real?
Yes. A cycle is one meter update, or
cycle_floor_sif that comes first (default 15 s).60 s is not typical. It needs an installer to raise
cycle_floor_sto 60, which must stay undermeter_max_age_s(default 30), on a meter that slow.Can it be bounded without bringing back the quarter-edge alarm?
I tried three variants on top of the dev's rule:
Plain answer: it can be bounded up to about a 10 s meter, but not shown safely above that. At 15 s and slower, the never case and the quarter-edge kick look alike to a counter: a few bound cycles in short turns at a quarter edge. The owner should decide one of these:
Scripts and logs:
scratch/SAFETY-119/review/exp/e2.py,exp/r2-*.log,mut2/,suite-85542e4.log. Full report:reports/SAFETY-119-review-report.md, round 2.Review round 3 of SAFETY-119, head
6fd0fa0(then96375ec)Verdict: right. Merging at
96375ec(the dev's6fd0fa0plus my text-only commit). The owner accepted the rule-shape change in principle, the evidence holds on the pushed code, and the safety direction is sound. Two follow-ups are below (F2, F3); neither blocks the merge.I pushed one text-only commit,
96375ec. It adds a "Limits" paragraph to DECISIONS and a matching line to the PR body (finding F1). CI on96375ec:checks / unitsuccess, run 1358 job 2508 (80/80, hwsim line, result96375ec);infra-pin / freshsuccess, run 1359.Check 1 in plain words (for the owner)
cycle_floor_sis raised), there can be up to two quiet quarters.Check 1 table
cycle_floor_sraised)Findings
96375ec)DECISIONS/SAFETY-119-bound-after-hand-back.md:50-51and the PR body. "0 notifications at 1-30 s" was measured with one cycle per reading at a steady interval.cycle_floor_sraised.test_safety119.pypart 5 (code:app/main.py:5829-5833, 5900-5910). Two of my mutations survive:app/main.py:5884-5886(_settling). A gap over 120 s keeps the last measured length instead of falling back to "unknown" (8). It is not reachable withcycle_floor_s≤ 60 unless reads hang.Other checks
75f0804vs6fd0fa0). Only 218 OUTPUT_CLAMP log rows differ, the same rows as round 2, so the pins are unchanged.meter_poll_s) are caught. X3 and X4 survive (F2). The test seeds are the brief's 1-3, plus seed 25, which is kept to show the old rule failing. Nothing is cherry-picked.git archive 6fd0fa0ingoodwe-addon-checks:1(--network none, read-only mount,GOODWE_REQUIRE_HWSIM=1, admin/goodwe at the pin 3b4ff84; the hwsim line is there).96375ecchanges only DECISIONS, and CI run 1358 covers it.