Skip to content

Commit ee36984

Browse files
committed
Update tests for MDNFilter
1 parent 7277b90 commit ee36984

1 file changed

Lines changed: 238 additions & 21 deletions

File tree

tests/test_mdn_filter.py

Lines changed: 238 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,252 @@
1+
import itertools
2+
from collections.abc import Callable
3+
from functools import partial
4+
15
import numpy as np
2-
import scipy as sp
6+
import pytest
7+
from scipy import signal
38

4-
from eitprocessing.filters.mdn import MDNFilter
9+
from eitprocessing.datahandling.sequence import Sequence
10+
from eitprocessing.filters.mdn import UPPER_HEART_RATE_LIMIT, UPPER_RESPIRATORY_RATE_LIMIT, MDNFilter
511

6-
SAMPLE_FREQUENCY = 20
712
MINUTE = 60
813

914

10-
def test_mdn_working():
11-
n_samples = 10000
12-
heart_rate = 70 / MINUTE
13-
respiratory_rate = 12 / MINUTE
15+
@pytest.fixture
16+
def periodogram() -> Callable:
17+
return partial(
18+
signal.periodogram,
19+
detrend="constant",
20+
return_onesided=True,
21+
scaling="density",
22+
)
23+
24+
25+
@pytest.fixture
26+
def signal_factory() -> Callable[..., np.ndarray]:
27+
def factory(
28+
*,
29+
high_power_frequencies: tuple[float, ...],
30+
low_power_frequencies: tuple[float, ...],
31+
duration: float = 60.0,
32+
sample_frequency: float = 20.0,
33+
low_power_amplitude: float = 0.2,
34+
noise_amplitude: float = 0.01,
35+
high_frequency_scale_factor: float = 1.0,
36+
low_frequency_scale_factor: float = 1.0,
37+
captures: dict | None = None,
38+
) -> np.ndarray:
39+
nframes = int(duration * sample_frequency)
40+
time = np.arange(nframes) / sample_frequency
41+
rng = np.random.default_rng()
42+
43+
# Generate base signals
44+
high_power_signal = np.zeros((nframes,))
45+
low_power_signal = np.zeros((nframes,))
46+
47+
for freq in high_power_frequencies:
48+
inst_freq = np.linspace(freq, freq * high_frequency_scale_factor, len(time))
49+
phase = 2 * np.pi * np.cumsum(inst_freq) / sample_frequency
50+
high_power_signal += rng.normal(loc=1, scale=0.1) * signal.sawtooth(phase, width=0.5)
51+
for freq in low_power_frequencies:
52+
inst_freq = np.linspace(freq, freq * low_frequency_scale_factor, len(time))
53+
phase = 2 * np.pi * np.cumsum(inst_freq) / sample_frequency
54+
low_power_signal += rng.normal(loc=1, scale=0.1) * signal.sawtooth(phase, width=0.5)
55+
56+
noise = rng.normal(loc=0, scale=noise_amplitude, size=(nframes,))
57+
58+
values = high_power_signal + low_power_amplitude * low_power_signal + noise
59+
60+
if captures is not None:
61+
captures["high_power_signal"] = high_power_signal
62+
captures["low_power_signal"] = low_power_signal
63+
captures["time"] = time
64+
captures["values"] = values
65+
66+
return values
67+
68+
return factory
69+
70+
71+
def test_respiratory_rate_above_limits():
72+
with pytest.warns(UserWarning, match=r"The provided respiratory rate \(.*\) is higher than .* Hz \(.* BPM\)"):
73+
_ = MDNFilter(
74+
respiratory_rate=UPPER_RESPIRATORY_RATE_LIMIT + 0.01,
75+
heart_rate=UPPER_HEART_RATE_LIMIT - 0.01,
76+
)
1477

15-
signal = np.random.normal(2.0, 1.0, n_samples)
1678

17-
mdn_filter = MDNFilter(sample_frequency=SAMPLE_FREQUENCY, heart_rate=heart_rate, respiratory_rate=respiratory_rate)
18-
filtered_signal = mdn_filter.apply_filter(signal)
79+
def test_heart_rate_above_limits():
80+
with pytest.warns(UserWarning, match=r"The provided heart rate \(.*\) is higher than .* Hz \(.* BPM\)"):
81+
_ = MDNFilter(
82+
respiratory_rate=UPPER_RESPIRATORY_RATE_LIMIT - 0.01,
83+
heart_rate=UPPER_HEART_RATE_LIMIT + 0.01,
84+
)
1985

