SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery #163

Merged
admin merged 2 commits from safety-85-no-false-not-keeping-up into release/1.0 2026-10-01 20:52:18 +02:00
Owner

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

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
admin added 1 commit 2026-10-01 19:15:22 +02:00
SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery
checks / unit (pull_request) Successful in 25m35s
infra-pin / fresh (pull_request) Successful in 2s
4b81387908
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
Author
Owner

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.
## 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.
admin added 1 commit 2026-10-01 20:25:43 +02:00
SAFETY-85 review: grade discharge only, over the plant's settle time
checks / unit (pull_request) Successful in 25m47s
infra-pin / fresh (pull_request) Successful in 2s
7b723a6cd5
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>
Author
Owner

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.

## 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.0 2026-10-01 20:52:18 +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#163