"""ECG signal quality assessment.
The signal quality index (SQI) is a heuristic screening score, not a
validated clinical measure. It averages four sub-scores, each in [0, 1]:
- **Kurtosis** (after Li, Clark and Clifford 2008, kSQI): a clean ECG is
spiky and has a high excess kurtosis. Score 1 for excess kurtosis in
3--20, ``k / 3`` below 3, 0 when negative, and decreasing linearly to 0
at 50 above 20.
- **Power ratio**: variance of the 1--40 Hz band divided by the variance of
the mean-removed signal, scaled so a ratio of 0.7 or more scores 1.
- **Peak regularity**: ``1 - 2 * CV`` of the RR intervals from
:func:`~ecgdatakit.processing.detect_r_peaks`, clipped to [0, 1]; 0.3
when fewer than 3 peaks are found. Irregular rhythms (atrial
fibrillation, ectopy) lower this score although the signal may be clean.
- **Baseline stability**: ``1 - var(< 1 Hz) / var(1--40 Hz)``, clipped to
[0, 1].
Band components are obtained with zero-phase Butterworth filters, so the
scores do not depend on the sample rate.
"""
from __future__ import annotations
import numpy as np
from numpy.typing import NDArray
from ecgdatakit.models import LeadLike
from ecgdatakit.processing._core import ensure_lead, require_scipy
from ecgdatakit.processing.peaks import _validate_signal, detect_r_peaks
_SNR_MIN_FS = 200.0
[docs]
def signal_quality_index(lead: LeadLike, *, fs: int | None = None) -> float:
"""Compute a composite signal quality index (SQI) in the range [0, 1].
Mean of four sub-scores: kurtosis, power ratio, R-peak regularity and
baseline stability (see the module documentation for definitions and
thresholds).
Parameters
----------
lead : Lead | NDArray[np.float64]
Input ECG lead or raw signal array (at least 1 s).
fs : int | None
Sample rate in Hz. Required when *lead* is a numpy array.
Returns
-------
float
Score between 0.0 (unusable) and 1.0 (excellent).
Raises
------
ValueError
Signal shorter than 1 s, not 1-D, or containing NaN or infinite
values.
"""
lead = ensure_lead(lead, fs=fs)
samples = _validate_signal(lead.samples, lead.sampling_rate)
fs_ = lead.sampling_rate
scores = [
_kurtosis_sqi(samples),
_power_ratio_sqi(samples, fs_),
_peak_regularity_sqi(samples, fs_),
_baseline_sqi(samples, fs_),
]
return float(np.clip(np.mean(scores), 0.0, 1.0))
[docs]
def classify_quality(lead: LeadLike, *, fs: int | None = None) -> str:
"""Classify signal quality as a human-readable category.
Parameters
----------
lead : Lead | NDArray[np.float64]
Input ECG lead or raw signal array.
fs : int | None
Sample rate in Hz. Required when *lead* is a numpy array.
Returns
-------
str
``"excellent"`` (SQI > 0.8), ``"acceptable"`` (0.5--0.8),
or ``"unacceptable"`` (< 0.5).
"""
sqi = signal_quality_index(lead, fs=fs)
if sqi > 0.8:
return "excellent"
if sqi >= 0.5:
return "acceptable"
return "unacceptable"
[docs]
def snr_estimate(lead: LeadLike, *, fs: int | None = None) -> float:
"""Estimate signal-to-noise ratio in dB.
The noise floor is the mean power spectral density (Welch, 1 s
segments) above 100 Hz, assumed white and extrapolated over the
1--40 Hz ECG band. The SNR is the 1--40 Hz power divided by that
extrapolated noise power.
Limits: only broadband noise that reaches above 100 Hz (EMG, electronic
noise) is measured. Baseline wander, powerline interference and
in-band motion artefacts are not counted, and recordings low-pass
filtered below 100 Hz by the device read as noise-free.
Parameters
----------
lead : Lead | NDArray[np.float64]
Input ECG lead or raw signal array (at least 1 s).
fs : int | None
Sample rate in Hz. Required when *lead* is a numpy array.
Returns
-------
float
Estimated SNR in dB. ``nan`` when the sample rate is 200 Hz or
lower (no band above 100 Hz); ``inf`` when no noise is measured.
"""
lead = ensure_lead(lead, fs=fs)
samples = _validate_signal(lead.samples, lead.sampling_rate)
fs_ = lead.sampling_rate
if fs_ <= _SNR_MIN_FS:
return float("nan")
sig = require_scipy("signal")
nperseg = min(len(samples), int(fs_))
freqs, psd = sig.welch(samples, fs=fs_, nperseg=nperseg)
df = freqs[1] - freqs[0]
signal_mask = (freqs >= 1.0) & (freqs <= 40.0)
# exclude the half-weight Nyquist bin
noise_mask = (freqs >= 100.0) & (freqs < fs_ / 2.0)
if not np.any(noise_mask):
return float("nan")
signal_power = float(np.sum(psd[signal_mask]) * df)
noise_power = float(np.mean(psd[noise_mask])) * 39.0
if noise_power <= 0:
return float("inf")
if signal_power <= 0:
return float("-inf")
return float(10 * np.log10(signal_power / noise_power))
def _band(samples: NDArray[np.float64], fs: int, low: float | None, high: float | None) -> NDArray[np.float64]:
"""Zero-phase 2nd-order Butterworth band component."""
sig = require_scipy("signal")
if low is not None and high is not None:
sos = sig.butter(2, [low, high], btype="band", fs=fs, output="sos")
elif high is not None:
sos = sig.butter(2, high, btype="low", fs=fs, output="sos")
else:
sos = sig.butter(2, low, btype="high", fs=fs, output="sos")
return sig.sosfiltfilt(sos, samples)
def _ecg_high(fs: int) -> float:
return min(40.0, 0.45 * fs)
def _kurtosis_sqi(samples: NDArray[np.float64]) -> float:
"""Kurtosis-based SQI. Clean ECG typically has excess kurtosis 5--15."""
if len(samples) < 4:
return 0.0
std = samples.std()
if std == 0:
return 0.0
k = float(np.mean(((samples - samples.mean()) / std) ** 4)) - 3.0
if 3.0 <= k <= 20.0:
return 1.0
if k < 0:
return 0.0
if k < 3.0:
return k / 3.0
return max(0.0, 1.0 - (k - 20.0) / 30.0)
def _power_ratio_sqi(samples: NDArray[np.float64], fs: int) -> float:
"""Share of variance in the ECG band (1--40 Hz), 0.7 or more scores 1."""
total = float(np.var(samples))
if total <= 0:
return 0.0
ecg = float(np.var(_band(samples, fs, 1.0, _ecg_high(fs))))
return float(np.clip(ecg / total / 0.7, 0.0, 1.0))
def _peak_regularity_sqi(samples: NDArray[np.float64], fs: int) -> float:
"""RR-interval regularity: low coefficient of variation scores high."""
peaks = detect_r_peaks(samples, fs=fs)
if len(peaks) < 3:
return 0.3
rr = np.diff(peaks).astype(np.float64)
cv = rr.std() / rr.mean()
return float(np.clip(1.0 - cv * 2.0, 0.0, 1.0))
def _baseline_sqi(samples: NDArray[np.float64], fs: int) -> float:
"""Baseline stability: low power below 1 Hz relative to 1--40 Hz."""
centred = samples - samples.mean()
ecg = float(np.var(_band(centred, fs, 1.0, _ecg_high(fs))))
if ecg <= 0:
return 0.0
baseline = float(np.var(_band(centred, fs, None, 1.0)))
return float(np.clip(1.0 - baseline / ecg, 0.0, 1.0))