20-
f, Pxx = sp.signal.welch(signal, fs=SAMPLE_FREQUENCY)
21-
filtered_f, filtered_Pxx = sp.signal.welch(filtered_signal, fs=SAMPLE_FREQUENCY)
2286

23-
current_frequency_center = heart_rate
24-
while current_frequency_center < mdn_filter.noise_frequency_limit:
25-
notch_distance = mdn_filter.notch_distance
26-
frequency_band = (f > current_frequency_center - notch_distance) & (
27-
f < current_frequency_center + notch_distance
87+
def test_respiratory_rate_higher_than_heart_rate():
88+
with pytest.raises(ValueError, match=r"The respiratory rate \(.* Hz\) is higher than the heart rate \(.* Hz\)"):
89+
_ = MDNFilter(
90+
respiratory_rate=UPPER_RESPIRATORY_RATE_LIMIT - 0.01,
91+
heart_rate=UPPER_RESPIRATORY_RATE_LIMIT - 0.02,
2892
)
29-
mean_power = np.mean(Pxx[frequency_band])
3093

31-
filtered_mean_power = np.mean(filtered_Pxx[frequency_band])
3294

33-
assert filtered_mean_power < mean_power
95+
def test_with_continuous_data(draeger1: Sequence):
96+
continuous_data = draeger1.continuous_data["global_impedance_(raw)"]
97+
mdn_filter = MDNFilter(
98+
respiratory_rate=10 / MINUTE,
99+
heart_rate=80 / MINUTE,
100+
)
101+
102+
filtered_data = mdn_filter.apply(continuous_data)
103+
filtered_signal = mdn_filter.apply(
104+
continuous_data.values, sample_frequency=continuous_data.sample_frequency, axis=0
105+
)
106+
107+
assert np.allclose(filtered_data.values, filtered_signal)
108+
109+
110+
def test_with_eit_data(draeger1: Sequence):
111+
eit_data = draeger1.eit_data["raw"]
112+
mdn_filter = MDNFilter(
113+
respiratory_rate=10 / MINUTE,
114+
heart_rate=80 / MINUTE,
115+
)
116+
117+
filtered_data = mdn_filter.apply(eit_data)
118+
filtered_signal = mdn_filter.apply(eit_data.pixel_impedance, sample_frequency=eit_data.sample_frequency, axis=0)
119+
120+
assert np.allclose(filtered_data.pixel_impedance, filtered_signal)
121+
122+
123+
@pytest.mark.parametrize(
124+
("respiratory_rate", "heart_rate", "sample_frequency", "order"),
125+
itertools.product(
126+
(10 / MINUTE, 20 / MINUTE, 30 / MINUTE), (80 / MINUTE, 120 / MINUTE, 160 / MINUTE), (20, 50.2), (1, 5, 10, 20)
127+
),
128+
)
129+
def test_with_numpy_different_frequencies(
130+
signal_factory: Callable,
131+
periodogram: Callable,
132+
respiratory_rate: float,
133+
heart_rate: float,
134+
sample_frequency: float,
135+
order: int,
136+
):
137+
signal = signal_factory(
138+
high_power_frequencies=(respiratory_rate,),
139+
low_power_frequencies=(heart_rate,),
140+
sample_frequency=sample_frequency,
141+
duration=120.0,
142+
)
143+
144+
mdn_filter = MDNFilter(
145+
respiratory_rate=respiratory_rate,
146+
heart_rate=heart_rate,
147+
order=order,
148+
)
149+
150+
filtered_signal = mdn_filter.apply(signal, sample_frequency=sample_frequency, captures=(captures := {}))
151+
152+
frequencies, power_unfiltered = periodogram(signal)
153+
_, power_filtered = periodogram(filtered_signal)
154+
155+
assert np.sum(power_filtered) < np.sum(power_unfiltered)
156+
157+
# Within the filtered frequency bands, power must be lower
158+
for lower_freq, higher_freq in [*captures["frequency_bands"]]:
159+
slice_ = slice(*np.searchsorted(frequencies, [lower_freq, higher_freq]))
160+
assert np.all(power_filtered[slice_] < power_unfiltered[slice_])
161+
162+
# Above the noise frequency limit, power must be lower
163+
slice_ = slice(np.searchsorted(frequencies, mdn_filter.noise_frequency_limit), None)
164+
assert np.all(power_filtered[slice_] < power_unfiltered[slice_])
165+
166+
# The lower frequency of the first filtered band is the normal notch distance away
167+
assert captures["frequency_bands"][0][0] == heart_rate - mdn_filter.notch_distance
168+
169+
assert len(captures["frequency_bands"]) == captures["n_harmonics"]
170+
171+
172+
def test_sample_frequency_not_provided():
173+
signal = np.random.default_rng().normal(size=1000)
174+
mdn_filter = MDNFilter(
175+
respiratory_rate=10 / MINUTE,
176+
heart_rate=80 / MINUTE,
177+
)
178+
with pytest.raises(ValueError, match="Sample frequency must be provided."):
179+
mdn_filter.apply(signal)
180+
181+
with pytest.raises(ValueError, match="Sample frequency must be provided."):
182+
mdn_filter.apply(signal, sample_frequency=None)
183+
184+
185+
def test_close_respiratory_and_heart_rate(signal_factory: Callable):
186+
heart_rate = 55 / MINUTE
187+
respiratory_rate = 40 / MINUTE
188+
signal = signal_factory(
189+
high_power_frequencies=(respiratory_rate,),
190+
low_power_frequencies=(heart_rate,),
191+
sample_frequency=50,
192+
)
193+
194+
mdn_filter = MDNFilter(respiratory_rate=respiratory_rate, heart_rate=heart_rate)
195+
_ = mdn_filter.apply(signal, sample_frequency=50, captures=(captures := {}))
196+
197+
assert captures["n_harmonics"] == 5
198+
199+
# With the rates this close, the first band start exactly between them
200+
assert captures["frequency_bands"][0][0] == np.mean([respiratory_rate, heart_rate])
201+
202+
mdn_filter = MDNFilter(respiratory_rate=respiratory_rate, heart_rate=heart_rate, notch_distance=5 / MINUTE)
203+
_ = mdn_filter.apply(signal, sample_frequency=50, captures=(captures := {}))
204+
205+
# With a smaller notch distance, the lower frequency is the notch distance away from the heart rate
206+
assert captures["frequency_bands"][0][0] == heart_rate - 5 / MINUTE
207+
208+
209+
def test_wrong_input_type_raises():
210+
mdn_filter = MDNFilter(
211+
respiratory_rate=10 / MINUTE,
212+
heart_rate=80 / MINUTE,
213+
)
214+
with pytest.raises(TypeError, match="Invalid input data type"):
215+
mdn_filter.apply("not a valid input type")
216+
217+
with pytest.raises(TypeError, match="Invalid input data type"):
218+
mdn_filter.apply(12345)
219+
220+
221+
def test_provide_sample_frequency_axis_with_datacontainers_raises(draeger1: Sequence):
222+
eit_data = draeger1.eit_data["raw"]
223+
continuous_data = draeger1.continuous_data["global_impedance_(raw)"]
224+
mdn_filter = MDNFilter(
225+
respiratory_rate=10 / MINUTE,
226+
heart_rate=80 / MINUTE,
227+
)
228+
229+
with pytest.raises(ValueError, match="Sample frequency should not be provided"):
230+
mdn_filter.apply(continuous_data, sample_frequency=50)
231+
232+
with pytest.raises(ValueError, match="Sample frequency should not be provided"):
233+
mdn_filter.apply(eit_data, sample_frequency=50)
234+
235+
with pytest.raises(ValueError, match="Axis should not be provided"):
236+
mdn_filter.apply(continuous_data, axis=0)
237+
238+
with pytest.raises(ValueError, match="Axis should not be provided"):
239+
mdn_filter.apply(eit_data, axis=0)
240+
241+
242+
def test_kwargs(draeger1: Sequence):
243+
eit_data = draeger1.eit_data["raw"]
244+
mdn_filter = MDNFilter(
245+
respiratory_rate=10 / MINUTE,
246+
heart_rate=80 / MINUTE,
247+
)
248+
249+
# Ensure that kwargs are passed through correctly
250+
filtered_data = mdn_filter.apply(eit_data, label="Filtered EIT Data")
34251

35-
current_frequency_center += heart_rate
252+
assert filtered_data.label == "Filtered EIT Data"

0 commit comments

Comments
 (0)