Repository: physiopy/phys2bids

Issue:
Add support for matlab files
## Detailed Description
<!--- Provide a detailed description of the change or addition you are proposing -->
We have some labchart-exported [matlab files in our OSF](https://osf.io/2j43t/). We could implement an interface function that reads matlab files, in order to support yet another type of data!

## Possible Implementation
<!--- Not obligatory, but suggest an idea for implementing addition or change -->
<!--- If you already have worked on the idea, please share a link to the branch in your forked project -->
- Look at the functions in [`interfaces`](https://github.com/physiopy/phys2bids/tree/master/phys2bids/interfaces)
- Using [scipy](https://docs.scipy.org/doc/scipy/reference/tutorial/io.html) or [h5py](https://www.h5py.org/) (or both), write an itnerface function to read Labchart data in matlab format. 
- Write a test for such interface function!

Discussion:

Patch:
diff --git a/phys2bids/io.py b/phys2bids/io.py
index a253857..e2a7c5c 100644
--- a/phys2bids/io.py
+++ b/phys2bids/io.py
@@ -337,3 +337,67 @@ def load_acq(filename, chtrig=0):
         names.append(ch.name)
 
     return BlueprintInput(timeseries, freq, names, units, chtrig)
+
+
+def load_mat(filename, chtrig=0):
+    """
+    Populate object phys_input from MATLAB files.
+
+    Parameters
+    ----------
+    filename: str
+        path to the txt labchart file
+    chtrig : int
+        index of trigger channel.
+        !!! ATTENTION: IT'S MEANT TO REPRESENT AN INDEX STARTING FROM 1 !!!
+
+    Returns
+    -------
+    BlueprintInput
+
+    Note
+    ----
+    chtrig is not a 0-based Python index - instead, it's human readable (i.e., 1-based).
+    This is handy because, when initialising the class, a new channel corresponding
+    to time is added at the beginning - that is already taken into account!
+
+    See Also
+    --------
+    physio_obj.BlueprintInput
+    """
+    # Load MATLAB file into dictionary.
+    from pymatreader import read_mat
+    mat_dict = read_mat(filename)
+    if '__header__' in mat_dict:
+        orig_names = list(mat_dict['labels'])
+        orig_units = list(mat_dict['units'])
+        interval = [mat_dict['isi'], mat_dict['isi_units']]
+        channel_list = mat_dict['data']
+        return generate_blueprint(channel_list, chtrig, interval, orig_units, orig_names)
+    else:
+        # Convert data into 1d numpy array for easier indexing.
+        data = np.squeeze(np.asarray(mat_dict['data']))
+
+        # Extract number of channels and tick rate.
+        n_channels = len(mat_dict['titles'])
+        t_freq = mat_dict['tickrate']
+
+        # Stores MATLAB data into lists.
+        timeseries = []
+        freq = [t_freq, ]
+        units = ['s', ]
+        names = ['time', ]
+
+        for ch in range(n_channels):
+            units.append(mat_dict['unittext'][int(mat_dict['unittextmap'][ch] - 1)].strip())
+            names.append(mat_dict['titles'][ch].strip())
+            freq.append(mat_dict['samplerate'][ch])
+            idx_start = int(mat_dict['datastart'][ch])
+            idx_end = int(mat_dict['dataend'][ch])
+            timeseries.append(data[idx_start:idx_end])
+        # Calculate duration based on frequency and create time channel.
+        interval = 1 / t_freq
+        duration = (timeseries[0].shape[0] + 1) * interval
+        t_ch = np.ogrid[0:duration:interval][:-1]
+        timeseries = [t_ch, ] + timeseries
+        return BlueprintInput(timeseries, freq, names, units, chtrig)
diff --git a/phys2bids/phys2bids.py b/phys2bids/phys2bids.py
index be8027d..c618695 100644
--- a/phys2bids/phys2bids.py
+++ b/phys2bids/phys2bids.py
@@ -224,6 +224,9 @@ def phys2bids(filename, info=False, indir='.', outdir='.', heur_file=None,
     elif ftype == 'txt':
         from phys2bids.io import load_txt
         phys_in = load_txt(infile, chtrig)
+    elif ftype == 'mat':
+        from phys2bids.io import load_mat
+        phys_in = load_mat(infile, chtrig)
 
     LGR.info('Checking that units of measure are BIDS compatible')
     for index, unit in enumerate(phys_in.units):
diff --git a/phys2bids/utils.py b/phys2bids/utils.py
index bc846e3..fa14950 100644
--- a/phys2bids/utils.py
+++ b/phys2bids/utils.py
@@ -8,7 +8,7 @@ from pathlib import Path
 
 LGR = logging.getLogger(__name__)
 
-SUPPORTED_FTYPES = ('acq', 'txt')  # 'mat', ...
+SUPPORTED_FTYPES = ('acq', 'txt', 'mat')
 
 
 def check_input_dir(indir):
diff --git a/setup.cfg b/setup.cfg
index 77f3cad..cafcfd9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -35,6 +35,8 @@ include_package_data = True
 [options.extras_require]
 acq =
     bioread >=1.0.5
+mat=
+    pymatreader >=0.0.24
 duecredit =
     duecredit
 doc =
@@ -44,13 +46,14 @@ doc =
 style =
     flake8 >=3.7
     flake8-docstrings >=1.5
+interfaces =
+    %(acq)s
+    %(mat)s
 test =
     pytest >=5.3
     pytest-cov
-    %(acq)s
+    %(interfaces)s
     %(style)s
-interfaces =
-    %(acq)s
 all =
     %(doc)s
     %(duecredit)s


Test patch:
diff --git a/phys2bids/tests/conftest.py b/phys2bids/tests/conftest.py
index c0bf0fe..ef8fb30 100644
--- a/phys2bids/tests/conftest.py
+++ b/phys2bids/tests/conftest.py
@@ -87,4 +87,15 @@ def notime_lab_file(testpath):
 @pytest.fixture
 def multi_run_file(testpath):
     return fetch_file('gvy84', testpath,
-                      'Test2_samefreq_TWOscans.txt')
\ No newline at end of file
+                      'Test2_samefreq_TWOscans.txt')
+
+@pytest.fixture
+def matlab_file_labchart(testpath):
+    return fetch_file('2j43t', testpath,
+                      'test_2minRest.mat')
+
+
+@pytest.fixture
+def matlab_file_acq(testpath):
+    return fetch_file('mc96w', testpath,
+                      'Test_belt_pulse_multifreq.mat')
diff --git a/phys2bids/tests/test_io.py b/phys2bids/tests/test_io.py
index f6a074c..bfe5cb2 100644
--- a/phys2bids/tests/test_io.py
+++ b/phys2bids/tests/test_io.py
@@ -126,3 +126,35 @@ def test_load_acq(samefreq_full_acq_file):
     assert phys_obj.ch_name[chtrig] == 'MR TRIGGER - Custom, HLT100C - A 5'
     assert phys_obj.freq[chtrig] == 10000.0
     assert phys_obj.units[chtrig] == 'Volts'
+
+
+def test_load_mat(matlab_file_labchart, matlab_file_acq):
+    # Read data to test labchart in mat exension
+    chtrig = 1
+    phys_obj = io.load_mat(matlab_file_labchart, chtrig)
+
+    # Check channel names are the same.
+    orig_channels = ['time', 'Trigger', 'CO2', 'O2', 'Belt', 'Pulse']
+    assert phys_obj.ch_name == orig_channels
+
+    # Check frequencies are the same.
+    orig_freq = [1000.0, 1000.0, 100.0, 40.0, 400.0, 1000.0]
+    assert phys_obj.freq == orig_freq
+
+    # Check units are the same
+    orig_units = ['s', 'V', 'mmHg', 'mmHg', 'V', 'V']
+    assert phys_obj.units == orig_units
+
+    # Read data to test acq in mat exension
+    chtrig = 3
+    phys_obj = io.load_mat(matlab_file_acq, chtrig)
+
+    # checks that the outputs make sense
+    assert phys_obj.ch_name[0] == 'time'
+    assert phys_obj.freq[0] == 10000.0
+    assert phys_obj.units[0] == 's'
+
+    # checks that the trigger is in the right channel
+    assert phys_obj.ch_name[chtrig] == 'MR TRIGGER - Custom, HLT100C - A 5'
+    assert phys_obj.freq[chtrig] == 10000.0
+    assert phys_obj.units[chtrig] == 'Volts'
\ No newline at end of file