Skip to content

Commit 772ab8e

Browse files
author
Stefano Moia
authored
Merge pull request #32 from tsalo/tests
Add smoke tests for metrics and fix miscellaneous bugs
2 parents cfced3b + df0a85b commit 772ab8e

File tree

4 files changed

+183
-76
lines changed

4 files changed

+183
-76
lines changed

phys2denoise/metrics/cardiac.py

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def _crf(t):
6161
return crf_arr
6262

6363

64-
def cardiac_phase(peaks, slice_timings, n_scans, t_r):
64+
def cardiac_phase(peaks, sample_rate, slice_timings, n_scans, t_r):
6565
"""Calculate cardiac phase from cardiac peaks.
6666
6767
Assumes that timing of cardiac events are given in same units
@@ -71,6 +71,8 @@ def cardiac_phase(peaks, slice_timings, n_scans, t_r):
7171
----------
7272
peaks : 1D array_like
7373
Cardiac peak times, in seconds.
74+
sample_rate : float
75+
Sample rate of physio, in Hertz.
7476
slice_timings : 1D array_like
7577
Slice times, in seconds.
7678
n_scans : int
@@ -81,31 +83,37 @@ def cardiac_phase(peaks, slice_timings, n_scans, t_r):
8183
Returns
8284
-------
8385
phase_card : array_like
84-
Cardiac phase signal.
86+
Cardiac phase signal, of shape (n_scans,)
8587
"""
86-
n_slices = np.shape(slice_timings)
87-
phase_card = np.zeros(n_scans)
88+
assert slice_timings.ndim == 1, "Slice times must be a 1D array"
89+
n_slices = np.size(slice_timings)
90+
phase_card = np.zeros((n_scans, n_slices))
8891

92+
card_peaks_sec = peaks / sample_rate
8993
for i_slice in range(n_slices):
90-
# find previous cardiac peaks
91-
previous_card_peaks = np.asarray(np.nonzero(peaks < slice_timings[i_slice]))
92-
if np.size(previous_card_peaks) == 0:
93-
t1 = 0
94-
else:
95-
last_peak = previous_card_peaks[0][-1]
96-
t1 = peaks[last_peak]
97-
98-
# find posterior cardiac peaks
99-
next_card_peaks = np.asarray(np.nonzero(peaks > slice_timings[i_slice]))
100-
if np.size(next_card_peaks) == 0:
101-
t2 = n_scans * t_r
102-
else:
103-
next_peak = next_card_peaks[0][0]
104-
t2 = peaks[next_peak]
105-
106-
# compute cardiac phase
107-
phase_card[i_slice] = (2 * np.math.pi * (slice_timings[i_slice] - t1)) / (
108-
t2 - t1
109-
)
94+
# generate slice acquisition timings across all scans
95+
times_crSlice = t_r * np.arange(n_scans) + slice_timings[i_slice]
96+
phase_card_crSlice = np.zeros(n_scans)
97+
for j_scan in range(n_scans):
98+
previous_card_peaks = np.asarray(
99+
np.nonzero(card_peaks_sec < times_crSlice[j_scan])
100+
)
101+
if np.size(previous_card_peaks) == 0:
102+
t1 = 0
103+
else:
104+
last_peak = previous_card_peaks[0][-1]
105+
t1 = card_peaks_sec[last_peak]
106+
next_card_peaks = np.asarray(
107+
np.nonzero(card_peaks_sec > times_crSlice[j_scan])
108+
)
109+
if np.size(next_card_peaks) == 0:
110+
t2 = n_scans * t_r
111+
else:
112+
next_peak = next_card_peaks[0][0]
113+
t2 = card_peaks_sec[next_peak]
114+
phase_card_crSlice[j_scan] = (
115+
2 * np.math.pi * (times_crSlice[j_scan] - t1)
116+
) / (t2 - t1)
117+
phase_card[:, i_slice] = phase_card_crSlice
110118

111119
return phase_card

phys2denoise/metrics/chest_belt.py

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import numpy as np
44
import pandas as pd
55
from scipy.ndimage.filters import convolve1d
6-
from scipy.signal import detrend, resample
6+
from scipy.signal import detrend
77
from scipy.stats import zscore
88

