Repository: nipy/heudiconv

Issue:
Converting uncombined (channel-level) data
<!-- DO NOT DELETE THIS! 
This template is used to facilitate issue resolution.
All text in <!-> tags will not be displayed.
-->

### Summary
<!-- If you are having conversion troubles, please share as much
relevant information as possible. This includes, but is not limited to:
- log of conversion
- heuristic
-->
In order to support channel-level data, I was thinking that [convert.save_converted_files](https://github.com/nipy/heudiconv/blob/fdea77ce6263d3f92e51dd8a24ac506e7a2fce4e/heudiconv/convert.py#L462) should include a check for the `CoilString` metadata field and should insert a `coil` or `channel` entity into the filenames before copying them to the output directory.

It might be worth it to move the multi-echo, complex (currently only for SBRefs), and uncombined data renaming methods into distinct functions as well.

### Platform details:

Choose one:
- [ ] Local environment
<!-- If selected, please provide OS and python version -->
- [ ] Container
<!-- If selected, please provide container name and tag"-->

- Heudiconv version: 
<!-- To check: run heudiconv with just the --version flag -->


Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d4b1e2..32e72bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -360,3 +360,11 @@ TODO Summary
 [#434]: https://github.com/nipy/heudiconv/issues/434
 [#436]: https://github.com/nipy/heudiconv/issues/436
 [#437]: https://github.com/nipy/heudiconv/issues/437
+[#425]: https://github.com/nipy/heudiconv/issues/425
+[#420]: https://github.com/nipy/heudiconv/issues/420
+[#425]: https://github.com/nipy/heudiconv/issues/425
+[#430]: https://github.com/nipy/heudiconv/issues/430
+[#432]: https://github.com/nipy/heudiconv/issues/432
+[#434]: https://github.com/nipy/heudiconv/issues/434
+[#436]: https://github.com/nipy/heudiconv/issues/436
+[#437]: https://github.com/nipy/heudiconv/issues/437
diff --git a/docs/installation.rst b/docs/installation.rst
index 86668d3..0bb2380 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -7,23 +7,23 @@ Installation
 
 Local
 =====
-Released versions of HeuDiConv are available on `PyPI <https://pypi.org/project/heudiconv/>`_ 
-and `conda <https://github.com/conda-forge/heudiconv-feedstock#installing-heudiconv>`_. 
+Released versions of HeuDiConv are available on `PyPI <https://pypi.org/project/heudiconv/>`_
+and `conda <https://github.com/conda-forge/heudiconv-feedstock#installing-heudiconv>`_.
 If installing through ``PyPI``, eg::
 
     pip install heudiconv[all]
 
-Manual installation of `dcm2niix <https://github.com/rordenlab/dcm2niix#install>`_ 
+Manual installation of `dcm2niix <https://github.com/rordenlab/dcm2niix#install>`_
 is required.
 
-On Debian-based systems we recommend using `NeuroDebian <http://neuro.debian.net>`_ 
+On Debian-based systems we recommend using `NeuroDebian <http://neuro.debian.net>`_
 which provides the `heudiconv package <http://neuro.debian.net/pkgs/heudiconv.html>`_.
 
 
 Docker
 ======
-If `Docker <https://docs.docker.com/install/>`_ is available on your system, you 
-can visit `our page on Docker Hub <https://hub.docker.com/r/nipy/heudiconv/tags>`_ 
+If `Docker <https://docs.docker.com/install/>`_ is available on your system, you
+can visit `our page on Docker Hub <https://hub.docker.com/r/nipy/heudiconv/tags>`_
 to view available releases. To pull the latest release, run::
 
     $ docker pull nipy/heudiconv:0.8.0
@@ -31,8 +31,8 @@ to view available releases. To pull the latest release, run::
 
 Singularity
 ===========
-If `Singularity <https://www.sylabs.io/singularity/>`_ is available on your system, 
-you can use it to pull and convert our Docker images! For example, to pull and 
+If `Singularity <https://www.sylabs.io/singularity/>`_ is available on your system,
+you can use it to pull and convert our Docker images! For example, to pull and
 build the latest release, you can run::
 
     $ singularity pull docker://nipy/heudiconv:0.8.0
diff --git a/heudiconv/convert.py b/heudiconv/convert.py
index 7a0ea36..8e2c3c6 100644
--- a/heudiconv/convert.py
+++ b/heudiconv/convert.py
@@ -82,7 +82,7 @@ def conversion_info(subject, outdir, info, filegroup, ses):
 
 
 def prep_conversion(sid, dicoms, outdir, heuristic, converter, anon_sid,
-                    anon_outdir, with_prov, ses, bids_options, seqinfo, 
+                    anon_outdir, with_prov, ses, bids_options, seqinfo,
                     min_meta, overwrite, dcmconfig, grouping):
     if dicoms:
         lgr.info("Processing %d dicoms", len(dicoms))
@@ -233,6 +233,157 @@ def prep_conversion(sid, dicoms, outdir, heuristic, converter, anon_sid,
                                     getattr(heuristic, 'DEFAULT_FIELDS', {}))
 
 
+def update_complex_name(metadata, filename, suffix):
+    """
+    Insert `_rec-<magnitude|phase>` entity into filename if data are from a
+    sequence with magnitude/phase part.
+
+    Parameters
+    ----------
+    metadata : dict
+        Scan metadata dictionary from BIDS sidecar file.
+    filename : str
+        Incoming filename
+    suffix : str
+        An index used for cases where a single scan produces multiple files,
+        but the differences between those files are unknown.
+
+    Returns
+    -------
+    filename : str
+        Updated filename with rec entity added in appropriate position.
+    """
+    # Some scans separate magnitude/phase differently
+    unsupported_types = ['_bold', '_phase',
+                         '_magnitude', '_magnitude1', '_magnitude2',
+                         '_phasediff', '_phase1', '_phase2']
+    if any(ut in filename for ut in unsupported_types):
+        return filename
+
+    # Check to see if it is magnitude or phase part:
+    if 'M' in metadata.get('ImageType'):
+        mag_or_phase = 'magnitude'
+    elif 'P' in metadata.get('ImageType'):
+        mag_or_phase = 'phase'
+    else:
+        mag_or_phase = suffix
+
+    # Determine scan suffix
+    filetype = '_' + filename.split('_')[-1]
+
+    # Insert rec label
+    if not ('_rec-%s' % mag_or_phase) in filename:
+        # If "_rec-" is specified, prepend the 'mag_or_phase' value.
+        if '_rec-' in filename:
+            raise BIDSError(
+                "Reconstruction label for images will be automatically set, "
+                "remove from heuristic"
+            )
+
+        # Insert it **before** the following string(s), whichever appears first.
+        for label in ['_dir', '_run', '_mod', '_echo', '_recording', '_proc', '_space', filetype]:
+            if (label == filetype) or (label in filename):
+                filename = filename.replace(
+                    label, "_rec-%s%s" % (mag_or_phase, label)
+                )
+                break
+
+    return filename
+
+
+def update_multiecho_name(metadata, filename, echo_times):
+    """
+    Insert `_echo-<num>` entity into filename if data are from a multi-echo
+    sequence.
+
+    Parameters
+    ----------
+    metadata : dict
+        Scan metadata dictionary from BIDS sidecar file.
+    filename : str
+        Incoming filename
+    echo_times : list
+        List of all echo times from scan. Used to determine the echo *number*
+        (i.e., index) if field is missing from metadata.
+
+    Returns
+    -------
+    filename : str
+        Updated filename with echo entity added, if appropriate.
+    """
+    # Field maps separate echoes differently
+    unsupported_types = [
+        '_magnitude', '_magnitude1', '_magnitude2',
+        '_phasediff', '_phase1', '_phase2', '_fieldmap'
+    ]
+    if any(ut in filename for ut in unsupported_types):
+        return filename
+
+    # Get the EchoNumber from json file info.  If not present, use EchoTime
+    if 'EchoNumber' in metadata.keys():
+        echo_number = metadata['EchoNumber']
+    else:
+        echo_number = echo_times.index(metadata['EchoTime']) + 1
+
+    # Determine scan suffix
+    filetype = '_' + filename.split('_')[-1]
+
+    # Insert it **before** the following string(s), whichever appears first.
+    for label in ['_recording', '_proc', '_space', filetype]:
+        if (label == filetype) or (label in filename):
+            filename = filename.replace(
+                label, "_echo-%s%s" % (echo_number, label)
+            )
+            break
+
+    return filename
+
+
+def update_uncombined_name(metadata, filename, channel_names):
+    """
+    Insert `_ch-<num>` entity into filename if data are from a sequence
+    with "save uncombined".
+
+    Parameters
+    ----------
+    metadata : dict
+        Scan metadata dictionary from BIDS sidecar file.
+    filename : str
+        Incoming filename
+    channel_names : list
+        List of all channel names from scan. Used to determine the channel
+        *number* (i.e., index) if field is missing from metadata.
+
+    Returns
+    -------
+    filename : str
+        Updated filename with ch entity added, if appropriate.
+    """
+    # In case any scan types separate channels differently
+    unsupported_types = []
+    if any(ut in filename for ut in unsupported_types):
+        return filename
+
+    # Determine the channel number
+    channel_number = ''.join([c for c in metadata['CoilString'] if c.isdigit()])
+    if not channel_number:
+        channel_number = channel_names.index(metadata['CoilString']) + 1
+    channel_number = str(channel_number).zfill(2)
+
+    # Determine scan suffix
+    filetype = '_' + filename.split('_')[-1]
+
+    # Insert it **before** the following string(s), whichever appears first.
+    # Choosing to put channel near the end since it's not in the specification yet.
+    for label in ['_recording', '_proc', '_space', filetype]:
+        if (label == filetype) or (label in filename):
+            filename = filename.replace(
+                label, "_ch-%s%s" % (channel_number, label)
+            )
+            break
+    return filename
+
+
 def convert(items, converter, scaninfo_suffix, custom_callable, with_prov,
             bids_options, outdir, min_meta, overwrite, symlink=True, prov_file=None,
             dcmconfig=None):
@@ -534,14 +685,17 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
         #   series. To do that, the most straightforward way is to read the
         #   echo times for all bids_files and see if they are all the same or not.
 
-        # Check for varying echo times
-        echo_times = sorted(list(set(
-            b.get('EchoTime', nan)
-            for b in bids_metas
-            if b
-        )))
-
-        is_multiecho = len(echo_times) > 1
+        # Collect some metadata across all images
+        echo_times, channel_names, image_types = set(), set(), set()
+        for metadata in bids_metas:
+            if not metadata:
+                continue
+            echo_times.add(metadata.get('EchoTime', nan))
+            channel_names.add(metadata.get('CoilString', nan))
+            image_types.update(metadata.get('ImageType', [nan]))
+        is_multiecho = len(set(filter(bool, echo_times))) > 1  # Check for varying echo times
+        is_uncombined = len(set(filter(bool, channel_names))) > 1  # Check for uncombined data
+        is_complex = 'M' in image_types and 'P' in image_types  # Determine if data are complex (magnitude + phase)
 
         ### Loop through the bids_files, set the output name and save files
         for fl, suffix, bids_file, bids_meta in zip(res_files, suffixes, bids_files, bids_metas):
@@ -552,65 +706,22 @@ def save_converted_files(res, item_dicoms, bids_options, outtype, prefix, outnam
             # and we don't want to modify it for all the bids_files):
             this_prefix_basename = prefix_basename
 
-            # _sbref sequences reconstructing magnitude and phase generate
-            # two NIfTI files IN THE SAME SERIES, so we cannot just add
-            # the suffix, if we want to be bids compliant:
-            if bids_meta and this_prefix_basename.endswith('_sbref') \
-                    and len(suffixes) > len(echo_times):
-                if len(suffixes) != len(echo_times)*2:
-                    lgr.warning(
-                        "Got %d suffixes for %d echo times, which isn't "
-                        "multiple of two as if it was magnitude + phase pairs",
-                        len(suffixes), len(echo_times)
+            # Update name for certain criteria
+            if bids_file:
+                if is_multiecho:
+                    this_prefix_basename = update_multiecho_name(
+                        bids_meta, this_prefix_basename, echo_times
+                    )
+
+                if is_complex:
+                    this_prefix_basename = update_complex_name(
+                        bids_meta, this_prefix_basename, suffix
+                    )
+
+                if is_uncombined:
+                    this_prefix_basename = update_uncombined_name(
+                        bids_meta, this_prefix_basename, channel_names
                     )
-                # Check to see if it is magnitude or phase reconstruction:
-                if 'M' in bids_meta.get('ImageType'):
-                    mag_or_phase = 'magnitude'
-                elif 'P' in bids_meta.get('ImageType'):
-                    mag_or_phase = 'phase'
-                else:
-                    mag_or_phase = suffix
-
-                # Insert reconstruction label
-                if not ("_rec-%s" % mag_or_phase) in this_prefix_basename:
-
-                    # If "_rec-" is specified, prepend the 'mag_or_phase' value.
-                    if ('_rec-' in this_prefix_basename):
-                        raise BIDSError(
-                        "Reconstruction label for multi-echo single-band"
-                        " reference images will be automatically set, remove"
-                        " from heuristic"
-                        )
-
-                    # If not, insert "_rec-" + 'mag_or_phase' into the prefix_basename
-                    #   **before** "_run", "_echo" or "_sbref", whichever appears first:
-                    for label in ['_run', '_echo', '_sbref']:
-                        if (label in this_prefix_basename):
-                            this_prefix_basename = this_prefix_basename.replace(
-                                label, "_rec-%s%s" % (mag_or_phase, label)
-                            )
-                            break
-
-            # Now check if this run is multi-echo
-            # (Note: it can be _sbref and multiecho, so don't use "elif"):
-            # For multi-echo sequences, we have to specify the echo number in
-            # the file name:
-            if bids_meta and is_multiecho:
-                # Get the EchoNumber from json file info.  If not present, use EchoTime
-                if 'EchoNumber' in bids_meta:
-                    echo_number = bids_meta['EchoNumber']
-                else:
-                    echo_number = echo_times.index(bids_meta['EchoTime']) + 1
-
-                supported_multiecho = ['_bold', '_phase', '_epi', '_sbref', '_T1w', '_PDT2']
-                # Now, decide where to insert it.
-                # Insert it **before** the following string(s), whichever appears first.
-                for imgtype in supported_multiecho:
-                    if (imgtype in this_prefix_basename):
-                        this_prefix_basename = this_prefix_basename.replace(
-                            imgtype, "_echo-%d%s" % (echo_number, imgtype)
-                        )
-                        break
 
             # Fallback option:
             # If we have failed to modify this_prefix_basename, because it didn't fall


Test patch:
diff --git a/heudiconv/tests/test_convert.py b/heudiconv/tests/test_convert.py
new file mode 100644
index 0000000..7593cb0
--- /dev/null
+++ b/heudiconv/tests/test_convert.py
@@ -0,0 +1,78 @@
+"""Test functions in heudiconv.convert module.
+"""
+import pytest
+
+from heudiconv.convert import (update_complex_name,
+                               update_multiecho_name,
+                               update_uncombined_name)
+from heudiconv.bids import BIDSError
+
+
+def test_update_complex_name():
+    """Unit testing for heudiconv.convert.update_complex_name(), which updates
+    filenames with the rec field if appropriate.
+    """
+    # Standard name update
+    fn = 'sub-X_ses-Y_task-Z_run-01_sbref'
+    metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'P', 'MB', 'TE3', 'ND', 'MOSAIC']}
+    suffix = 3
+    out_fn_true = 'sub-X_ses-Y_task-Z_rec-phase_run-01_sbref'
+    out_fn_test = update_complex_name(metadata, fn, suffix)
+    assert out_fn_test == out_fn_true
+    # Catch an unsupported type and *do not* update
+    fn = 'sub-X_ses-Y_task-Z_run-01_phase'
+    out_fn_test = update_complex_name(metadata, fn, suffix)
+    assert out_fn_test == fn
+    # Data type is missing from metadata so use suffix
+    fn = 'sub-X_ses-Y_task-Z_run-01_sbref'
+    metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'MB', 'TE3', 'ND', 'MOSAIC']}
+    out_fn_true = 'sub-X_ses-Y_task-Z_rec-3_run-01_sbref'
+    out_fn_test = update_complex_name(metadata, fn, suffix)
+    assert out_fn_test == out_fn_true
+    # Catch existing field with value that *does not match* metadata
+    # and raise Exception
+    fn = 'sub-X_ses-Y_task-Z_rec-magnitude_run-01_sbref'
+    metadata = {'ImageType': ['ORIGINAL', 'PRIMARY', 'P', 'MB', 'TE3', 'ND', 'MOSAIC']}
+    suffix = 3
+    with pytest.raises(BIDSError):
+        assert update_complex_name(metadata, fn, suffix)
+
+
+def test_update_multiecho_name():
+    """Unit testing for heudiconv.convert.update_multiecho_name(), which updates
+    filenames with the echo field if appropriate.
+    """
+    # Standard name update
+    fn = 'sub-X_ses-Y_task-Z_run-01_bold'
+    metadata = {'EchoTime': 0.01,
+                'EchoNumber': 1}
+    echo_times = [0.01, 0.02, 0.03]
+    out_fn_true = 'sub-X_ses-Y_task-Z_run-01_echo-1_bold'
+    out_fn_test = update_multiecho_name(metadata, fn, echo_times)
+    assert out_fn_test == out_fn_true
+    # EchoNumber field is missing from metadata, so use echo_times
+    metadata = {'EchoTime': 0.01}
+    out_fn_test = update_multiecho_name(metadata, fn, echo_times)
+    assert out_fn_test == out_fn_true
+    # Catch an unsupported type and *do not* update
+    fn = 'sub-X_ses-Y_task-Z_run-01_phasediff'
+    out_fn_test = update_multiecho_name(metadata, fn, echo_times)
+    assert out_fn_test == fn
+
+
+def test_update_uncombined_name():
+    """Unit testing for heudiconv.convert.update_uncombined_name(), which updates
+    filenames with the ch field if appropriate.
+    """
+    # Standard name update
+    fn = 'sub-X_ses-Y_task-Z_run-01_bold'
+    metadata = {'CoilString': 'H1'}
+    channel_names = ['H1', 'H2', 'H3', 'HEA;HEP']
+    out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-01_bold'
+    out_fn_test = update_uncombined_name(metadata, fn, channel_names)
+    assert out_fn_test == out_fn_true
+    # CoilString field has no number in it
+    metadata = {'CoilString': 'HEA;HEP'}
+    out_fn_true = 'sub-X_ses-Y_task-Z_run-01_ch-04_bold'
+    out_fn_test = update_uncombined_name(metadata, fn, channel_names)
+    assert out_fn_test == out_fn_true