Source code for ecgdatakit.processing.clean

"""ECG signal cleaning.

Methods
-------
default
    Built-in Butterworth bandpass (0.5--40 Hz) + power-line notch. No extra deps.
biosppy
    BioSPPy ECG filter (FIR band-pass, no notch). Requires ``pip install biosppy``.
neurokit2
    NeuroKit2 ``ecg_clean`` (0.5 Hz high-pass + power-line filter).
    Requires ``pip install neurokit2``.
combined
    BioSPPy followed by NeuroKit2. Requires both.
deepfade
    DeepFADE DenseNet encoder-decoder denoiser. Requires ``pip install torch``.
"""

from __future__ import annotations

import importlib
from pathlib import Path

import numpy as np

from ecgdatakit.models import Lead, LeadLike
from ecgdatakit.processing._core import ensure_lead, new_lead, require_finite

_DEEPFADE_WEIGHTS = Path(__file__).parent / "nn" / "weights" / "deepfade_exp_1_ddp.pt"


_METHOD_KWARGS: dict[str, frozenset[str]] = {
    "default": frozenset(),
    "biosppy": frozenset(),
    "neurokit2": frozenset(),
    "combined": frozenset(),
    "deepfade": frozenset({"weights_path", "device", "batch_size"}),
}


[docs] def clean_ecg( lead: LeadLike, method: str = "default", *, fs: int | None = None, powerline: float | None = 50.0, **kwargs, ) -> Lead: """Clean an ECG lead signal. Parameters ---------- lead : Lead | NDArray[np.float64] Input ECG lead or 1-D signal array. method : str Cleaning method: ``"default"``, ``"biosppy"``, ``"neurokit2"``, ``"combined"``, or ``"deepfade"`` (experimental, warns on use). fs : int | None Sample rate in Hz. Required when *lead* is a numpy array. powerline : float | None Power-line frequency in Hz (50 or 60) removed by ``"default"``, ``"neurokit2"`` and the NeuroKit2 stage of ``"combined"``. ``None`` disables the notch of ``"default"``. BioSPPy and DeepFADE have no power-line stage and ignore it. With ``"default"``, a notch that is not below the Nyquist frequency is skipped with a warning. **kwargs Extra arguments for ``"deepfade"`` only: - ``device`` (str): PyTorch device (default ``"cpu"``). - ``weights_path`` (str | Path): Override the bundled DeepFADE weights. - ``batch_size`` (int): Inference batch size (default 32). Returns ------- Lead Cleaned lead (new object, original unchanged). Raises ------ ValueError Unknown *method*, or the backend rejects the signal (e.g. too short or containing NaN). Backend errors are never hidden. TypeError A keyword argument the selected method does not accept. """ lead = ensure_lead(lead, fs=fs) if method not in _METHOD_KWARGS: raise ValueError(f"Unknown method {method!r}; choose from {tuple(_METHOD_KWARGS)}") unknown = set(kwargs) - _METHOD_KWARGS[method] if unknown: raise TypeError( f"clean_ecg(method={method!r}) got unexpected keyword argument(s): " f"{', '.join(sorted(unknown))}" ) if method == "default": return _clean_default(lead, powerline) if method == "biosppy": return _clean_biosppy(lead, method) if method == "neurokit2": return _clean_neurokit2(lead, powerline, method) if method == "combined": return _clean_neurokit2(_clean_biosppy(lead, method), powerline, method) return _clean_deepfade(lead, **kwargs)
def _clean_default(lead: Lead, powerline: float | None) -> Lead: """Bandpass 0.5--40 Hz + power-line notch using built-in Butterworth filters.""" from ecgdatakit.processing.filters import _band_top, _notch_if_possible, bandpass high = _band_top(40.0, lead.sampling_rate, "clean_ecg") result = bandpass(lead, low=0.5, high=high, order=4) return _notch_if_possible(result, powerline, "clean_ecg") def _import_backend(module: str, method: str): """Import a cleaning backend, keeping the real import error in the message. A backend can be installed yet fail to import because one of its own dependencies is missing (e.g. biosppy 2.2.4 needs peakutils). """ try: return importlib.import_module(module) except ImportError as exc: raise ImportError( f"clean_ecg(method={method!r}) needs {module.split('.')[0]}, which " f"could not be imported ({type(exc).__name__}: {exc}). " 'Install it with: pip install "ecgdatakit[cleaning]"' ) from exc def _clean_biosppy(lead: Lead, method: str = "biosppy") -> Lead: """Clean using BioSPPy ECG filtering pipeline.""" bio_ecg = _import_backend("biosppy.signals.ecg", method) require_finite(lead, "clean_ecg") result = bio_ecg.ecg( lead.samples, sampling_rate=lead.sampling_rate, show=False, ) return new_lead(lead, samples=np.asarray(result["filtered"], dtype=np.float64)) def _clean_neurokit2(lead: Lead, powerline: float | None, method: str = "neurokit2") -> Lead: """Clean using NeuroKit2 ecg_clean pipeline.""" nk = _import_backend("neurokit2", method) require_finite(lead, "clean_ecg") extra = {} if powerline is None else {"powerline": powerline} filtered = nk.ecg_clean(lead.samples, sampling_rate=lead.sampling_rate, **extra) return new_lead(lead, samples=np.asarray(filtered, dtype=np.float64)) def _clean_deepfade( lead: Lead, *, weights_path: str | Path | None = None, device: str = "cpu", batch_size: int = 32, ) -> Lead: """Denoise using the DeepFADE neural network.""" from ecgdatakit.processing.denoise import denoise_deepfade if weights_path is None: weights_path = _DEEPFADE_WEIGHTS return denoise_deepfade(lead, weights_path=weights_path, device=device, batch_size=batch_size)