99
mpl.use("TkAgg")
@@ -15,17 +15,18 @@
1515

1616

1717
@due.dcite(references.POWER_2018)
18-
def rpv(belt_ts, window):
18+
def rpv(resp, window=6):
1919
"""Calculate respiratory pattern variability.
2020
2121
Parameters
2222
----------
23-
belt_ts
24-
window
23+
resp : 1D array_like
24+
window : int
2525
2626
Returns
2727
-------
28-
rpv_arr
28+
rpv_val : float
29+
Respiratory pattern variability value.
2930
3031
Notes
3132
-----
@@ -43,35 +44,29 @@ def rpv(belt_ts, window):
4344
115, pp. 2105-2114, 2018.
4445
"""
4546
# First, z-score respiratory traces
46-
resp_z = zscore(belt_ts)
47+
resp_z = zscore(resp)
4748

4849
# Collect upper envelope
4950
rpv_upper_env = utils.rms_envelope_1d(resp_z, window)
5051

5152
# Calculate standard deviation
52-
rpv_arr = np.std(rpv_upper_env)
53-
return rpv_arr
53+
rpv_val = np.std(rpv_upper_env)
54+
return rpv_val
5455

5556

5657
@due.dcite(references.POWER_2020)
57-
def env(belt_ts, samplerate, out_samplerate, window=10, lags=(0,)):
58+
def env(resp, samplerate, window=10):
5859
"""Calculate respiratory pattern variability across a sliding window.
5960
6061
Parameters
6162
----------
62-
belt_ts : (X,) :obj:`numpy.ndarray`
63+
resp : (X,) :obj:`numpy.ndarray`
6364
A 1D array with the respiratory belt time series.
6465
samplerate : :obj:`float`
65-
Sampling rate for belt_ts, in Hertz.
66-
out_samplerate : :obj:`float`
67-
Sampling rate for the output time series in seconds.
68-
Corresponds to TR in fMRI data.
66+
Sampling rate for resp, in Hertz.
6967
window : :obj:`int`, optional
70-
Size of the sliding window, in the same units as out_samplerate.
71-
Default is 6.
72-
lags : (Y,) :obj:`tuple` of :obj:`int`, optional
73-
List of lags to apply to the rv estimate. In the same units as
74-
out_samplerate.
68+
Size of the sliding window, in seconds.
69+
Default is 10.
7570
7671
Returns
7772
-------
@@ -92,42 +87,37 @@ def env(belt_ts, samplerate, out_samplerate, window=10, lags=(0,)):
9287
young adults scanned at rest, including systematic changes and 'missed'
9388
deep breaths," Neuroimage, vol. 204, 2020.
9489
"""
95-
window = window * samplerate / out_samplerate
90+
# Convert window to Hertz
91+
window = int(window * samplerate)
92+
9693
# Calculate RPV across a rolling window
9794
env_arr = (
98-
pd.Series(belt_ts).rolling(window=window, center=True).apply(rpv, window=window)
95+
pd.Series(resp).rolling(window=window, center=True).apply(rpv, args=(window,))
9996
)
10097
env_arr[np.isnan(env_arr)] = 0.0
10198
return env_arr
10299

103100

