Skip to content

Commit 4464c84

Browse files
authored
Merge pull request #424 from nw-duncan/test-ge
Add GE scanner physiological file functionality
2 parents 716bf1b + 635cb56 commit 4464c84

12 files changed

Lines changed: 407 additions & 12 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,4 @@ dmypy.json
131131
docs/generated/
132132
.vscode/
133133
phys2bids/tests/data/*
134+
/phys2bids/tests/data/

phys2bids/io.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,91 @@ def load_mat(filename, chtrig=0):
419419
t_ch = np.ogrid[0:duration:interval][:-1]
420420
timeseries = [t_ch, ] + timeseries
421421
return BlueprintInput(timeseries, freq, names, units, chtrig)
422+
423+
424+
def load_gep(filename):
425+
"""
426+
Populate object phys_input from GE physiological files.
427+
428+
Uses the filename that the user provides to find any matching inputs
429+
from other recording types (PPG, RESP, or ECG).
430+
431+
Populates physio_obj with all identified recording types (note that one
432+
or more of these may not be true recordings as the scanner outputs all
433+
possible types in all cases). The modality corresponding to the filename
434+
entered by the user is put first (after time and trigger).
435+
436+
Parameters
437+
----------
438+
filename: str
439+
path to the GE scanner physiological file
440+
441+
Returns
442+
-------
443+
BlueprintInput
444+
445+
Note
446+
----
447+
448+
GE physiological files do not record a trigger so a column is added at
449+
position 1. This has a value of zero up to the scan start time and then
450+
a value of one for the duration of the scan.
451+
452+
See Also
453+
--------
454+
physio_obj.BlueprintInput
455+
"""
456+
import os
457+
from glob import glob
458+
from pathlib import Path
459+
460+
# Inititate lists of column names and units with time and trigger
461+
names = ['time', 'trigger']
462+
units = ['s', 'mV'] # Assuming recording units are mV...
463+
464+
# Add column for file given by user
465+
if 'PPGData' in filename:
466+
freq = [100, 100, 100]
467+
names.append('cardiac')
468+
elif 'RESPData' in filename:
469+
freq = [25, 25, 25]
470+
names.append('respiratory')
471+
elif 'ECGData' in filename:
472+
freq = [1000, 1000, 1000]
473+
names.append('cardiac')
474+
475+
# Load in user file data
476+
data = [np.loadtxt(filename)]
477+
478+
# Calculate time in seconds for first input (starts from -30s)
479+
interval = 1 / freq[0]
480+
duration = data[0].shape[0] * interval
481+
t_ch = np.ogrid[-30:duration - 30:interval]
482+
483+
# Find and add additional data files
484+
filename = Path(filename)
485+
fnames = glob(os.path.join(filename.parent, f'*{filename.name[-24:-4]}.gep'))
486+
fnames.remove(str(filename)) # Drop the original file
487+
if not len(fnames) == 0:
488+
for fname in fnames:
489+
if 'PPGData' in fname:
490+
freq.append(100)
491+
names.append('cardiac')
492+
data.append(np.loadtxt(fname))
493+
elif 'RESPData' in fname:
494+
freq.append(25)
495+
names.append('respiratory')
496+
data.append(np.loadtxt(fname))
497+
elif 'ECGData' in fname:
498+
freq.append(1000)
499+
names.append('cardiac')
500+
data.append(np.loadtxt(fname))
501+
502+
# Create trigger channel
503+
trigger = np.hstack((np.zeros(int(30 / interval)),
504+
np.ones(int((duration - 30) / interval))))
505+
506+
# Create final list of timeseries
507+
timeseries = [t_ch, trigger]
508+
timeseries.extend(data)
509+
return BlueprintInput(timeseries, freq, names, units, 1)

phys2bids/phys2bids.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,10 @@ def phys2bids(filename, info=False, indir='.', outdir='.', heur_file=None,
192192
# Check options to make them internally coherent pt. II
193193
# #!# This can probably be done while parsing?
194194
indir = os.path.abspath(indir)
195-
if chtrig < 0:
195+
if chtrig and chtrig < 0:
196196
raise RuntimeError('Wrong trigger channel. Channel indexing starts with 0!')
197+
198+
utils.check_ge(filename, indir)
197199
filename, ftype = utils.check_input_type(filename,
198200
indir)
199201

@@ -227,6 +229,9 @@ def phys2bids(filename, info=False, indir='.', outdir='.', heur_file=None,
227229
elif ftype == 'mat':
228230
from phys2bids.io import load_mat
229231
phys_in = load_mat(infile, chtrig)
232+
elif ftype == 'gep':
233+
from phys2bids.io import load_gep
234+
phys_in = load_gep(infile)
230235

231236
LGR.info('Checking that units of measure are BIDS compatible')
232237
for index, unit in enumerate(phys_in.units):

phys2bids/physio_obj.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def has_size(var, data_size, token):
5656
"""
5757
Check that the var has the same dimension of the data.
5858
59-
If it's not the case, fill in the var or removes exceding var entry.
59+
If it's not the case, fill in the var or removes exceeding var entry.
6060
6161
Parameters
6262
----------
@@ -239,18 +239,20 @@ def __init__(self, timeseries, freq, ch_name, units, trigger_idx,
239239
self.ch_amount, 0.0))
240240
self.ch_name = deepcopy(has_size(ch_name, self.ch_amount, 'unknown'))
241241
self.units = deepcopy(has_size(units, self.ch_amount, '[]'))
242+
242243
self.trigger_idx = deepcopy(is_valid(trigger_idx, int))
243-
self.num_timepoints_found = deepcopy(num_timepoints_found)
244-
self.thr = deepcopy(thr)
245-
self.time_offset = deepcopy(time_offset)
246-
self._time_resampled_to_trigger = None
247244
if trigger_idx == 0:
248245
self.auto_trigger_selection()
249246
else:
250247
if ch_name[trigger_idx] not in TRIGGER_NAMES:
251248
LGR.info('Trigger channel name is not in our trigger channel name alias list. '
252249
'Please make sure you choose the proper channel.')
253250

251+
self.num_timepoints_found = deepcopy(num_timepoints_found)
252+
self.thr = deepcopy(thr)
253+
self.time_offset = deepcopy(time_offset)
254+
self._time_resampled_to_trigger = None
255+
254256
@property
255257
def ch_amount(self):
256258
"""

phys2bids/tests/conftest.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,43 @@ def matlab_file_labchart(testpath):
106106
def matlab_file_acq(testpath):
107107
return fetch_file('mc96w', testpath,
108108
'Test_belt_pulse_multifreq.mat')
109+
110+
@pytest.fixture
111+
def ge_one_gep_file(testpath):
112+
return fetch_file('wb84d', testpath,
113+
'PPGData_epiRT_0000000000_00_00_000.gep')
114+
115+
@pytest.fixture
116+
def ge_two_gep_files_ppg(testpath):
117+
tmp = fetch_file('qawjv', testpath,
118+
'RESPData_epiRT_0000000000_00_00_000.gep')
119+
return fetch_file('wb84d', testpath,
120+
'PPGData_epiRT_0000000000_00_00_000.gep')
121+
122+
@pytest.fixture
123+
def ge_two_gep_files_resp(testpath):
124+
tmp = fetch_file('wb84d', testpath,
125+
'PPGData_epiRT_0000000000_00_00_000.gep')
126+
return fetch_file('qawjv', testpath,
127+
'RESPData_epiRT_0000000000_00_00_000.gep')
128+
129+
@pytest.fixture
130+
def ge_one_raw_file(testpath):
131+
return fetch_file('u9wsr', testpath,
132+
'PPGData_epiRT_0000000000_00_00_000')
133+
134+
@pytest.fixture
135+
def ge_two_raw_files(testpath):
136+
tmp = fetch_file('49xpw', testpath,
137+
'RESPData_epiRT_0000000000_00_00_000')
138+
return fetch_file('u9wsr', testpath,
139+
'PPGData_epiRT_0000000000_00_00_000')
140+
141+
@pytest.fixture
142+
def ge_badfiles(testpath):
143+
tmp = fetch_file('tdmyn', testpath,
144+
'PPGData_epiRT_columnscsv_00_00_000')
145+
tmp = fetch_file('b6skq', testpath,
146+
'PPGData_epiRT_columnstsv_00_00_000')
147+
return fetch_file('8235b', testpath,
148+
'PPGData_epiRT_string0000_00_00_000')

phys2bids/tests/test_integration.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,112 @@ def test_integration_multirun(skip_integration, multi_run_file):
236236
# for run in ['1', '2']:
237237
# assert isfile(join(conversion_path, f'Test2_samefreq_TWOscans_{run}_trigger_time.png'))
238238
assert isfile(join(conversion_path, 'Test2_samefreq_TWOscans.png'))
239+
240+
def test_integration_gep_onefile(skip_integration, ge_one_gep_file):
241+
"""
242+
Does the integration test for a single GE file
243+
Input file is PPG
244+
"""
245+
246+
if skip_integration:
247+
pytest.skip('Skipping integration test')
248+
249+
test_path, test_filename = split(ge_one_gep_file)
250+
conversion_path = join(test_path, 'code', 'conversion')
251+
252+
phys2bids(filename=test_filename, indir=test_path, outdir=test_path)
253+
254+
# Check that files are generated
255+
for suffix in ['.json', '.tsv.gz']:
256+
assert isfile(join(test_path, test_filename[:-4] + suffix))
257+
258+
# Check files in extra are generated
259+
for suffix in ['.log']:
260+
assert isfile(join(conversion_path, test_filename[:-4] + suffix))
261+
262+
# Read log file (note that this file is not the logger file)
263+
with open(join(conversion_path, test_filename[:-4] + '.log')) as log_info:
264+
log_info = log_info.readlines()
265+
266+
# Check timepoints expected
267+
assert check_string(log_info, 'Timepoints expected', 'None', is_num=False)
268+
# Check timepoints found
269+
assert check_string(log_info, 'Timepoints found', 'None', is_num=False)
270+
# Check sampling frequency
271+
assert check_string(log_info, 'Sampling Frequency', '100')
272+
# Check sampling started
273+
assert check_string(log_info, 'Sampling started', '30.0000')
274+
# Check start time
275+
assert check_string(log_info, 'first trigger', 'Time 0', is_num=False)
276+
277+
# Checks json file
278+
with open(join(test_path, test_filename[:-4] + '.json')) as json_file:
279+
json_data = json.load(json_file)
280+
281+
# Compares values in json file with ground truth
282+
assert math.isclose(json_data['SamplingFrequency'], 100)
283+
assert math.isclose(json_data['StartTime'], 30.0)
284+
assert json_data['Columns'] == ['time', 'trigger', 'cardiac']
285+
286+
# Remove generated files
287+
for filename in glob.glob(join(conversion_path, 'phys2bids*')):
288+
remove(filename)
289+
for filename in glob.glob(join(test_path, test_filename+'*')):
290+
remove(filename)
291+
shutil.rmtree(conversion_path)
292+
293+
294+
def test_integration_gep_multifile(skip_integration, ge_two_gep_files_ppg):
295+
"""
296+
Does the integration test for a set of two GE files
297+
Input file is PPG with RESP file also in folder
298+
"""
299+
300+
if skip_integration:
301+
pytest.skip('Skipping integration test')
302+
303+
test_path, test_filename = split(ge_two_gep_files_ppg)
304+
conversion_path = join(test_path, 'code', 'conversion')
305+
306+
phys2bids(filename=test_filename, indir=test_path, outdir=test_path)
307+
308+
# Check that files are generated
309+
for suffix in ['.json', '.tsv.gz']:
310+
assert isfile(join(test_path, test_filename[:-4] + '_100Hz' + suffix))
311+
assert isfile(join(test_path, test_filename[:-4] + '_25Hz' + suffix))
312+
313+
# Check files in extra are generated
314+
for suffix in ['.log']:
315+
assert isfile(join(conversion_path, test_filename[:-4] + '_100Hz' + suffix))
316+
assert isfile(join(conversion_path, test_filename[:-4] + '_25Hz' + suffix))
317+
318+
# Read log file (note that this file is not the logger file)
319+
with open(join(conversion_path, test_filename[:-4] + '_100Hz.log')) as log_info:
320+
log_info = log_info.readlines()
321+
322+
# Check timepoints expected
323+
assert check_string(log_info, 'Timepoints expected', 'None', is_num=False)
324+
# Check timepoints found
325+
assert check_string(log_info, 'Timepoints found', 'None', is_num=False)
326+
# Check sampling frequency
327+
assert check_string(log_info, 'Sampling Frequency', '100')
328+
# Check sampling started
329+
assert check_string(log_info, 'Sampling started', '30.0000')
330+
# Check start time
331+
assert check_string(log_info, 'first trigger', 'Time 0', is_num=False)
332+
333+
# Checks json file
334+
with open(join(test_path, test_filename[:-4] + '_100Hz.json')) as json_file:
335+
json_data = json.load(json_file)
336+
337+
# Compares values in json file with ground truth
338+
assert math.isclose(json_data['SamplingFrequency'], 100)
339+
assert math.isclose(json_data['StartTime'], 30.0)
340+
assert json_data['Columns'] == ['time', 'trigger', 'cardiac']
341+
342+
# Remove generated files
343+
for filename in glob.glob(join(conversion_path, 'phys2bids*')):
344+
remove(filename)
345+
for filename in glob.glob(join(test_path, test_filename+'*')):
346+
remove(filename)
347+
shutil.rmtree(conversion_path)

phys2bids/tests/test_io.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from phys2bids import io
22

3+
import os
34
import math
4-
5+
import numpy as np
56
import pytest
67
from pytest import raises
78

@@ -169,4 +170,41 @@ def test_load_mat(matlab_file_labchart, matlab_file_acq):
169170
# checks that the trigger is in the right channel
170171
assert phys_obj.ch_name[chtrig] == 'MR TRIGGER - Custom, HLT100C - A 5'
171172
assert phys_obj.freq[chtrig] == 10000.0
172-
assert phys_obj.units[chtrig] == 'Volts'
173+
assert phys_obj.units[chtrig] == 'Volts'
174+
175+
176+
# Check single GE file is loaded correctly
177+
# To read downloaded files in conftest, use the same name of the fixture function
178+
def test_load_gep_one_file(ge_one_gep_file):
179+
# Load data
180+
phys_obj = io.load_gep(ge_one_gep_file)
181+
182+
# Check the channel data is as expected
183+
gep_data = np.loadtxt(ge_one_gep_file)
184+
assert np.array_equal(gep_data, phys_obj.timeseries[2])
185+
186+
187+
# Check two GE files are loaded correctly, PPG user defined
188+
def test_load_gep_two_files_ppg(ge_two_gep_files_ppg, testpath):
189+
# Load data
190+
phys_obj = io.load_gep(ge_two_gep_files_ppg)
191+
192+
# Check the channel data is as expected
193+
gep_data1 = np.loadtxt(ge_two_gep_files_ppg)
194+
gep_data2 = np.loadtxt(os.path.join(testpath,
195+
'RESPData_epiRT_0000000000_00_00_000.gep'))
196+
assert np.array_equal(gep_data1, phys_obj.timeseries[2])
197+
assert np.array_equal(gep_data2, phys_obj.timeseries[3])
198+
199+
200+
# Check two GE files are loaded correctly, RESP user defined
201+
def test_load_gep_two_files_resp(ge_two_gep_files_resp, testpath):
202+
# Load data
203+
phys_obj = io.load_gep(ge_two_gep_files_resp)
204+
205+
# Check the channel data is as expected
206+
gep_data1 = np.loadtxt(ge_two_gep_files_resp)
207+
gep_data2 = np.loadtxt(os.path.join(testpath,
208+
'PPGData_epiRT_0000000000_00_00_000.gep'))
209+
assert np.array_equal(gep_data1, phys_obj.timeseries[2])
210+
assert np.array_equal(gep_data2, phys_obj.timeseries[3])

0 commit comments

Comments
 (0)