SAFETY-119: no "not keeping up" when the loop takes the wire back #203

Merged
admin merged 11 commits from SAFETY-119 into release/1.0 2026-10-21 03:32:39 +02:00
Owner

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.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC

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
admin added 2 commits 2026-10-19 15:30:36 +02:00
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>
SAFETY-119: re-pin test_safety84/87 digests (log only); prove it
checks / unit (pull_request) Successful in 41m47s
infra-pin / fresh (pull_request) Successful in 1s
b0c2e5977d
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>
admin added 1 commit 2026-10-19 20:17:10 +02:00
SAFETY-119 review: DECISIONS states the worst delay and the no-warn case
checks / unit (pull_request) Successful in 42m3s
infra-pin / fresh (pull_request) Successful in 1s
98026f9a46
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
admin added 1 commit 2026-10-19 20:29:04 +02:00
SAFETY-119 review: DECISIONS, at least once a quarter
checks / unit (pull_request) Successful in 42m1s
infra-pin / fresh (pull_request) Successful in 1s
b9ff6acea4
A bigger house warns more often (every 150 s at 7.4 kW), so 'about' was
wrong. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Author
Owner

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.

## 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`.
admin added 1 commit 2026-10-20 00:52:26 +02:00
SAFETY-119 round 2: one settle window per run of hand-backs
checks / unit (pull_request) Failing after 42m32s
infra-pin / fresh (pull_request) Successful in 2s
e76c70ecb0
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
admin added 1 commit 2026-10-20 01:38:26 +02:00
SAFETY-119 round 2: test_safety119 reads through named_absence
checks / unit (pull_request) Successful in 42m22s
infra-pin / fresh (pull_request) Successful in 1s
85542e443b
test_named_absence flagged two unguarded reads (next() without a default,
a list indexed in a check name). Use first(), nth() and present().

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Author
Owner

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.

## 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.
admin added 1 commit 2026-10-20 17:27:46 +02:00
SAFETY-119 round 3: rolling 15-minute budget for the hidden integrator bound
checks / unit (pull_request) Successful in 42m33s
infra-pin / fresh (pull_request) Successful in 2s
103db2fcda
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>
admin added 1 commit 2026-10-20 17:28:25 +02:00
SAFETY-119 round 3: DECISIONS table ranges over all runs
checks / unit (pull_request) Successful in 42m30s
infra-pin / fresh (pull_request) Successful in 2s
86b94bedb5
Co-Authored-By: Claude <noreply@anthropic.com>
admin added 1 commit 2026-10-20 20:46:17 +02:00
SAFETY-119 round 3b: no-clock budget, fixed-seed tests, accepted limits
checks / unit (pull_request) Successful in 43m1s
infra-pin / fresh (pull_request) Successful in 1s
e27e62ff72
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>
admin added 1 commit 2026-10-21 01:41:54 +02:00
SAFETY-119 round 3c: the hidden-bound budget follows the cycle length
checks / unit (pull_request) Successful in 43m29s
infra-pin / fresh (pull_request) Successful in 1s
6fd0fa05f1
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>
admin added 1 commit 2026-10-21 02:47:31 +02:00
SAFETY-119 review: DECISIONS states the steady-meter limit
checks / unit (pull_request) Successful in 43m33s
infra-pin / fresh (pull_request) Successful in 2s
96375ecbec
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>
Author
Owner

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.0 2026-10-21 03:32:39 +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#203