# - * - coding: utf-8 - * -
from .ppg_peaks import ppg_peaks
from ..signal.signal_quality import signal_quality
from ..signal.signal_interpolate import signal_interpolate
from ..signal.signal_power import signal_power
import numpy as np
[docs]
def ppg_quality(
ppg_cleaned,
peaks=None,
sampling_rate=1000,
method="templatematch",
window_sec=None,
overlap_sec=None,
no_bins=16,
ppg_raw=None,
):
"""**PPG Signal Quality Assessment**
Assess the quality of the PPG Signal using various methods:
* The ``"templatematch"`` method (loosely based on Orphanidou et al., 2015) computes a continuous
index of quality of the PPG signal, by calculating the correlation coefficient between each
individual pulse wave and an average (template) pulse wave shape. This index is therefore
relative: 1 corresponds to pulse waves that are closest to the average pulse wave shape (i.e.
correlate exactly with it) and 0 corresponds to there being no correlation with the average
pulse wave shape.
* The ``"dissimilarity"`` method (loosely based on Sabeti et al., 2019) computes a continuous index
of quality of the PPG signal, by calculating the level of dissimilarity between each individual
pulse wave and an average (template) pulse wave shape (after they are normalised). A value of
zero indicates no dissimilarity (i.e. equivalent pulse wave shapes), whereas values above or below
indicate increasing dissimilarity. The original method used dynamic time-warping to align the pulse
waves prior to calculating the level of dsimilarity, whereas this implementation does not currently
include this step.
* The ``"ho2025"`` method (Ho et al., 2025) assesses PPG quality on a beat-by-beat basis by predicting
whether each interbeat-interval (IBI) is accurate. To do so, beats are detected using a primary beat detector,
and each IBI is predicted to be accurate only if a secondary beat detector detects beats
in the same positions (within a tolerance). In this implementation, all signal samples within an
IBI are rated as high quality (1) if that IBI is predicted to be accurate, or low
quality (0) if that IBI is predicted to be inaccurate. Ho et al. proposed this approach for the ECG, and here
it has been applied to the PPG. The general approach was derived by Ho et al from the previously proposed bSQI
approach.
* The ``"skewness"`` method (based on Selveraj, 2011 and Elgendi, 2016) computes the skewness of the PPG signal.
The skewness is a measure of the asymmetry of the probability distribution of the signal's amplitude values.
In Elgendi (2016), higher quality signals were generally found to have higher skewness values.
* The ``"kurtosis"`` method (based on Selveraj, 2011 and Elgendi, 2016) computes the kurtosis of the PPG signal.
The kurtosis is a measure of the "tailedness" of the probability distribution of the signal's amplitude values.
In Elgendi (2016), higher quality signals were generally found to have higher kurtosis values.
* The ``"entropy"`` method (based on Selvaraj et al., 2011, and inspired by Elgendi, 2016) computes the entropy of the
signal in moving windows. The entropy is a measure of the randomness in the signal's amplitude values.
* The ``"perfusion"`` method (based on Elgendi, 2016) computes the perfusion index of the PPG signal.
The perfusion index is the ratio of the amplitude of the pulsatile (AC) component of the PPG to its baseline (DC)
amplitude, expressed as a percentage. It is calculated over moving windows (default: 3 seconds window, 2 seconds
overlap). Requires raw PPG signal.
* The ``"relative_power"`` method (based on Elgendi, 2016) computes the relative power of the PPG signal.
The relative power is the ratio of the power in the 1-2.25 Hz band to the power in the 0-8 Hz band, giving a value
between 0 and 1. It is calculated over moving windows (default: 60 seconds window, 30 seconds overlap). Requires
raw PPG signal.
Parameters
----------
ppg_cleaned : Union[list, np.array, pd.Series]
The cleaned PPG signal in the form of a vector of values.
peaks : tuple or list
The list of PPG pulse wave peak samples returned by ``ppg_peaks()``. If None, peaks is computed from
the signal input.
sampling_rate : int
The sampling frequency of the signal (in Hz, i.e., samples/second).
method : str
The method for computing PPG signal quality, can be ``"templatematch"`` (default), ``"dissimilarity"``,
``"ho2025"``, ``"skewness"``, ``"kurtosis"``, ``"entropy"``, ``"perfusion"``, or ``"relative_power"``.
window_sec : float, optional
Window length in seconds for windowed metrics. Default is 3 seconds for ``"skewness"``, ``"kurtosis"``,
``"perfusion"``, and 60 seconds for ``"relative_power"``.
overlap_sec : float, optional
Overlap between windows in seconds for windowed metrics. Default is 2 seconds for ``"skewness"``, ``"kurtosis"``,
``"perfusion"``, and 30 seconds for ``"relative_power"``.
no_bins : int, optional
Number of bins for ``"entropy"`` calculation (default: 16).
ppg_raw : Union[list, np.array, pd.Series], optional
The raw PPG signal: used for the ``"perfusion"`` and ``"relative_power"`` methods.
Returns
-------
quality : array
Vector containing the quality index ranging from 0 to 1 for ``"templatematch"`` method,
or an unbounded value (where 0 indicates high quality) for ``"dissimilarity"`` method,
or zeros and ones (where 1 indicates high quality) for ``"ho2025"`` method,
or an unbounded value for ``"skewness"``, ``"kurtosis"``, or ``"entropy"``,
or a value between 0 and 100% for ``"perfusion"`` method (100% being high quality),
or a value between 0 and 1 for ``"relative_power"`` method.
See Also
--------
signal_quality, ppg_clean
References
----------
* Orphanidou, C. et al. (2015). "Signal-quality indices for the electrocardiogram and photoplethysmogram:
derivation and applications to wireless monitoring". IEEE Journal of Biomedical and Health Informatics, 19(3), 832-8.
* Sabeti E. et al. (2019). Signal quality measure for pulsatile physiological signals using morphological features:
Applications in reliability measure for pulse oximetry. Informatics in Medicine Unlocked, 16, 100222.
* Ho, S.Y.S et al. (2025). "Accurate RR-interval extraction from single-lead, telehealth electrocardiogram signals.
medRxiv, 2025.03.10.25323655. https://doi.org/10.1101/2025.03.10.25323655
* Elgendi, M. et al. (2016). "Optimal signal quality index for photoplethysmogram signals".
Bioengineering, 3(4), 1–15. doi: https://doi.org/10.3390/bioengineering3040021
* Selvaraj, N. et al. (2011). "Statistical approach for the detection of motion/noise artifacts in Photoplethysmogram".
Proc IEEE EMBC; pp. 4972–4975.
Examples
--------
* **Example 1:** 'templatematch' method
.. ipython:: python
import neurokit2 as nk
sampling_rate = 100
ppg = nk.ppg_simulate(duration=30, sampling_rate=sampling_rate, heart_rate=80)
ppg_cleaned = nk.ppg_clean(ppg, sampling_rate=sampling_rate)
quality = nk.ppg_quality(ppg_cleaned, sampling_rate=sampling_rate, method="templatematch")
@savefig p_ppg_quality.png scale=100%
nk.signal_plot([ppg_cleaned, quality], standardize=True)
@suppress
plt.close()
* **Example 2:** 'skewness' method
.. ipython:: python
import neurokit2 as nk
sampling_rate = 100
ppg = nk.ppg_simulate(duration=30, sampling_rate=sampling_rate, heart_rate=80)
ppg_cleaned = nk.ppg_clean(ppg, sampling_rate=sampling_rate)
quality = nk.ppg_quality(ppg_cleaned, sampling_rate=sampling_rate, method="skewness", window_sec=3, overlap_sec=2)
nk.signal_plot([ppg_cleaned, quality], standardize=True)
plt.close()
"""
method = method.lower() # remove capitalised letters
# Sanitise method name
if method in ["templatematch", "orphanidou2015"]:
method = "templatematch"
elif method in ["dissimilarity", "sabeti2019"]:
method = "dissimilarity"
elif method in ["ho2025", "ho", "ibi", "ici"]:
method = "ici"
elif method in ["skewness"]:
method = "skewness"
elif method in ["kurtosis"]:
method = "kurtosis"
elif method in ["entropy"]:
method = "entropy"
elif method in ["perfusion"]:
method = "perfusion"
elif method in ["relative_power"]:
method = "relative_power"
else:
raise ValueError(
f"Method '{method}' not recognised. Please use 'templatematch', 'dissimilarity', 'ici', 'skewness',"
" 'kurtosis', 'entropy', 'perfusion', or 'relative_power'."
)
# Check that raw PPG signal has been provided if required
if (method in ["perfusion", "relative_power"]) and (ppg_raw is None):
raise ValueError(f"ppg_raw must be provided for the {method} method.")
# Set default window values based on method if not provided
if method == "relative_power":
if window_sec is None:
window_sec = 60
if overlap_sec is None:
overlap_sec = 30
elif method in ["skewness", "kurtosis", "perfusion", "entropy"]:
if window_sec is None:
window_sec = 3
if overlap_sec is None:
overlap_sec = 2
# Detect PPG peaks (if not done already, and if required for the specified quality-assessment method)
if peaks is None:
if method in ["templatematch", "dissimilarity", "ici"]:
_, peaks = ppg_peaks(ppg_cleaned, sampling_rate=sampling_rate)
peaks = peaks["PPG_Peaks"]
# Run 'templatematch' and 'dissimilarity' methods
if method in ["templatematch", "dissimilarity"]:
quality = signal_quality(
ppg_cleaned,
cycle_inds=peaks,
signal_type="ppg",
sampling_rate=sampling_rate,
method=method,
)
elif method == "ici":
# Assess quality using Ho2025 method (IBI accuracy prediction)
quality = signal_quality(
ppg_cleaned,
signal_type="ppg",
primary_detector="charlton",
secondary_detector="elgendi",
sampling_rate=sampling_rate,
method="ici",
)
elif method == "skewness":
quality = signal_quality(
ppg_cleaned,
sampling_rate=sampling_rate,
signal_type="ppg",
method="skewness",
window_sec=window_sec,
overlap_sec=overlap_sec,
)
elif method == "kurtosis":
quality = signal_quality(
ppg_cleaned,
sampling_rate=sampling_rate,
signal_type="ppg",
method="kurtosis",
window_sec=window_sec,
overlap_sec=overlap_sec,
)
elif method == "entropy":
quality = signal_quality(
ppg_cleaned,
sampling_rate=sampling_rate,
signal_type="ppg",
method="entropy",
window_sec=window_sec,
overlap_sec=overlap_sec,
no_bins=no_bins,
)
elif method == "perfusion":
quality = _windowed_metric(
ppg_cleaned,
ppg_raw,
_perfusion_func,
sampling_rate=sampling_rate,
window_sec=window_sec,
overlap_sec=overlap_sec,
)
elif method == "relative_power":
quality = _windowed_metric(
ppg_cleaned,
ppg_raw,
_rel_power_func,
sampling_rate=sampling_rate,
window_sec=window_sec,
overlap_sec=overlap_sec,
)
return quality
# Common window calculation for perfusion and relative_power
def _windowed_metric(
clean_signal, raw_signal, func, sampling_rate, window_sec, overlap_sec, **kwargs
):
# check that clean_signal and raw_signal have the same length
if len(clean_signal) != len(raw_signal):
raise ValueError("Clean and raw signals must have the same length.")
# setup windows
window_size = int(window_sec * sampling_rate)
step_size = int((window_sec - overlap_sec) * sampling_rate)
n_samples = len(clean_signal)
if n_samples < window_size:
raise ValueError(
f"Signal length ({n_samples} samples) is shorter than window size ({window_size} samples)."
)
# calculate metric for each window
metric_values = []
for start in range(0, n_samples - window_size + 1, step_size):
if func == _rel_power_func:
metric_values.append(
func(
raw_signal[start : start + window_size], sampling_rate=sampling_rate
)
)
else:
metric_values.append(
func(
raw_signal[start : start + window_size],
clean_signal[start : start + window_size],
)
)
# interpolate window to provide a continuous output (same length as input signal)
window_centers = (
np.arange(0, n_samples - window_size + 1, step_size) + window_size // 2
)
output = signal_interpolate(
x_values=window_centers,
y_values=metric_values,
x_new=np.arange(n_samples),
method="previous",
)
if np.isnan(output[0]):
output[: window_centers[0]] = metric_values[0]
return output
def _perfusion_func(raw_ppg, cleaned_ppg):
"""
Compute perfusion index for PPG signal quality in moving windows.
Parameters
----------
raw_ppg : array-like
Raw PPG signal
cleaned_ppg : array-like
Cleaned PPG signal
Returns
-------
perfusion : np.ndarray
Perfusion index values for each window, interpolated to signal length.
"""
if raw_ppg is None:
raise ValueError("raw_ppg must be provided for perfusion calculation.")
# calculate baseline
x_bar = np.mean(raw_ppg)
# avoid dividing by zero
if x_bar == 0:
return 0
# calculate perfusion
perfusion = ((np.max(cleaned_ppg) - np.min(cleaned_ppg)) / abs(x_bar)) * 100
return perfusion
# Define relative power function for a window
def _rel_power_func(raw_ppg, sampling_rate):
"""
Compute relative power for PPG signal quality.
Parameters
----------
raw_ppg : array-like
Raw PPG signal.
sampling_rate : int
Sampling frequency (Hz).
Returns
-------
relative_power : float
Relative power: power in 1-2.25 Hz band divided by power in 0-8 Hz band.
"""
# Compute power in both bands
power = signal_power(
raw_ppg,
frequency_band=[(1, 2.25), (0, 8)],
sampling_rate=sampling_rate,
continuous=False,
normalize=False,
)
power_1_2_25 = power.loc[0, "Hz_1_2.25"]
power_0_8 = power.loc[0, "Hz_0_8"]
# Avoid division by zero
if power_0_8 == 0:
return 0.0
# calculate relative power
rel_power = power_1_2_25 / power_0_8
return rel_power