"Battery is not keeping up" fired about every 2.5 min during the drain, the charge and the HOLD, and under the peak guard. The battery gave exactly what it was asked. Each warning re-created the HA notification.
N=1: when a higher claim decides, the loop's integrator is re-seeded from the target, and then gain * error is added for a grid error the loop does not own. Target + kick crosses the rail and sets i_clamped.
N>=2: a maintenance pin is a unit claim, so the loop stays the site winner and winds to its rail. When the pin lifts, the batteries jump: 2500 W in one cycle at the end of a HOLD under a 4500 W house, and 1390 W when a charge pin lifts.
The fix
The loop's clamp counts only on a cycle the loop decided. That means: the winner is the loop, its number is on the wire (only a rating may cut it), the bound did not hold it, and nothing was unmet. This is Controller._loop_alarm_input.
A battery another owner set alarms only when it really gives less than it was told: more than saturation_w short, in the told direction, for saturation_cycles cycles. "Another owner" means every battery on a cycle the loop did not decide, or a battery with its own pin. The alarm, its text and its id are unchanged, so there are no new strings.
At N>=2, while maintenance holds a battery, the loop gets OPT-11's loop_bound(). It does not wind, and control comes back one slew step a cycle. N=1 needed no change: re-seeding already prevents windup there (measured: 400 W steps at the end of a HOLD).
The status row counts episodes (ClampWatchdog.bound_episodes), not cycles.
What SAFETY-03/40 protect is kept. With the loop in charge, the integrator bound still warns (a runaway, or a house above the rating). SAFETY-03 AC 2 (a saturated inverter under the loop's own command does not alarm) is untouched.
Before → after (test_safety85.py, real Controller, simulated time)
scenario (1 h)
before (notes / cycles / episodes)
after
N=1 drain, house 6500, limit 3500 (guard raises the drain)
24 / 719 / 1
0 / 0 / 0
N=1 drain, house 7500
24 / 719 / 1
0
N=1 charge, house 10000
24 / 720 / 1
0
N=1 HOLD, house 6500, limit 3500
24 / 719 / 1
0
N=1 HOLD, house 9000
24 / 720 / 1
0
N=2 drain, house 6500, limit 3500
24 / 719 / 1
0
N=2 charge, house 10000
24 / 720 / 1
0
N=2 HOLD, house 4500
24 / 711 / 1
0
N=2 HOLD, house 6500, limit 3500
24 / 719 / 1
0
N=2, A at its cut-out, house 6000, no maintenance (15 min)
1 / 3 / 1
0
Real shortfalls still alarm:
scenario
before
after
N=1 drain 2000 W, the battery gives nothing for 10 min (house 1000)
silent (0)
4 notes, "the battery gives -4 W of the +2000 W asked"
N=2 drain on A, A gives nothing
not measured
warns, names inverter-a (SAFETY-73 then takes A)
N=2 HOLD on A, B gives nothing
not measured
warns, names inverter-b
a two-cycle dip
not measured
no alarm
Control coming back from maintenance (largest step per cycle):
scenario
before
after
N=2 HOLD ends
2500 W
400 W
N=2 charge pin lifts
1390 W
400 W
N=1 HOLD ends
400 W
400 W
Re-pinned digests (all intended; no N=1 command moves)
digest
why
test_safety78 n1_drain_owner
the false clamp rows are gone from the log; commands identical
bounded loop: reason mode-bound, integrator and loop claim; commands identical
test_modes SAFETY78_N2_MAINT
A drops out at t=1655 s: B follows the house at once; 8 cycles differ, max 920 W
test_safety84 n2_charge_dear
A's pin lifts at t=1800 s: 400 W a cycle instead of a 1390 W jump, and the surplus step after it; 98 cycles
Other test edits:
test_clamp: the row assertion now expects episodes.
test_modes: only n2_maint hands compute() a bound.
test_safety86: the log-flood non-vacuity check now removes both the hold ceiling and the bound, and asserts that either one alone keeps the log clean.
test_safety84/86 alarmed(): also reads SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of the 40-row log.
Mutations
Each mutation ran on a fresh copy of the committed tree with tools/mutation_run.sh (purged bytecode), in goodwe-addon-checks:1. Result: 14 of 14 red, 0 survivors.
#
rule
mutation
red in
M1
1
loop clamp counts on every cycle (owns and removed)
test_safety85 parts 1, 4
M2
1
an unmet target still counts as the loop's
test_safety85 part 4 (A at cut-out)
M3
1
a bound-held loop still counts as the loop's
test_safety85 part 2 (HOLD, B gives nothing)
M4
1
a rating cut makes the cycle not the loop's
test_clamp 8e (shadow: the clamp alarm still runs)
M5
2
a pinned battery is not graded while the loop decides
test_safety85 part 2 (N=2 drain, A gives nothing)
M6
2
one short cycle alarms
test_safety85 part 2 (two-cycle dip, start time)
M7
2
shortfall by magnitude, either direction
test_safety85 part 6
M8
2
0 W asked can be short
test_safety85 part 6
M9
2
shortfall never alarms
test_safety85 part 2
M10
3
no loop bound at N>=2 during maintenance
test_safety85 parts 1 (N=2 drain warns), 3 (2500 / 1390 W jumps)
M11
3
compute() never reports held
test_safety85 part 2 (HOLD, B gives nothing)
M12
counter
episodes not counted
test_safety85 part 5, test_clamp part 3
M13
counter
the active row shows cycles
test_safety85 part 5
M14
counter
the after-episode row shows cycles
test_clamp part 3
Open for the reviewer / owner
With the loop in charge and the house above the batteries' total rating, it still warns. This is SAFETY-40, unchanged. On the rig's 2 × 2000 W N=2 most of SAFETY-84's count came from houses of 4500 and 6500 W, so expect some warnings there on a rig re-test.
A battery that tapers near full during a charge, more than saturation_w under its command for 3 cycles, now warns (rule 2). The text already says "check it is not empty or full".
TEL-10's loop_reason reads mode-bound while the maintenance bound holds the loop, with modes off.
Out of scope: SAFETY-88 (N=2 charge flapping) is not fixed. The hand-back step at a charge pin's lift is now one slew step, so the flapping should be smaller, not larger. I have no descriptions of SAFETY-89/90/91 and did not assess them. No user-facing strings changed, so UX-17/UX-19 are not affected. 'Load too high' (SAFETY-81, ALARM_HOLD_S) is untouched.
Full suite: 58/58 green locally in goodwe-addon-checks:1 with the infra pin.
Fixes SAFETY-85. Base: `release/1.0` 9358781. Record: `goodwe_controller/DECISIONS/SAFETY-85-no-false-not-keeping-up.md`.
## What was wrong
"Battery is not keeping up" fired about every 2.5 min during the drain, the charge and the HOLD, and under the peak guard. The battery gave exactly what it was asked. Each warning re-created the HA notification.
- **N=1:** when a higher claim decides, the loop's integrator is re-seeded from the target, and then `gain * error` is added for a grid error the loop does not own. Target + kick crosses the rail and sets `i_clamped`.
- **N>=2:** a maintenance pin is a unit claim, so the loop stays the site winner and winds to its rail. When the pin lifts, the batteries jump: 2500 W in one cycle at the end of a HOLD under a 4500 W house, and 1390 W when a charge pin lifts.
## The fix
1. **The loop's clamp counts only on a cycle the loop decided.** That means: the winner is the loop, its number is on the wire (only a rating may cut it), the bound did not hold it, and nothing was unmet. This is `Controller._loop_alarm_input`.
2. **A battery another owner set alarms only when it really gives less than it was told:** more than `saturation_w` short, in the told direction, for `saturation_cycles` cycles. "Another owner" means every battery on a cycle the loop did not decide, or a battery with its own pin. The alarm, its text and its id are unchanged, so there are no new strings.
3. **At N>=2, while maintenance holds a battery, the loop gets OPT-11's `loop_bound()`.** It does not wind, and control comes back one slew step a cycle. N=1 needed no change: re-seeding already prevents windup there (measured: 400 W steps at the end of a HOLD).
4. **The status row counts episodes** (`ClampWatchdog.bound_episodes`), not cycles.
What SAFETY-03/40 protect is kept. With the loop in charge, the integrator bound still warns (a runaway, or a house above the rating). SAFETY-03 AC 2 (a saturated inverter under the loop's own command does not alarm) is untouched.
## Before → after (`test_safety85.py`, real Controller, simulated time)
| scenario (1 h) | before (notes / cycles / episodes) | after |
|---|---|---|
| N=1 drain, house 6500, limit 3500 (guard raises the drain) | 24 / 719 / 1 | 0 / 0 / 0 |
| N=1 drain, house 7500 | 24 / 719 / 1 | 0 |
| N=1 charge, house 10000 | 24 / 720 / 1 | 0 |
| N=1 HOLD, house 6500, limit 3500 | 24 / 719 / 1 | 0 |
| N=1 HOLD, house 9000 | 24 / 720 / 1 | 0 |
| N=2 drain, house 6500, limit 3500 | 24 / 719 / 1 | 0 |
| N=2 charge, house 10000 | 24 / 720 / 1 | 0 |
| N=2 HOLD, house 4500 | 24 / 711 / 1 | 0 |
| N=2 HOLD, house 6500, limit 3500 | 24 / 719 / 1 | 0 |
| N=2, A at its cut-out, house 6000, no maintenance (15 min) | 1 / 3 / 1 | 0 |
Real shortfalls still alarm:
| scenario | before | after |
|---|---|---|
| N=1 drain 2000 W, the battery gives nothing for 10 min (house 1000) | **silent** (0) | 4 notes, "the battery gives -4 W of the +2000 W asked" |
| N=2 drain on A, A gives nothing | not measured | warns, names inverter-a (SAFETY-73 then takes A) |
| N=2 HOLD on A, B gives nothing | not measured | warns, names inverter-b |
| a two-cycle dip | not measured | no alarm |
Control coming back from maintenance (largest step per cycle):
| scenario | before | after |
|---|---|---|
| N=2 HOLD ends | 2500 W | 400 W |
| N=2 charge pin lifts | 1390 W | 400 W |
| N=1 HOLD ends | 400 W | 400 W |
## Re-pinned digests (all intended; no N=1 command moves)
| digest | why |
|---|---|
| test_safety78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical |
| test_safety78 `n2_drain`; test_safety84 `n2_charge`, `n2_charge_banked`; test_peak_margin `SAFETY84_N2_MAINT` | bounded loop: reason `mode-bound`, integrator and loop claim; commands identical |
| test_modes `SAFETY78_N2_MAINT` | A drops out at t=1655 s: B follows the house at once; 8 cycles differ, max 920 W |
| test_safety84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle instead of a 1390 W jump, and the surplus step after it; 98 cycles |
Other test edits:
- test_clamp: the row assertion now expects episodes.
- test_modes: only n2_maint hands `compute()` a bound.
- test_safety86: the log-flood non-vacuity check now removes both the hold ceiling and the bound, and asserts that either one alone keeps the log clean.
- test_safety84/86 `alarmed()`: also reads SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of the 40-row log.
## Mutations
Each mutation ran on a fresh copy of the committed tree with `tools/mutation_run.sh` (purged bytecode), in `goodwe-addon-checks:1`. Result: 14 of 14 red, 0 survivors.
| # | rule | mutation | red in |
|---|---|---|---|
| M1 | 1 | loop clamp counts on every cycle (`owns and` removed) | test_safety85 parts 1, 4 |
| M2 | 1 | an unmet target still counts as the loop's | test_safety85 part 4 (A at cut-out) |
| M3 | 1 | a bound-held loop still counts as the loop's | test_safety85 part 2 (HOLD, B gives nothing) |
| M4 | 1 | a rating cut makes the cycle not the loop's | test_clamp 8e (shadow: the clamp alarm still runs) |
| M5 | 2 | a pinned battery is not graded while the loop decides | test_safety85 part 2 (N=2 drain, A gives nothing) |
| M6 | 2 | one short cycle alarms | test_safety85 part 2 (two-cycle dip, start time) |
| M7 | 2 | shortfall by magnitude, either direction | test_safety85 part 6 |
| M8 | 2 | 0 W asked can be short | test_safety85 part 6 |
| M9 | 2 | shortfall never alarms | test_safety85 part 2 |
| M10 | 3 | no loop bound at N>=2 during maintenance | test_safety85 parts 1 (N=2 drain warns), 3 (2500 / 1390 W jumps) |
| M11 | 3 | `compute()` never reports `held` | test_safety85 part 2 (HOLD, B gives nothing) |
| M12 | counter | episodes not counted | test_safety85 part 5, test_clamp part 3 |
| M13 | counter | the active row shows cycles | test_safety85 part 5 |
| M14 | counter | the after-episode row shows cycles | test_clamp part 3 |
## Open for the reviewer / owner
- With the loop in charge and the house above the batteries' total rating, it still warns. This is SAFETY-40, unchanged. On the rig's 2 × 2000 W N=2 most of SAFETY-84's count came from houses of 4500 and 6500 W, so expect some warnings there on a rig re-test.
- A battery that tapers near full during a charge, more than `saturation_w` under its command for 3 cycles, now warns (rule 2). The text already says "check it is not empty or full".
- TEL-10's `loop_reason` reads `mode-bound` while the maintenance bound holds the loop, with modes off.
- Out of scope: SAFETY-88 (N=2 charge flapping) is not fixed. The hand-back step at a charge pin's lift is now one slew step, so the flapping should be smaller, not larger. I have no descriptions of SAFETY-89/90/91 and did not assess them. No user-facing strings changed, so UX-17/UX-19 are not affected. 'Load too high' (SAFETY-81, `ALARM_HOLD_S`) is untouched.
- Full suite: 58/58 green locally in `goodwe-addon-checks:1` with the infra pin.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
The loop's integrator bound counts only on a cycle the loop decided. A battery
another owner set (maintenance, the peak guard, a safety limit) alarms only
when it gives more than saturation_w less than told for saturation_cycles
cycles. At N>=2 the loop is bounded while maintenance holds a battery, so it
does not wind and control comes back one slew step a cycle. The status row
counts episodes, not cycles. Digests re-pinned where behaviour changed on
purpose; DECISIONS/SAFETY-85-no-false-not-keeping-up.md has the list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Reviewed 4b81387 against release/1.0 9358781. Rule 1, rule 3 (the bound), the episode counter and the re-pins hold up. Rule 2 does not: it is measured on a plant that delivers its command instantly, and on a plant that lags it moves the false warning rather than removing it. I will push the fixes below to this branch.
F1 (blocker): rule 2 fires on a healthy battery that lags
_loop_alarm_input grades each battery against last cycle's command, and warns after saturation_cycles (3) short cycles. The meter tick on the live install is 2 s (control.LOOP_TICK_S), so that is 6 s. The plant takes about 18 to 23 s to settle a 1.78 kW step (PLANT_SETTLE_S, PLANT_SETTLE_DOWN_S, NOTES §5.10), and plantsim models it with a 10 s lag. Every drain start, charge start and peak-guard step bigger than about 900 W is "short" for more than 6 s.
Probe: the PR's own scenarios with the rig battery replaced by plantsim's model (800 W/s ramp, 10 s first-order lag), at the 2 s tick (scratch/SAFETY-85/review/probe_lag.py). "Kettle" is a house of 800 W, plus 2 kW for 3 min every 10 min, plus 3 kW for 1 min every 4 min, with a 3500 W limit.
At N=2 under the peak guard with a house that moves, it is still about one warning every 4 min. Each one is a battery that is following its command.
Fix: grade a battery against the smallest discharge command it stood at over the last SETTLE_CYCLES cycles. That is the plant's measured settle time (23 s), the same window SAFETY-13 gives the plant before it blames anything else. A battery that gives nothing against a steady command is still caught after the same 3 cycles. A battery still ramping toward a new command is not.
F2 (blocker): every maintenance charge warns while the battery tapers
This is the open point in the PR. Measured with an instant plant whose charge acceptance tapers from 2500 W to 150 W over 30 min, from t=600 s (probe_taper.py): 11 notifications in 50 min at N=1 and at N=2, against 0 on base for the same run. SAFETY-42 §7 (delivery.py, "DISCHARGE SHARES ONLY") decided that charge shares are not graded for exactly this reason: "arming that direction would fire on the taper of every §12 maintenance charge, which is the crying-wolf failure it exists to avoid". Rule 2 re-opens that.
Fix: rule 2 grades only discharge commands. What that costs: a battery that does not take a charge another owner set is not named by this alarm. It is the gap SAFETY-42 already states, and the charge phase still has its 36 h timeout. The peak guard and the drain only ask for discharge, so the SAFETY-53 case (a battery that gives nothing while it is told to give) stays covered.
F3: Loop reason = mode-bound with modes off
This is user-visible: it is the HA entity Loop reason (TEL-10). DOCS "Reading the grid-following loop" says "one of ten values", and mode-bound was never in that table (an OPT-11 gap). The PR now shows it with modes off. Fix: add the row, worded true for both cases: the batteries' limits (their modes, or at two or more inverters a battery in maintenance) set the command, not the meter. No code or digest change.
F4: test gaps
No test of a battery under the peak guard (no maintenance) that gives 0 W. I mutated rule 2 to skip cycles the peak guard decides, and the mutant is green on the PR's suite (to be confirmed with the mutation runner in round 2). Adding one.
No test on a lagging plant, and no taper test. Adding both, each non-vacuous against the PR's rule.
Checked and fine
Re-pins (row dumps on 9358781 vs 4b81387, commands compared cycle by cycle): s78 n1_drain_owner, n2_drain; s84 n2_charge, n2_charge_banked; s79 n2_maint (margin 0): 0 command differences. test_modes n2_maint: 8 cycles (t=1660-1695 s), max 920 W, all the hand-back after A drops out, where B now follows the house within one slew step a cycle. s84 n2_charge_dear: 98 cycles (t=1800-2395 s). These are the pin-lift ramp (+400 W a cycle instead of +1390 W) and a deadband rest 5 W per unit different after the surplus step. Each re-pin changes for its stated reason only.
The bound at N>=2:allocator.loop_bound counts a pinned battery from 0 to its pin, so drain and HOLD pins (including SAFETY-86's raised HOLD pin) stay reachable. A battery held under its peak reserve is not a unit limit, so the loop can still draw it as a last resort (SAFETY-25 option A, unchanged). SAFETY-84's charge ceiling and SAFETY-86's hold ceiling are site claims: the loop is re-seeded under them, as before. The bound is re-evaluated each cycle, so it ends with maintenance. Hand-back is +400 W a cycle (200 W/s at a 2 s meter). In s84 n2_charge_dear that is about 6 Wh less discharge at the quarter start (about +25 W on that quarter, house 5000 W against a 3500 W limit, grid near 0). When the guard is engaged its floor outranks the loop, so the ramp cannot cause an overshoot at a quarter edge.
Rule 1 (owns): fine. With the loop stopped or blind, the command is 0 W, so there is nothing to grade.
alarmed() reading _peak_alarm_block: that is set on exactly the cycle the 'Load too high' row is logged, so it is equivalent and more robust. ALARM_HOLD_S is untouched.
Not changed and out of scope: at N=1 a battery that gives nothing while the loop itself drives it is still caught only by the loop's own clamp, which is SAFETY-03 AC 2 and SAFETY-42 (inert at N=1). That is the same as on base.
## SAFETY-85 review, round 1
Reviewed 4b81387 against release/1.0 9358781. Rule 1, rule 3 (the bound), the episode counter and the re-pins hold up. Rule 2 does not: it is measured on a plant that delivers its command instantly, and on a plant that lags it moves the false warning rather than removing it. I will push the fixes below to this branch.
### F1 (blocker): rule 2 fires on a healthy battery that lags
`_loop_alarm_input` grades each battery against last cycle's command, and warns after `saturation_cycles` (3) short cycles. The meter tick on the live install is 2 s (`control.LOOP_TICK_S`), so that is 6 s. The plant takes about 18 to 23 s to settle a 1.78 kW step (`PLANT_SETTLE_S`, `PLANT_SETTLE_DOWN_S`, NOTES §5.10), and plantsim models it with a 10 s lag. Every drain start, charge start and peak-guard step bigger than about 900 W is "short" for more than 6 s.
Probe: the PR's own scenarios with the rig battery replaced by plantsim's model (800 W/s ramp, 10 s first-order lag), at the 2 s tick (`scratch/SAFETY-85/review/probe_lag.py`). "Kettle" is a house of 800 W, plus 2 kW for 3 min every 10 min, plus 3 kW for 1 min every 4 min, with a 3500 W limit.
| 1 h, 2 s tick, 10 s lag | base 9358781 | PR 4b81387 |
|---|---|---|
| N=1 drain / charge / HOLD, the PR's 5 houses | 24-25 notes each | 1 note each (at the start ramp), 0 for HOLD 9000 |
| N=2 drain / charge / HOLD, the PR's 4 houses | 24-25 | 1 each |
| N=1 HOLD, kettle, limit 3500 | 2 | 4 |
| N=2 drain, kettle, limit 3500 | 15 | **15** |
| N=2 HOLD, kettle, limit 3500 | 21 | **15** |
At N=2 under the peak guard with a house that moves, it is still about one warning every 4 min. Each one is a battery that is following its command.
**Fix:** grade a battery against the smallest discharge command it stood at over the last `SETTLE_CYCLES` cycles. That is the plant's measured settle time (23 s), the same window SAFETY-13 gives the plant before it blames anything else. A battery that gives nothing against a steady command is still caught after the same 3 cycles. A battery still ramping toward a new command is not.
### F2 (blocker): every maintenance charge warns while the battery tapers
This is the open point in the PR. Measured with an instant plant whose charge acceptance tapers from 2500 W to 150 W over 30 min, from t=600 s (`probe_taper.py`): **11 notifications in 50 min** at N=1 and at N=2, against 0 on base for the same run. SAFETY-42 §7 (`delivery.py`, "DISCHARGE SHARES ONLY") decided that charge shares are not graded for exactly this reason: "arming that direction would fire on the taper of every §12 maintenance charge, which is the crying-wolf failure it exists to avoid". Rule 2 re-opens that.
**Fix:** rule 2 grades only discharge commands. What that costs: a battery that does not take a charge another owner set is not named by this alarm. It is the gap SAFETY-42 already states, and the charge phase still has its 36 h timeout. The peak guard and the drain only ask for discharge, so the SAFETY-53 case (a battery that gives nothing while it is told to give) stays covered.
### F3: `Loop reason` = `mode-bound` with modes off
This is user-visible: it is the HA entity `Loop reason` (TEL-10). DOCS "Reading the grid-following loop" says "one of ten values", and `mode-bound` was never in that table (an OPT-11 gap). The PR now shows it with modes off. **Fix:** add the row, worded true for both cases: the batteries' limits (their modes, or at two or more inverters a battery in maintenance) set the command, not the meter. No code or digest change.
### F4: test gaps
- No test of a battery under the peak guard (no maintenance) that gives 0 W. I mutated rule 2 to skip cycles the peak guard decides, and the mutant is green on the PR's suite (to be confirmed with the mutation runner in round 2). Adding one.
- No test on a lagging plant, and no taper test. Adding both, each non-vacuous against the PR's rule.
### Checked and fine
- **Re-pins** (row dumps on 9358781 vs 4b81387, commands compared cycle by cycle): s78 `n1_drain_owner`, `n2_drain`; s84 `n2_charge`, `n2_charge_banked`; s79 `n2_maint` (margin 0): 0 command differences. test_modes `n2_maint`: 8 cycles (t=1660-1695 s), max 920 W, all the hand-back after A drops out, where B now follows the house within one slew step a cycle. s84 `n2_charge_dear`: 98 cycles (t=1800-2395 s). These are the pin-lift ramp (+400 W a cycle instead of +1390 W) and a deadband rest 5 W per unit different after the surplus step. Each re-pin changes for its stated reason only.
- **The bound at N>=2:** `allocator.loop_bound` counts a pinned battery from 0 to its pin, so drain and HOLD pins (including SAFETY-86's raised HOLD pin) stay reachable. A battery held under its peak reserve is not a unit limit, so the loop can still draw it as a last resort (SAFETY-25 option A, unchanged). SAFETY-84's charge ceiling and SAFETY-86's hold ceiling are site claims: the loop is re-seeded under them, as before. The bound is re-evaluated each cycle, so it ends with maintenance. Hand-back is +400 W a cycle (200 W/s at a 2 s meter). In s84 `n2_charge_dear` that is about 6 Wh less discharge at the quarter start (about +25 W on that quarter, house 5000 W against a 3500 W limit, grid near 0). When the guard is engaged its floor outranks the loop, so the ramp cannot cause an overshoot at a quarter edge.
- **Rule 1** (`owns`): fine. With the loop stopped or blind, the command is 0 W, so there is nothing to grade.
- `alarmed()` reading `_peak_alarm_block`: that is set on exactly the cycle the 'Load too high' row is logged, so it is equivalent and more robust. `ALARM_HOLD_S` is untouched.
- **Not changed and out of scope:** at N=1 a battery that gives nothing while the loop itself drives it is still caught only by the loop's own clamp, which is SAFETY-03 AC 2 and SAFETY-42 (inert at N=1). That is the same as on base.
Rule 2 graded each battery against last cycle's command and graded charges
too. On plantsim's battery (10 s lag) at the 2 s meter it warned at every
drain or charge start and 15 times an hour under the peak guard at N=2; a
charge tapering near full warned 11 times in 50 min (SAFETY-42 section 7).
Now a discharge is graded against the least it stood at over SHORT_CYCLES /
SHORT_SPAN_S. DOCS lists `mode-bound` under Loop reason. test_safety85 part 7:
peak guard with a dead battery, lagging plant, taper; part 3: the bound ends
with maintenance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A battery is graded against the least it stood at over SHORT_CYCLES cycles (12) and SHORT_SPAN_S seconds (22 s). That is the same span SAFETY-73 uses for refusal evidence, so a 1 s meter does not shorten it. main.py_loop_alarm_input.
F2: every maintenance charge warns while the battery tapers
fixed
delivery_short grades a discharge only (SAFETY-42 §7). The cost is stated in DECISIONS.
F3: mode-bound with modes off
fixed (docs)
DOCS Loop reason table: eleven values, mode-bound = "the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance". No code or digest change.
F4: test gaps
fixed
test_safety85 part 7: the peak guard sets the battery and it gives nothing (N=1 and N=2) and it warns; a battery that lags 10 s at a 2 s meter does not warn (N=1 drain, N=2 drain and HOLD, stepping house, limit 3500); a lagging battery that gives nothing still warns; a charge that tapers does not warn. Each check is non-vacuous against the round-1 rule. Part 3: the bound is on while maintenance runs and off from the cycle it ends.
Probe after the fix, 1 h each, plantsim's battery, 2 s tick (probe_lag.py):
10 s lag: 0 warnings in all 14 maintenance and peak-guard scenarios. Before the fix it was 1 to 15.
20 s lag (twice the modelled lag, hypothetical): at most 2 an hour, mostly once at a drain start.
The loop in charge with the house above the rating still warns: SAFETY-40, unchanged.
Taper (probe_taper.py): 0 notes at N=1 and N=2, against 11 in round 1.
Mutations (tools/mutation_run.sh on a fresh copy of the final tree, graded on test_safety85 + test_clamp):
#
mutation
result
M2
an unmet target still counts as the loop's
red
M5
a pinned battery is not graded while the loop decides
red
M7
shortfall by magnitude, either direction
red
M9
a shortfall never alarms
red
M10
no loop bound at N>=2 during maintenance
red
M11
compute() never reports held
red
R1
graded against this cycle's new command, not what stood on the wire
red
R2
the episode counter is never re-armed per episode
red
R3
the bound is also applied at N=1
survives: equivalent. loop_bound() returns None at N=1 without a mode claim, and with modes on the bound is already applied.
R4
the bound stays on after maintenance ends
survived the first run; red after the part 3 check was added
R5
a battery under the peak guard that gives 0 W never alarms
red (part 7)
R6
the settle window removed (last command only)
red (part 7)
R7
charges graded again
red (part 7)
Full suite in goodwe-addon-checks:1: 58/58 green. The commands in every pinned digest are unchanged by this round (rule 2 moves no watts).
Noted, not in scope: at N=2, when a drain-pinned battery that gives nothing is refused (SAFETY-73), B jumps 1020 → 2500 W in one cycle. Base does the same, so this is not a regression. The loop's site target still counts A's pin.
## SAFETY-85 review, round 2: fixes pushed (7b723a6)
| finding | status | what changed |
|---|---|---|
| F1: rule 2 fires on a battery that lags | **fixed** | A battery is graded against the least it stood at over `SHORT_CYCLES` cycles (12) and `SHORT_SPAN_S` seconds (22 s). That is the same span SAFETY-73 uses for refusal evidence, so a 1 s meter does not shorten it. `main.py` `_loop_alarm_input`. |
| F2: every maintenance charge warns while the battery tapers | **fixed** | `delivery_short` grades a discharge only (SAFETY-42 §7). The cost is stated in DECISIONS. |
| F3: `mode-bound` with modes off | **fixed (docs)** | DOCS `Loop reason` table: eleven values, `mode-bound` = "the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance". No code or digest change. |
| F4: test gaps | **fixed** | test_safety85 part 7: the peak guard sets the battery and it gives nothing (N=1 and N=2) and it warns; a battery that lags 10 s at a 2 s meter does not warn (N=1 drain, N=2 drain and HOLD, stepping house, limit 3500); a lagging battery that gives nothing still warns; a charge that tapers does not warn. Each check is non-vacuous against the round-1 rule. Part 3: the bound is on while maintenance runs and off from the cycle it ends. |
**Probe after the fix**, 1 h each, plantsim's battery, 2 s tick (`probe_lag.py`):
- 10 s lag: 0 warnings in all 14 maintenance and peak-guard scenarios. Before the fix it was 1 to 15.
- 20 s lag (twice the modelled lag, hypothetical): at most 2 an hour, mostly once at a drain start.
- The loop in charge with the house above the rating still warns: SAFETY-40, unchanged.
Taper (`probe_taper.py`): 0 notes at N=1 and N=2, against 11 in round 1.
**Mutations** (`tools/mutation_run.sh` on a fresh copy of the final tree, graded on test_safety85 + test_clamp):
| # | mutation | result |
|---|---|---|
| M2 | an unmet target still counts as the loop's | red |
| M5 | a pinned battery is not graded while the loop decides | red |
| M7 | shortfall by magnitude, either direction | red |
| M9 | a shortfall never alarms | red |
| M10 | no loop bound at N>=2 during maintenance | red |
| M11 | `compute()` never reports `held` | red |
| R1 | graded against this cycle's new command, not what stood on the wire | red |
| R2 | the episode counter is never re-armed per episode | red |
| R3 | the bound is also applied at N=1 | survives: **equivalent**. `loop_bound()` returns None at N=1 without a mode claim, and with modes on the bound is already applied. |
| R4 | the bound stays on after maintenance ends | survived the first run; **red** after the part 3 check was added |
| R5 | a battery under the peak guard that gives 0 W never alarms | red (part 7) |
| R6 | the settle window removed (last command only) | red (part 7) |
| R7 | charges graded again | red (part 7) |
Full suite in `goodwe-addon-checks:1`: 58/58 green. The commands in every pinned digest are unchanged by this round (rule 2 moves no watts).
Noted, not in scope: at N=2, when a drain-pinned battery that gives nothing is refused (SAFETY-73), B jumps 1020 → 2500 W in one cycle. Base does the same, so this is not a regression. The loop's site target still counts A's pin.
admin
merged commit 61072ca460 into release/1.02026-10-01 20:52:18 +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.
Fixes SAFETY-85. Base:
release/1.09358781. Record:goodwe_controller/DECISIONS/SAFETY-85-no-false-not-keeping-up.md.What was wrong
"Battery is not keeping up" fired about every 2.5 min during the drain, the charge and the HOLD, and under the peak guard. The battery gave exactly what it was asked. Each warning re-created the HA notification.
gain * erroris added for a grid error the loop does not own. Target + kick crosses the rail and setsi_clamped.The fix
Controller._loop_alarm_input.saturation_wshort, in the told direction, forsaturation_cyclescycles. "Another owner" means every battery on a cycle the loop did not decide, or a battery with its own pin. The alarm, its text and its id are unchanged, so there are no new strings.loop_bound(). It does not wind, and control comes back one slew step a cycle. N=1 needed no change: re-seeding already prevents windup there (measured: 400 W steps at the end of a HOLD).ClampWatchdog.bound_episodes), not cycles.What SAFETY-03/40 protect is kept. With the loop in charge, the integrator bound still warns (a runaway, or a house above the rating). SAFETY-03 AC 2 (a saturated inverter under the loop's own command does not alarm) is untouched.
Before → after (
test_safety85.py, real Controller, simulated time)Real shortfalls still alarm:
Control coming back from maintenance (largest step per cycle):
Re-pinned digests (all intended; no N=1 command moves)
n1_drain_ownern2_drain; test_safety84n2_charge,n2_charge_banked; test_peak_marginSAFETY84_N2_MAINTmode-bound, integrator and loop claim; commands identicalSAFETY78_N2_MAINTn2_charge_dearOther test edits:
compute()a bound.alarmed(): also reads SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of the 40-row log.Mutations
Each mutation ran on a fresh copy of the committed tree with
tools/mutation_run.sh(purged bytecode), ingoodwe-addon-checks:1. Result: 14 of 14 red, 0 survivors.owns andremoved)compute()never reportsheldOpen for the reviewer / owner
saturation_wunder its command for 3 cycles, now warns (rule 2). The text already says "check it is not empty or full".loop_reasonreadsmode-boundwhile the maintenance bound holds the loop, with modes off.ALARM_HOLD_S) is untouched.goodwe-addon-checks:1with the infra pin.🤖 Generated with Claude Code
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
SAFETY-85 review, round 1
Reviewed
4b81387against release/1.09358781. Rule 1, rule 3 (the bound), the episode counter and the re-pins hold up. Rule 2 does not: it is measured on a plant that delivers its command instantly, and on a plant that lags it moves the false warning rather than removing it. I will push the fixes below to this branch.F1 (blocker): rule 2 fires on a healthy battery that lags
_loop_alarm_inputgrades each battery against last cycle's command, and warns aftersaturation_cycles(3) short cycles. The meter tick on the live install is 2 s (control.LOOP_TICK_S), so that is 6 s. The plant takes about 18 to 23 s to settle a 1.78 kW step (PLANT_SETTLE_S,PLANT_SETTLE_DOWN_S, NOTES §5.10), and plantsim models it with a 10 s lag. Every drain start, charge start and peak-guard step bigger than about 900 W is "short" for more than 6 s.Probe: the PR's own scenarios with the rig battery replaced by plantsim's model (800 W/s ramp, 10 s first-order lag), at the 2 s tick (
scratch/SAFETY-85/review/probe_lag.py). "Kettle" is a house of 800 W, plus 2 kW for 3 min every 10 min, plus 3 kW for 1 min every 4 min, with a 3500 W limit.93587814b81387At N=2 under the peak guard with a house that moves, it is still about one warning every 4 min. Each one is a battery that is following its command.
Fix: grade a battery against the smallest discharge command it stood at over the last
SETTLE_CYCLEScycles. That is the plant's measured settle time (23 s), the same window SAFETY-13 gives the plant before it blames anything else. A battery that gives nothing against a steady command is still caught after the same 3 cycles. A battery still ramping toward a new command is not.F2 (blocker): every maintenance charge warns while the battery tapers
This is the open point in the PR. Measured with an instant plant whose charge acceptance tapers from 2500 W to 150 W over 30 min, from t=600 s (
probe_taper.py): 11 notifications in 50 min at N=1 and at N=2, against 0 on base for the same run. SAFETY-42 §7 (delivery.py, "DISCHARGE SHARES ONLY") decided that charge shares are not graded for exactly this reason: "arming that direction would fire on the taper of every §12 maintenance charge, which is the crying-wolf failure it exists to avoid". Rule 2 re-opens that.Fix: rule 2 grades only discharge commands. What that costs: a battery that does not take a charge another owner set is not named by this alarm. It is the gap SAFETY-42 already states, and the charge phase still has its 36 h timeout. The peak guard and the drain only ask for discharge, so the SAFETY-53 case (a battery that gives nothing while it is told to give) stays covered.
F3:
Loop reason=mode-boundwith modes offThis is user-visible: it is the HA entity
Loop reason(TEL-10). DOCS "Reading the grid-following loop" says "one of ten values", andmode-boundwas never in that table (an OPT-11 gap). The PR now shows it with modes off. Fix: add the row, worded true for both cases: the batteries' limits (their modes, or at two or more inverters a battery in maintenance) set the command, not the meter. No code or digest change.F4: test gaps
Checked and fine
9358781vs4b81387, commands compared cycle by cycle): s78n1_drain_owner,n2_drain; s84n2_charge,n2_charge_banked; s79n2_maint(margin 0): 0 command differences. test_modesn2_maint: 8 cycles (t=1660-1695 s), max 920 W, all the hand-back after A drops out, where B now follows the house within one slew step a cycle. s84n2_charge_dear: 98 cycles (t=1800-2395 s). These are the pin-lift ramp (+400 W a cycle instead of +1390 W) and a deadband rest 5 W per unit different after the surplus step. Each re-pin changes for its stated reason only.allocator.loop_boundcounts a pinned battery from 0 to its pin, so drain and HOLD pins (including SAFETY-86's raised HOLD pin) stay reachable. A battery held under its peak reserve is not a unit limit, so the loop can still draw it as a last resort (SAFETY-25 option A, unchanged). SAFETY-84's charge ceiling and SAFETY-86's hold ceiling are site claims: the loop is re-seeded under them, as before. The bound is re-evaluated each cycle, so it ends with maintenance. Hand-back is +400 W a cycle (200 W/s at a 2 s meter). In s84n2_charge_dearthat is about 6 Wh less discharge at the quarter start (about +25 W on that quarter, house 5000 W against a 3500 W limit, grid near 0). When the guard is engaged its floor outranks the loop, so the ramp cannot cause an overshoot at a quarter edge.owns): fine. With the loop stopped or blind, the command is 0 W, so there is nothing to grade.alarmed()reading_peak_alarm_block: that is set on exactly the cycle the 'Load too high' row is logged, so it is equivalent and more robust.ALARM_HOLD_Sis untouched.SAFETY-85 review, round 2: fixes pushed (
7b723a6)SHORT_CYCLEScycles (12) andSHORT_SPAN_Sseconds (22 s). That is the same span SAFETY-73 uses for refusal evidence, so a 1 s meter does not shorten it.main.py_loop_alarm_input.delivery_shortgrades a discharge only (SAFETY-42 §7). The cost is stated in DECISIONS.mode-boundwith modes offLoop reasontable: eleven values,mode-bound= "the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance". No code or digest change.Probe after the fix, 1 h each, plantsim's battery, 2 s tick (
probe_lag.py):Taper (
probe_taper.py): 0 notes at N=1 and N=2, against 11 in round 1.Mutations (
tools/mutation_run.shon a fresh copy of the final tree, graded on test_safety85 + test_clamp):compute()never reportsheldloop_bound()returns None at N=1 without a mode claim, and with modes on the bound is already applied.Full suite in
goodwe-addon-checks:1: 58/58 green. The commands in every pinned digest are unchanged by this round (rule 2 moves no watts).Noted, not in scope: at N=2, when a drain-pinned battery that gives nothing is refused (SAFETY-73), B jumps 1020 → 2500 W in one cycle. Base does the same, so this is not a regression. The loop's site target still counts A's pin.