Skip to content

Include the right edge in find_local_peaks window - #573

Merged
tompollard merged 4 commits into
MIT-LCP:mainfrom
rmz-oz:fix-find-local-peaks-window
Oct 31, 2026
Merged

tompollard merged 4 commits into
MIT-LCP:mainfrom
rmz-oz:fix-find-local-peaks-window

Conversation

@rmz-oz

@rmz-oz rmz-oz commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #572. The window now covers radius samples on both sides like the docstring says, and the third loop that could never run is gone (#474).

test_processing.py wasn't being collected because the classes were named test_*, so I renamed them. The new test fails on main.

XQRS uses find_local_peaks for its candidates, so its output shifts a little. On MIT-BIH 100, 101, 105, 108, 119, 200, 203, 207 and 208 it finds 21 fewer of 20856 beats (sensitivity 0.9777 to 0.9766), mostly in 208, and false positives stay at 733. If you'd rather keep XQRS as it was I can look at that side too.

@tompollard

Copy link
Copy Markdown
Member

Thanks, this looks good.

On the XQRS side, I think the small sensitivity change you measured is acceptable given that the function is now correct. A few small requests:

  • Could you add an entry to docs/changes.rst under "Unreleased"? It should mention the find_local_peaks fix and that XQRS detections may change slightly.
  • The docstring says that for flat tops "the middle sample is classified as the local peak", but the first sample wins. Not something you introduced, but it would be nice to correct the wording while you're here (or leave it for the TODO).
  • Optional: sig[lo:hi].max() instead of the built-in max() would be noticeably faster on long signals.
  • Please update the branch from main so CI runs against the current setup. The class renames overlap with tests: rename test classes so pytest collects them #577, but they're identical and merge cleanly.

tompollard added a commit that referenced this pull request Oct 31, 2026
`correct_peaks` is meant to search `search_radius` samples on both sides
of each peak, but `shift_peaks` slices `sig[ind - r : min(ind + r,
sig_len - 1)]`. That leaves out `ind + r`, and the last sample of the
signal, so a peak there can't be found:

```python
sig = np.zeros(30); sig[13] = 5; sig[29] = 5
processing.correct_peaks(sig, np.array([10]), search_radius=3, smooth_window_size=9, peak_dir="up")  # [7], expected [13]
processing.correct_peaks(sig, np.array([27]), search_radius=5, smooth_window_size=9, peak_dir="up")  # [22], expected [29]
```

**Fix:** slice up to `min(ind + r + 1, sig_len)`.

**Effect on record 100** (`correct_peaks` on the reference beats, XQRS
and GQRS, radius 10, 36 and 61, all four `peak_dir` options):
- With the radius used in the existing test (61), only one peak moves,
and only for `peak_dir="down"`: the last beat, now on the final sample.
- With radius 10 on GQRS beats (`"up"`, `"both"`, `"compare"`), almost
every peak moves one sample right, from `ind + 9` to `ind + 10`. In all
2271 cases the new sample is higher in the smoothed-difference signal,
so the old window was cutting off the maximum.
- The existing `test_correct_peaks` expectations are unchanged.
- XQRS and GQRS don't call `correct_peaks`, so their detections are
unchanged.

This is the same kind of off-by-one as #572 / #573, but in `shift_peaks`
rather than `find_local_peaks`. The two PRs touch different functions
and don't depend on each other.

**Tests:** a peak exactly `search_radius` after the guess, and a peak on
the last sample. The test fails on `main`. `pytest -m "not network"` and
the docs build pass.
rmz-oz and others added 4 commits October 31, 2026 17:05
The slices stopped at i + radius - 1, so the sample radius positions to
the right was never compared. Use one loop over the whole signal; the
third loop was unreachable (MIT-LCP#474).

The test classes in test_processing.py were named test_*, which pytest
does not collect, so rename them to make those tests run.

Fixes MIT-LCP#572
When several samples share the maximum value, the first of them is
classified as the peak, not the middle one. A flat top wider than the
radius gives a peak every <radius> samples along it.
The built-in max() iterates over the window one element at a time.
ndarray.max() makes find_local_peaks about 3 times faster (XQRS on
record 100 goes from 1.1 s to 0.4 s), with identical results for
finite signals. Next to a NaN value, the window's maximum is now
consistently NaN, so no peak is found there, where max() gave a result
that depended on the position of the NaN. Lists are converted to
arrays, so they still work.
@tompollard
tompollard force-pushed the fix-find-local-peaks-window branch from a07fc39 to 4df94ba Compare October 31, 2026 21:16
@tompollard

Copy link
Copy Markdown
Member

Thanks for the fix! I went ahead and addressed the points above:

  • Rebased onto main (the package moved to src/wfdb/, and the test class renames were already there).
  • Corrected the docstring for flat peaks: the first of the equal samples is the peak, and a flat top wider than radius gives a peak every radius samples.
  • Switched to ndarray.max(), which makes XQRS on record 100 about 3 times faster with identical detections. Lists still work.
  • Added a changelog entry that mentions XQRS detections may change slightly.

If you have local changes, please reset your branch to this one before pushing.

@tompollard
tompollard merged commit b6215cb into MIT-LCP:main Oct 31, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

find_local_peaks ignores the sample at i + radius, so points on a rising slope are returned as peaks

2 participants