# SAFETY-85 - no false "not keeping up" while another owner sets the battery **Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`. **The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`) fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the peak guard, while the battery gave exactly what it was told. Each warning re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in 7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page counter counted cycles (861), not episodes. **Cause, measured on the real Controller.** - N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the target each cycle, then `compute()` adds `gain * error` for a grid error the loop does not own. Target + kick crosses the rail and sets `i_clamped`. The carried integrator is not wound (SAFETY-48 caps the lead at one slew step), so control comes back one slew step at a time. - N>=2: a maintenance pin is a unit claim. The loop stays the site winner and winds to its rail for watts the pinned battery may not give. When the pin lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s). **Decision.** Three rules; the alarm, its text and its id are unchanged. 1. **The loop's clamp counts only on a cycle the loop decided.** The winner is the loop. Its number is on the wire, or only a rating (`device`, `supervised`) cut it. No bound held it, and nothing was unmet. (`Controller._loop_alarm_input`.) 2. **A battery another owner set to discharge alarms when it gives less than it was told.** This covers every battery on a cycle the loop did not decide, and a battery with its own pin otherwise. The test is more than `saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for `saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a real shortfall during maintenance or under the peak guard. Before, that shortfall was silent unless the kick happened to reach the rail (house 1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min now). 3. **At N>=2, while maintenance holds a battery, the loop is bounded** by OPT-11's `loop_bound()` (what the batteries may place). It does not wind, and control comes back one slew step a cycle. Why maintenance only: that is where the jump was measured and where the owner order puts maintenance above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep the old law. Rule 1 still silences them (the target is unmet), so they no longer warn either. Fixing the windup was chosen over only ignoring the wound part, because at N>=2 the wound integrator moves real watts at hand-back. At N=1 there was nothing to fix: re-seeding already prevents windup. **What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator bound still warns (a runaway, a house above the rating), and a saturated inverter under the loop's own command still does not alarm (SAFETY-03 AC 2): rule 2 grades only batteries another owner set. **The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a bound engagement. The status row shows it ("N times since start"). `bound` (cycles) stays for the log and the tests. **Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt. | digest | what changed | |---|---| | s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical | | s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical | | opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) | | s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles | test_safety86's "without the ceiling the log floods" now needs the ceiling and the bound both removed: either one alone keeps the loop inside what the batteries can give. The `alarmed()` helpers in test_safety84/86 also read SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of the 40-row log. **Review (PR #163).** The first draft graded each battery against last cycle's command and graded charges too. On plantsim's battery (10 s lag) at the live 2 s meter, every drain or charge start warned once, and an N=2 drain or HOLD under the peak guard with a stepping house warned 15 times an hour. A maintenance charge tapering near full warned 11 times in 50 min, which SAFETY-42 section 7 had ruled out. With the settle window and discharge only: 0 in all of those cases. A battery that gives nothing still warns, lagging or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost: a battery that does not take a charge another owner set is not named by this alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout). `test_safety85.py` part 7. **Not changed, open for the owner.** - With the loop in charge and the house above the batteries' total rating, it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's count came from houses of 4500 and 6500 W. - TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds the loop, with modes off. DOCS lists the value with both meanings.