104101
@due.dcite(references.CHANG_GLOVER_2009)
105-
def rv(belt_ts, samplerate, out_samplerate, window=6, lags=(0,)):
102+
def rv(resp, samplerate, window=6, lags=(0,)):
106103
"""Calculate respiratory variance.
107104
108105
Parameters
109106
----------
110-
belt_ts : (X,) :obj:`numpy.ndarray`
107+
resp : (X,) :obj:`numpy.ndarray`
111108
A 1D array with the respiratory belt time series.
112109
samplerate : :obj:`float`
113-
Sampling rate for belt_ts, in Hertz.
114-
out_samplerate : :obj:`float`
115-
Sampling rate for the output time series in seconds.
116-
Corresponds to TR in fMRI data.
110+
Sampling rate for resp, in Hertz.
117111
window : :obj:`int`, optional
118-
Size of the sliding window, in the same units as out_samplerate.
112+
Size of the sliding window, in seconds.
119113
Default is 6.
120-
lags : (Y,) :obj:`tuple` of :obj:`int`, optional
121-
List of lags to apply to the rv estimate. In the same units as
122-
out_samplerate.
123114
124115
Returns
125116
-------
126-
rv_out : (Z, 2Y) :obj:`numpy.ndarray`
127-
Respiratory variance values, with lags applied, downsampled to
128-
out_samplerate, convolved with an RRF, and detrended/normalized.
129-
The first Y columns are not convolved with the RRF, while the second Y
130-
columns are.
117+
rv_out : (X, 2) :obj:`numpy.ndarray`
118+
Respiratory variance values.
119+
The first column is raw RV values, after detrending/normalization.
120+
The second column is RV values convolved with the RRF, after detrending/normalization.
131121
132122
Notes
133123
-----
@@ -145,25 +135,19 @@ def rv(belt_ts, samplerate, out_samplerate, window=6, lags=(0,)):
145135
end-tidal CO2, and BOLD signals in resting-state fMRI," Neuroimage,
146136
issue 4, vol. 47, pp. 1381-1393, 2009.
147137
"""
138+
# Convert window to Hertz
139+
window = int(window * samplerate)
140+
148141
# Raw respiratory variance
149-
rv_arr = pd.Series(belt_ts).rolling(window=window, center=True).std()
142+
rv_arr = pd.Series(resp).rolling(window=window, center=True).std()
150143
rv_arr[np.isnan(rv_arr)] = 0.0
151144

152-
# Apply lags
153-
n_out_samples = int((belt_ts.shape[0] / samplerate) / out_samplerate)
154-
# convert lags from out_samplerate to samplerate
155-
delays = [abs(int(lag * samplerate)) for lag in lags]
156-
rv_with_lags = utils.apply_lags(rv_arr, lags=delays)
157-
158-
# Downsample to out_samplerate
159-
rv_with_lags = resample(rv_with_lags, num=n_out_samples, axis=0)
160-
161145
# Convolve with rrf
162-
rrf_arr = rrf(out_samplerate, oversampling=1)
163-
rv_convolved = convolve1d(rv_with_lags, rrf_arr, axis=0)
146+
rrf_arr = rrf(samplerate, oversampling=1)
147+
rv_convolved = convolve1d(rv_arr, rrf_arr, axis=0)
164148

165149
# Concatenate the raw and convolved versions
166-
rv_combined = np.hstack((rv_with_lags, rv_convolved))
150+
rv_combined = np.stack((rv_arr, rv_convolved), axis=-1)
167151

