|
| 1 | +import warnings |
| 2 | +from dataclasses import dataclass |
| 3 | +from typing import TYPE_CHECKING, Final, Literal |
| 4 | + |
| 5 | +import numpy as np |
| 6 | +from scipy import signal |
| 7 | + |
| 8 | +from eitprocessing.datahandling.eitdata import EITData |
| 9 | + |
| 10 | +if TYPE_CHECKING: |
| 11 | + from eitprocessing.plotting.rate_detection import RateDetectionPlotting |
| 12 | + |
| 13 | +MINUTE: Final = 60 |
| 14 | +MIN_WELCH_WINDOW_LENGTH: Final = 10 |
| 15 | + |
| 16 | + |
| 17 | +@dataclass(frozen=True) |
| 18 | +class RateDetection: |
| 19 | + """Detect the respiratory and heart rate from EIT pixel data. |
| 20 | +
|
| 21 | + This algorithm attempts to detect the respiratory and heart rate from EIT pixel data. It is based on the observation |
| 22 | + that many high-amplitude pixels have the respiratory rate as the main frequency, while in low-amplitude pixels the |
| 23 | + power of the heart rate is relatively high. The algorithm uses Welch's method to estimate the power spectrum of the |
| 24 | + summed pixel data and individual pixels. It then identifies the respiratory rate as the frequency with the highest |
| 25 | + power for the summed pixels within the specified range. The power spectra of the individual pixels are normalized |
| 26 | + and averaged. The normalized power spectrum of the summed pixels is subtracted from the average of the normalized |
| 27 | + individual power spectra. The frequency with the highest relative power in this difference within the specified |
| 28 | + range is taken as the heart rate. |
| 29 | +
|
| 30 | + If either rate is variable, the algorithm will in most cases return an average frequency. If there are multiple |
| 31 | + distinct frequencies, e.g., due to a change in the controlled respiratory rate, multiple peaks might be visible in |
| 32 | + the power spectrum. The algorithm will only return the frequency with the highest power in the specified range. |
| 33 | +
|
| 34 | + The algorithm can't distinguish between the respiratory and heart rate if they are too close together, especially in |
| 35 | + very short signals (or when the Welch window is short). The algorithm can distinguish between both rates if the |
| 36 | + heart rate is at one of the harmonics of the respiratory rate. |
| 37 | +
|
| 38 | + If the `refine_estimated_frequency` attribute is set to False, the estimated frequency is simply the location of the |
| 39 | + peak of the power Welch spectrum. Since Welch's method results in a limited number of frequency bins, this can lead |
| 40 | + to inaccuracies, especially with short or low-sample frequency data. If `refine_estimated_frequency` is set to True |
| 41 | + (default), the estimated frequency is refined using parabolic interpolation, which often yields more accurate |
| 42 | + results, even with short signals, low signal-to-noise ratio and very similar (but distinct) respiratory and heart |
| 43 | + rates. See e.g. [Quadratic Interpolation of Spectral |
| 44 | + Peaks](https://ccrma.stanford.edu/~jos/sasp/Quadratic_Interpolation_Spectral_Peaks.html). |
| 45 | +
|
| 46 | + The respiratory and heart rate limits can be set when initializing this algorithm. Default values for adults and |
| 47 | + neonates are set in `DEFAULT_RATE_LIMITS`. |
| 48 | +
|
| 49 | + Although the algorithm might perform reasonably on data as short as 10 seconds (200 samples), it is recommended to |
| 50 | + use at least 30 seconds of data for reliable results. Longer data wil yield more reliable results, but only if the |
| 51 | + respiratory and heart rates are stable. |
| 52 | +
|
| 53 | + Note that the algorithm performs best if no large changes in end-expiratory impedance occur in the data, the data is |
| 54 | + unfiltered, and the respiratory and heart rates are relatively stable. |
| 55 | +
|
| 56 | + Attributes: |
| 57 | + subject_type: The type of subject, either "adult" or "neonate". This affects the default settings for the |
| 58 | + minimum and maximum heart and respiratory rates. |
| 59 | + welch_window: The length of the Welch window in seconds. |
| 60 | + welch_overlap: The fraction overlap between Welch windows (e.g., 0.5 = 50% overlap). |
| 61 | + min_heart_rate: The minimum heart rate in Hz. If None, the default value for the subject type is used. |
| 62 | + max_heart_rate: The maximum heart rate in Hz. If None, the default value for the subject type is used. |
| 63 | + min_respiratory_rate: |
| 64 | + The minimum respiratory rate in Hz. If None, the default value for the subject type is used. |
| 65 | + max_respiratory_rate: |
| 66 | + The maximum respiratory rate in Hz. If None, the default value for the subject type is used. |
| 67 | + refine_estimated_frequency: |
| 68 | + If True, the estimated frequency is refined using parabolic interpolation. If False, the frequency with the |
| 69 | + highest power is used as the estimated frequency. |
| 70 | + """ |
| 71 | + |
| 72 | + subject_type: Literal["adult", "neonate"] |
| 73 | + |
| 74 | + min_heart_rate: float |
| 75 | + max_heart_rate: float |
| 76 | + min_respiratory_rate: float |
| 77 | + max_respiratory_rate: float |
| 78 | + |
| 79 | + welch_window: float = 30.0 |
| 80 | + welch_overlap: float = 0.5 |
| 81 | + |
| 82 | + refine_estimated_frequency: bool = True |
| 83 | + |
| 84 | + def __init__( |
| 85 | + self, |
| 86 | + subject_type: Literal["adult", "neonate"], |
| 87 | + *, |
| 88 | + welch_window: float = 30.0, |
| 89 | + welch_overlap: float = 0.5, |
| 90 | + min_heart_rate: float | None = None, |
| 91 | + max_heart_rate: float | None = None, |
| 92 | + min_respiratory_rate: float | None = None, |
| 93 | + max_respiratory_rate: float | None = None, |
| 94 | + refine_estimated_frequency: bool = True, |
| 95 | + ): |
| 96 | + if welch_overlap >= 1: |
| 97 | + msg = "Welch overlap must be less than 1.0 (100%)." |
| 98 | + raise ValueError(msg) |
| 99 | + if welch_overlap < 0: |
| 100 | + msg = "Welch overlap must be at least 0 (0%)." |
| 101 | + raise ValueError(msg) |
| 102 | + |
| 103 | + if welch_window < MIN_WELCH_WINDOW_LENGTH: |
| 104 | + msg = "Welch window must be at least 10 seconds." |
| 105 | + raise ValueError(msg) |
| 106 | + |
| 107 | + if subject_type not in ("adult", "neonate"): |
| 108 | + msg = f"Invalid subject type: {subject_type}. Must be 'adult' or 'neonate'." |
| 109 | + raise ValueError(msg) |
| 110 | + |
| 111 | + object.__setattr__(self, "subject_type", subject_type) |
| 112 | + object.__setattr__(self, "welch_window", welch_window) |
| 113 | + object.__setattr__(self, "welch_overlap", welch_overlap) |
| 114 | + object.__setattr__(self, "refine_estimated_frequency", refine_estimated_frequency) |
| 115 | + |
| 116 | + for attr in ( |
| 117 | + "min_heart_rate", |
| 118 | + "max_heart_rate", |
| 119 | + "min_respiratory_rate", |
| 120 | + "max_respiratory_rate", |
| 121 | + ): |
| 122 | + object.__setattr__(self, attr, locals().get(attr, None) or DEFAULT_RATE_LIMITS[attr][self.subject_type]) |
| 123 | + |
| 124 | + def apply( |
| 125 | + self, |
| 126 | + eit_data: EITData, |
| 127 | + *, |
| 128 | + captures: dict | None = None, |
| 129 | + suppress_length_warnings: bool = False, |
| 130 | + suppress_edge_case_warning: bool = False, |
| 131 | + ) -> tuple[float, float]: |
| 132 | + """Detect respiratory and heart rate based on pixel data. |
| 133 | +
|
| 134 | + NB: the respiratory and heart rate are returned in Hz. Multiply by 60 to convert to breaths/beats per minute. |
| 135 | +
|
| 136 | + Arguments: |
| 137 | + eit_data: EITData object containing pixel impedance data and sample frequency. |
| 138 | + captures: |
| 139 | + Optional dictionary to capture additional information during processing. Can be used for plotting or |
| 140 | + debugging purposes. |
| 141 | + suppress_length_warnings: |
| 142 | + If True, suppress warnings about segment length being larger than the data length or overlap being |
| 143 | + larger than segment length. Defaults to False. |
| 144 | + suppress_edge_case_warning: |
| 145 | + If True, suppress warnings about the maximum power being at the edge of the frequency range, which |
| 146 | + prevents frequency refinement. Defaults to False. |
| 147 | +
|
| 148 | + Returns: |
| 149 | + A tuple containing the estimated respiratory rate and heart rate in Hz. |
| 150 | +
|
| 151 | + Warnings: |
| 152 | + If the segment length is larger than the data length, a warning is issued and the segment length is set to |
| 153 | + the data length. If the overlap is larger than the segment length, a warning is issued and the overlap is |
| 154 | + set to segment length - 1. |
| 155 | +
|
| 156 | + """ |
| 157 | + summed_impedance = eit_data.calculate_global_impedance() |
| 158 | + len_segment = self.welch_window * eit_data.sample_frequency |
| 159 | + |
| 160 | + if len(summed_impedance) < len_segment: |
| 161 | + if not suppress_length_warnings: |
| 162 | + warnings.warn( |
| 163 | + "The Welch window is longer than the data. Reducing the window length to the lenght of the data.", |
| 164 | + UserWarning, |
| 165 | + ) |
| 166 | + len_segment = len(summed_impedance) |
| 167 | + |
| 168 | + len_overlap = int(len_segment * self.welch_overlap) |
| 169 | + |
| 170 | + frequencies, total_power = signal.welch( |
| 171 | + summed_impedance, |
| 172 | + eit_data.sample_frequency, |
| 173 | + nperseg=len_segment, |
| 174 | + noverlap=len_overlap, |
| 175 | + detrend="constant", |
| 176 | + ) |
| 177 | + |
| 178 | + normalized_total_power = total_power / np.sum(total_power) |
| 179 | + |
| 180 | + pixel_impedance = eit_data.pixel_impedance |
| 181 | + pixel_impedance[:, np.all(pixel_impedance == 0, axis=0)] = np.nan |
| 182 | + |
| 183 | + _, pixel_power_spectra = signal.welch( |
| 184 | + pixel_impedance, |
| 185 | + eit_data.sample_frequency, |
| 186 | + nperseg=len_segment, |
| 187 | + noverlap=len_overlap, |
| 188 | + detrend="constant", |
| 189 | + axis=0, |
| 190 | + ) |
| 191 | + |
| 192 | + normalized_power_spectra = np.divide(pixel_power_spectra, np.nansum(pixel_power_spectra, axis=0, keepdims=True)) |
| 193 | + average_normalized_pixel_power = np.nanmean(normalized_power_spectra, axis=(1, 2)) |
| 194 | + |
| 195 | + diff_total_averaged_power = average_normalized_pixel_power - normalized_total_power |
| 196 | + |
| 197 | + estimated_respiratory_rate = self._estimate_and_refine_freq( |
| 198 | + frequencies, total_power, self.min_respiratory_rate, self.max_respiratory_rate, suppress_edge_case_warning |
| 199 | + ) |
| 200 | + estimated_heart_rate = self._estimate_and_refine_freq( |
| 201 | + frequencies, diff_total_averaged_power, self.min_heart_rate, self.max_heart_rate, suppress_edge_case_warning |
| 202 | + ) |
| 203 | + |
| 204 | + if captures is not None: |
| 205 | + captures["frequencies"] = frequencies |
| 206 | + captures["normalized_total_power"] = normalized_total_power |
| 207 | + captures["average_normalized_pixel_power"] = average_normalized_pixel_power |
| 208 | + captures["diff_total_averaged_power"] = diff_total_averaged_power |
| 209 | + captures["estimated_respiratory_rate"] = estimated_respiratory_rate |
| 210 | + captures["estimated_heart_rate"] = estimated_heart_rate |
| 211 | + |
| 212 | + return estimated_respiratory_rate, estimated_heart_rate |
| 213 | + |
| 214 | + def _estimate_and_refine_freq( |
| 215 | + self, |
| 216 | + frequencies: np.ndarray, |
| 217 | + power: np.ndarray, |
| 218 | + min_rate: float, |
| 219 | + max_rate: float, |
| 220 | + suppress_edge_case_warning: bool, |
| 221 | + ) -> float: |
| 222 | + range_indices = np.nonzero((frequencies >= min_rate) & (frequencies <= max_rate)) |
| 223 | + |
| 224 | + frequency_range = frequencies[range_indices] |
| 225 | + power_range = power[range_indices] |
| 226 | + index_max_power = np.argmax(power_range) |
| 227 | + |
| 228 | + if self.refine_estimated_frequency and (index_max_power == 0 or index_max_power == len(power_range) - 1): |
| 229 | + # If the maximum power is at the edge of the range, we cannot refine the frequency |
| 230 | + if not suppress_edge_case_warning: |
| 231 | + warnings.warn("Maximum power is at the edge of the range, cannot refine the frequency.") |
| 232 | + estimated_rate = frequency_range[index_max_power] |
| 233 | + |
| 234 | + elif self.refine_estimated_frequency: |
| 235 | + y0, y1, y2 = power_range[index_max_power - 1 : index_max_power + 2] |
| 236 | + x0, x1, x2 = frequency_range[index_max_power - 1 : index_max_power + 2] |
| 237 | + # Parabolic interpolation formula: |
| 238 | + denom = y0 - 2 * y1 + y2 |
| 239 | + if denom != 0: |
| 240 | + delta = 0.5 * (y0 - y2) / denom |
| 241 | + estimated_rate = x1 + delta * (x2 - x0) / 2 |
| 242 | + else: |
| 243 | + estimated_rate = x1 |
| 244 | + else: |
| 245 | + estimated_rate: float = frequency_range[index_max_power] |
| 246 | + return estimated_rate |
| 247 | + |
| 248 | + @property |
| 249 | + def plotting(self) -> "RateDetectionPlotting": |
| 250 | + """A utility class for plotting the the results of the RateDetection algorithm. |
| 251 | +
|
| 252 | + The `plotting.plot(**captures)` method can be used to plot the results of the algorithm. It takes the captured |
| 253 | + variables from the `apply` method as keyword arguments. |
| 254 | +
|
| 255 | + Example: |
| 256 | + ```python |
| 257 | + >>> rd = RateDetection("adult") |
| 258 | + >>> captures = {} |
| 259 | + >>> estimated_respiratory_rate, estimated_heart_rate = rd.detect_respiratory_heart_rate(eit_data, captures) |
| 260 | + >>> fig = rd.plotting(**captures) |
| 261 | + >>> fig.savefig(...) |
| 262 | + ``` |
| 263 | +
|
| 264 | + """ |
| 265 | + from eitprocessing.plotting.rate_detection import RateDetectionPlotting |
| 266 | + |
| 267 | + return RateDetectionPlotting(self) |
| 268 | + |
| 269 | + |
| 270 | +DEFAULT_RATE_LIMITS = { |
| 271 | + "min_heart_rate": {"adult": 40 / MINUTE, "neonate": 90 / MINUTE}, |
| 272 | + "max_heart_rate": {"adult": 200 / MINUTE, "neonate": 210 / MINUTE}, |
| 273 | + "min_respiratory_rate": {"adult": 6 / MINUTE, "neonate": 15 / MINUTE}, |
| 274 | + "max_respiratory_rate": {"adult": 60 / MINUTE, "neonate": 85 / MINUTE}, |
| 275 | +} |
0 commit comments