Skip to content

Commit bf8a284

Browse files
authored
Merge pull request #405 from EIT-ALIVE/feature/211_rr_hr_detection_psomhorst
Add respiratory and heart rate detection
2 parents ceb3b91 + 5bf3852 commit bf8a284

15 files changed

Lines changed: 1130 additions & 16 deletions

File tree

.github/workflows/test_build_documentation.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ jobs:
2828
extract-data: false
2929
python-version: "3.10"
3030
- name: Build documentation
31-
run: mkdocs build
31+
run: |
32+
ln -s ../../notebooks docs/examples
33+
mkdocs build
3234
- name: Deploy documentation
3335
if: github.ref == 'refs/heads/main'
3436
run: mkdocs gh-deploy

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ venv3
4141
uv.lock
4242

4343
worktrees
44+
docs/examples/notebooks

docs/api/features.md

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: eitprocessing.features.breath_detection.BreathDetection
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: eitprocessing.features.moving_average.MovingAverage

docs/api/features/pixel_breath.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: eitprocessing.features.pixel_breath.PixelBreath
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: eitprocessing.features.rate_detection.RateDetection

docs/examples/.gitkeep

Whitespace-only changes.
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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

Comments
 (0)