168152
# Detrend and normalize
169153
rv_combined = rv_combined - np.mean(rv_combined, axis=0)
@@ -240,14 +224,15 @@ def respiratory_phase(resp, sample_rate, n_scans, slice_timings, t_r):
240224
Returns
241225
-------
242226
phase_resp : array_like
243-
Respiratory phase signal.
227+
Respiratory phase signal, of shape (n_scans, n_slices).
244228
"""
245-
n_slices = np.shape(slice_timings)
229+
assert slice_timings.ndim == 1, "Slice times must be a 1D array"
230+
n_slices = np.size(slice_timings)
246231
phase_resp = np.zeros((n_scans, n_slices))
247232

248233
# generate histogram from respiratory signal
249234
# TODO: Replace with numpy.histogram
250-
resp_hist, resp_hist_bins = plt.hist(resp, bins=100)
235+
resp_hist, resp_hist_bins, _ = plt.hist(resp, bins=100)
251236

252237
# first compute derivative of respiration signal
253238
resp_diff = np.diff(resp, n=1)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Tests for phys2denoise.metrics.cardiac."""
2+
import numpy as np
3+
4+
from phys2denoise.metrics import cardiac
5+
6+
7+
def test_crf_smoke():
8+
"""Basic smoke test for CRF calculation."""
9+
samplerate = 0.01 # in seconds
10+
oversampling = 20
11+
time_length = 20
12+
onset = 0
13+
tr = 0.72
14+
crf_arr = cardiac.crf(
15+
samplerate,
16+
oversampling=oversampling,
17+
time_length=time_length,
18+
onset=onset,
19+
tr=tr,
20+
)
21+
pred_len = np.rint(time_length / (tr / oversampling))
22+
assert crf_arr.ndim == 1
23+
assert crf_arr.shape == pred_len
24+
25+
26+
def test_cardiac_phase_smoke():
27+
"""Basic smoke test for cardiac phase calculation."""
28+
t_r = 1.0
29+
n_scans = 200
30+
sample_rate = 1 / 0.01
31+
slice_timings = np.linspace(0, t_r, 22)[1:-1]
32+
peaks = np.array([0.534, 0.577, 10.45, 20.66, 50.55, 90.22])
33+
card_phase = cardiac.cardiac_phase(
34+
peaks,
35+
sample_rate=sample_rate,
36+
slice_timings=slice_timings,
37+
n_scans=n_scans,
38+
t_r=t_r,
39+
)
40+
assert card_phase.ndim == 2
41+
assert card_phase.shape == (n_scans, slice_timings.size)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Tests for phys2denoise.metrics.chest_belt."""
2+
import numpy as np
3+
4+
from phys2denoise.metrics import chest_belt
5+
6+
7+
def test_rrf_smoke():
8+
"""Basic smoke test for RRF calculation."""
9+
samplerate = 0.01 # in seconds
10+
oversampling = 20
11+
time_length = 20
12+
onset = 0
13+
tr = 0.72
14+
rrf_arr = chest_belt.rrf(
15+
samplerate,
16+
oversampling=oversampling,
17+
time_length=time_length,
18+
onset=onset,
19+
tr=tr,
20+
)
21+
pred_len = int(np.rint(time_length / (tr / oversampling)))
22+
assert rrf_arr.ndim == 1
23+
assert rrf_arr.size == pred_len
24+
25+
26+
def test_respiratory_phase_smoke():
27+
"""Basic smoke test for respiratory phase calculation."""
28+
t_r = 1.0
29+
n_scans = 200
30+
sample_rate = 1 / 0.01
31+
slice_timings = np.linspace(0, t_r, 22)[1:-1]
32+
n_samples = int(np.rint((n_scans * t_r) * sample_rate))
33+
resp = np.random.normal(size=n_samples)
34+
resp_phase = chest_belt.respiratory_phase(
35+
resp,
36+
sample_rate=sample_rate,
37+
slice_timings=slice_timings,
38+
n_scans=n_scans,
39+
t_r=t_r,
40+
)
41+
assert resp_phase.ndim == 2
42+
assert resp_phase.shape == (n_scans, slice_timings.size)
43+
44+
45+
def test_rpv_smoke():
46+
"""Basic smoke test for respiratory pattern variability calculation."""
47+
n_samples = 2000
48+
resp = np.random.normal(size=n_samples)
49+
window = 50
50+
rpv_val = chest_belt.rpv(resp, window)
51+
assert isinstance(rpv_val, float)
52+
53+
54+
def test_env_smoke():
55+
"""Basic smoke test for ENV calculation."""
56+
n_samples = 2000
57+
resp = np.random.normal(size=n_samples)
58+
samplerate = 1 / 0.01
59+
window = 6
60+
env_arr = chest_belt.env(resp, samplerate=samplerate, window=window)
61+
assert env_arr.ndim == 1
62+
assert env_arr.shape == (n_samples,)
63+
64+
65+
def test_rv_smoke():
66+
"""Basic smoke test for respiratory variance calculation."""
67+
n_samples = 2000
68+
resp = np.random.normal(size=n_samples)
69+
samplerate = 1 / 0.01
70+
window = 6
71+
rv_arr = chest_belt.rv(resp, samplerate=samplerate, window=window)
72+
assert rv_arr.ndim == 2
73+
assert rv_arr.shape == (n_samples, 2)

0 commit comments

Comments
 (0)