Repository: mne-tools/mne-python

Issue:
FR: Add "unit" parameter to get_data method for Epochs
UPDATE: https://github.com/mne-tools/mne-python/pull/9136 implements this for "raw" ... we still need it for "epochs"

---


I often find myself doing things like this:

```Python
# get EEG data and scale to micro volts
# ntrials x nchannels x ntimes
eeg_data_uV = epochs.get_data() * 1e6
```

... just to do different kinds of analyses with custom functions.

I think we should add a `"unit"` parameter to `.get_data()`:

```python
"""
Parameters
----------
unit : str, one of ["nV, uV, mV, V"]
    The unit in which the data will be returned. Defaults to "V".
"""
```

This would make it more transparent for users what units the data are in they are receiving, and also offer a chance for customization.

I have a complete déjà vu writing up this issue, but I couldn't find existing threads on this.


Discussion:
get data will give you any of any channel type not just EEG. What if you
have misc, eeg, eog, grad and mag? you pass a dict of units?

>

> you pass a dict of units?

That was my thinking. It keeps it consistent with the `units` param we have in viz. Though in viz you're *saying what the unit of the data is*, meaning no scale factors get applied unless you also supply `scalings` as well -- and in this function the data *will be converted to those units* so we'll have to be careful about the docstring.
> you pass a dict of units?

and if in the data there are channel types for which no keys are passed, then the default unit is used :thinking: 
> and if in the data there are channel types for which no keys are passed, then the default unit is used ￼

This is also how it works in `units` for our viz functions, yes.

How do we handle `units='uV'`? Do we treat this effectively as:

1. `dict(eeg='uV', eog='uV', ecg='uV', misc='uV')`, "anything that typically has V units should be returned in uV"
2. raise an error, "more than one channel type could be in uV units"
3. `dict(eeg='uV')`, "whatever the first data channel type with this unit will get uV" (probably not safe for example because of reference MEG channels)
> and if in the data there are channel types for which no keys are passed, then the default unit is used

yes that seems natural

> How do we handle units='uV'?

perhaps we could make our lives easy and only accept a dict?

```python
"""
Parameters
----------
unit : dict | None
    Key-value pairs in this dictionary determine the unit in which the data
    of a particular channel type will be returned. For example
    ``{"eeg": "uV", "eog": "nV"}`` will return data from all channels of type "eeg"
    in micro Volts and those of type "eog" in nano Volts. All other potentially
    existing channel types in the data will retain the unit they are currently in.
    Defaults to None, returning MNE standard unit for each channel type.    
"""
```

EDIT: actually your option 1 sounds quite nice

> dict(eeg='uV', eog='uV', ecg='uV', misc='uV'), "anything that typically has V units should be returned in uV"
I think the `str` option is nice for EEG users, or really any users with a single channel type (other than STIM/trigger) in their data. I see your edit and agree (1) is probably the way to go.
Hi, would it work with fNIRS as well? The fNIRS channels when the types are `hbo` and `hbr` are in M (mol/L) but it may be interesting to have the data in mM or uM.
Hey @HanBnrd - that'd probably be possible. Currently, nothing has been implemented: We only have a good idea of what we want :-)

The start was made here: https://github.com/mne-tools/mne-python/pull/8476

If you want to take this on, I'd be happy to help along the way
Okay, will take it then :)
I now had a look at it more in details, are you sure this is worth implement this? I feel that this would be quite a consequent code addition for little reward. For example it would enable to write `epochs.get_data(unit=dict(eeg='uV'))` instead of `epochs.get_data() * 1e6` which is even longer. While the code change needs to take into account different units or different structures (raw, epochs...).
The benefits are (at least) twofold:

1. when somebody (a **non-user**) reads your code, something like `epochs.get_data() * 1e6` is much more confusing than `epochs.get_data(unit=dict(eeg='uV'))` --> where does the magic number `1e6` come from? They'd need to learn that in MNE all EEG data is represented in Volts, and that 1e6 scales to microVolts
1. as a fresh MNE **user**, I was confused about these units and a parameter `unit` would certainly have cleared up the situation for me

But I agree that this is an issue that requires quite some work, so no worries if you'd rather work on something else.
Okay makes sense, I just wanted to be sure before starting working on it :)
I was wondering why the EOG channels are not considered as data channels in [`get_channel_types()`](https://mne.tools/dev/generated/mne.io.BaseRaw.html#mne.io.BaseRaw.get_channel_types) and whether this `units` parameter should work on them?
> I was wondering why the EOG channels are not considered as data channels in get_channel_types() 

In some sense *all* channels are data channels. I think the idea is that the "data channels" in MNE correspond mostly to the channels that record brain data.

> whether this units parameter should work on them?

I wouldn't restrict it just to data channels. Any channels with units (which is most of them I think?) should be able to be modified by the `unit` parameter.
If I remember correctly there was also some talk somewhere (can't find it right now) to add a `.get_data` method to `Evoked` objects. Once that is added, the `units` parameter should also work for that.

edit: see https://github.com/mne-tools/mne-python/issues/9244

Resolving patch:
diff --git a/mne/epochs.py b/mne/epochs.py
--- a/mne/epochs.py
+++ b/mne/epochs.py
@@ -36,7 +36,7 @@
                       pick_channels, pick_info, _pick_data_channels,
                       _DATA_CH_TYPES_SPLIT, _picks_to_idx)
 from .io.proj import setup_proj, ProjMixin
-from .io.base import BaseRaw, TimeMixin
+from .io.base import BaseRaw, TimeMixin, _get_ch_factors
 from .bem import _check_origin
 from .evoked import EvokedArray, _check_decim
 from .baseline import rescale, _log_rescale, _check_baseline
@@ -1325,7 +1325,8 @@ def _project_epoch(self, epoch):
         return epoch
 
     @verbose
-    def _get_data(self, out=True, picks=None, item=None, verbose=None):
+    def _get_data(self, out=True, picks=None, item=None, *, units=None,
+                  verbose=None):
         """Load all data, dropping bad epochs along the way.
 
         Parameters
@@ -1334,6 +1335,9 @@ def _get_data(self, out=True, picks=None, item=None, verbose=None):
             Return the data. Setting this to False is used to reject bad
             epochs without caching all the data, which saves memory.
         %(picks_all)s
+        item : slice | array-like | str | list | None
+            See docstring of get_data method.
+        %(units)s
         %(verbose_meth)s
         """
         if item is None:
@@ -1355,16 +1359,28 @@ def _get_data(self, out=True, picks=None, item=None, verbose=None):
             logger.info('Loading data for %s events and %s original time '
                         'points ...' % (n_events, len(self._raw_times)))
 
+        orig_picks = picks
+        if orig_picks is None:
+            picks = _picks_to_idx(self.info, picks, "all", exclude=())
+        else:
+            picks = _picks_to_idx(self.info, picks)
+
+        # handle units param only if we are going to return data (out==True)
+        # else, we don't scale (scaling is vector of ones)
+        ch_factors = np.ones(len(picks))
+        if (units is not None) and out:
+            ch_factors = _get_ch_factors(self, units, picks)
+
         if self._bad_dropped:
             if not out:
                 return
             if self.preload:
                 data = data[select]
-                if picks is None:
-                    return data
-                else:
-                    picks = _picks_to_idx(self.info, picks)
-                    return data[:, picks]
+                if orig_picks is not None:
+                    data = data[:, picks]
+                if units is not None:
+                    data *= ch_factors[:, np.newaxis]
+                return data
 
             # we need to load from disk, drop, and return data
             detrend_picks = self._detrend_picks
@@ -1443,11 +1459,11 @@ def _get_data(self, out=True, picks=None, item=None, verbose=None):
                           select_data=False)
 
         if out:
-            if picks is None:
-                return data
-            else:
-                picks = _picks_to_idx(self.info, picks)
-                return data[:, picks]
+            if orig_picks is not None:
+                data = data[:, picks]
+            if units is not None:
+                data *= ch_factors[:, np.newaxis]
+            return data
         else:
             return None
 
@@ -1460,7 +1476,7 @@ def _detrend_picks(self):
             return []
 
     @fill_doc
-    def get_data(self, picks=None, item=None):
+    def get_data(self, picks=None, item=None, units=None):
         """Get all epochs as a 3D array.
 
         Parameters
@@ -1474,13 +1490,16 @@ def get_data(self, picks=None, item=None):
             None (default) is an alias for ``slice(None)``.
 
             .. versionadded:: 0.20
+        %(units)s
+
+            .. versionadded:: 0.24
 
         Returns
         -------
         data : array of shape (n_epochs, n_channels, n_times)
             A view on epochs data.
         """
-        return self._get_data(picks=picks, item=item)
+        return self._get_data(picks=picks, item=item, units=units)
 
     @verbose
     def apply_function(self, fun, picks=None, dtype=None, n_jobs=1,
@@ -3268,7 +3287,7 @@ def add_channels_epochs(epochs_list, verbose=None):
         raise ValueError('All epochs must be preloaded.')
 
     info = _merge_info([epochs.info for epochs in epochs_list])
-    data = [epochs.get_data() for epochs in epochs_list]
+    data = [epochs._data for epochs in epochs_list]
     _check_merge_epochs(epochs_list)
     for d in data:
         if len(d) != len(data[0]):
diff --git a/mne/io/base.py b/mne/io/base.py
--- a/mne/io/base.py
+++ b/mne/io/base.py
@@ -817,22 +817,7 @@ def get_data(self, picks=None, start=0, stop=None,
             'bad' are omitted. If 'NaN', the bad samples are filled with NaNs.
         return_times : bool
             Whether to return times as well. Defaults to False.
-        units : str | dict | None
-            Specify the unit(s) that the data should be returned in. If
-            ``None`` (default), the data is returned in the
-            channel-type-specific default units, which are SI units (see
-            :ref:`units` and :term:`data channels`). If a string, must be a
-            sub-multiple of SI units that will be used to scale the data from
-            all channels of the type associated with that unit. This only works
-            if the data contains one channel type that has a unit (unitless
-            channel types are left unchanged). For example if there are only
-            EEG and STIM channels, ``units='uV'`` will scale EEG channels to
-            micro-Volts while STIM channels will be unchanged. Finally, if a
-            dictionary is provided, keys must be channel types, and values must
-            be units to scale the data of that channel type to. For example
-            ``dict(grad='fT/cm', mag='fT')`` will scale the corresponding types
-            accordingly, but all other channel types will remain in their
-            channel-type-specific default unit.
+        %(units)s
         %(verbose_meth)s
 
         Returns
@@ -849,32 +834,10 @@ def get_data(self, picks=None, start=0, stop=None,
         """
         picks = _picks_to_idx(self.info, picks, 'all', exclude=())
 
-        # Convert into the specified unit
-        _validate_type(units, types=(None, str, dict), item_name="units")
-        needs_conversion = False
-        ch_factors = np.ones(len(picks))
-        si_units = _handle_default('si_units')
-        # Convert to dict if str units
-        if isinstance(units, str):
-            # Check that there is only one channel type
-            ch_types = self.get_channel_types(picks=picks, unique=True)
-            unit_ch_type = list(set(ch_types) & set(si_units.keys()))
-            if len(unit_ch_type) > 1:
-                raise ValueError('"units" cannot be str if there is more than '
-                                 'one channel type with a unit '
-                                 f'{unit_ch_type}.')
-            units = {unit_ch_type[0]: units}  # make the str argument a dict
-        # Loop over the dict to get channel factors
-        if isinstance(units, dict):
-            for ch_type, ch_unit in units.items():
-                # Get the scaling factors
-                scaling = _get_scaling(ch_type, ch_unit)
-                if scaling != 1:
-                    needs_conversion = True
-                    ch_types = self.get_channel_types(picks=picks)
-                    indices = [i_ch for i_ch, ch in enumerate(ch_types)
-                               if ch == ch_type]
-                    ch_factors[indices] *= scaling
+        # Get channel factors for conversion into specified unit
+        # (vector of ones if no conversion needed)
+        if units is not None:
+            ch_factors = _get_ch_factors(self, units, picks)
 
         # convert to ints
         picks = np.atleast_1d(np.arange(self.info['nchan'])[picks])
@@ -885,10 +848,10 @@ def get_data(self, picks=None, start=0, stop=None,
                 (picks, slice(start, stop)), return_times=return_times)
             if return_times:
                 data, times = getitem
-                if needs_conversion:
+                if units is not None:
                     data *= ch_factors[:, np.newaxis]
                 return data, times
-            if needs_conversion:
+            if units is not None:
                 getitem *= ch_factors[:, np.newaxis]
             return getitem
         _check_option('reject_by_annotation', reject_by_annotation.lower(),
@@ -899,7 +862,7 @@ def get_data(self, picks=None, start=0, stop=None,
         ends = np.minimum(ends[keep], stop)
         if len(onsets) == 0:
             data, times = self[picks, start:stop]
-            if needs_conversion:
+            if units is not None:
                 data *= ch_factors[:, np.newaxis]
             if return_times:
                 return data, times
@@ -942,7 +905,7 @@ def get_data(self, picks=None, start=0, stop=None,
         else:
             data, times = self[picks, start:stop]
 
-        if needs_conversion:
+        if units is not None:
             data *= ch_factors[:, np.newaxis]
         if return_times:
             return data, times
@@ -1961,6 +1924,50 @@ def _convert_slice(sel):
         return sel
 
 
+def _get_ch_factors(inst, units, picks_idxs):
+    """Get scaling factors for data, given units.
+
+    Parameters
+    ----------
+    inst : instance of Raw | Epochs | Evoked
+        The instance.
+    %(units)s
+    picks_idxs : ndarray
+        The picks as provided through _picks_to_idx.
+
+    Returns
+    -------
+    ch_factors : ndarray of floats, shape(len(picks),)
+        The sacling factors for each channel, ordered according
+        to picks.
+
+    """
+    _validate_type(units, types=(None, str, dict), item_name="units")
+    ch_factors = np.ones(len(picks_idxs))
+    si_units = _handle_default('si_units')
+    ch_types = inst.get_channel_types(picks=picks_idxs)
+    # Convert to dict if str units
+    if isinstance(units, str):
+        # Check that there is only one channel type
+        unit_ch_type = list(set(ch_types) & set(si_units.keys()))
+        if len(unit_ch_type) > 1:
+            raise ValueError('"units" cannot be str if there is more than '
+                             'one channel type with a unit '
+                             f'{unit_ch_type}.')
+        units = {unit_ch_type[0]: units}  # make the str argument a dict
+    # Loop over the dict to get channel factors
+    if isinstance(units, dict):
+        for ch_type, ch_unit in units.items():
+            # Get the scaling factors
+            scaling = _get_scaling(ch_type, ch_unit)
+            if scaling != 1:
+                indices = [i_ch for i_ch, ch in enumerate(ch_types)
+                           if ch == ch_type]
+                ch_factors[indices] *= scaling
+
+    return ch_factors
+
+
 def _get_scaling(ch_type, target_unit):
     """Return the scaling factor based on the channel type and a target unit.
 
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -602,6 +602,26 @@
 docdict['picks_nostr'] = f"""picks : list | slice | None
     {picks_intro} None (default) will pick all channels. {reminder_nostr}"""
 
+# Units
+docdict['units'] = """
+units : str | dict | None
+    Specify the unit(s) that the data should be returned in. If
+    ``None`` (default), the data is returned in the
+    channel-type-specific default units, which are SI units (see
+    :ref:`units` and :term:`data channels`). If a string, must be a
+    sub-multiple of SI units that will be used to scale the data from
+    all channels of the type associated with that unit. This only works
+    if the data contains one channel type that has a unit (unitless
+    channel types are left unchanged). For example if there are only
+    EEG and STIM channels, ``units='uV'`` will scale EEG channels to
+    micro-Volts while STIM channels will be unchanged. Finally, if a
+    dictionary is provided, keys must be channel types, and values must
+    be units to scale the data of that channel type to. For example
+    ``dict(grad='fT/cm', mag='fT')`` will scale the corresponding types
+    accordingly, but all other channel types will remain in their
+    channel-type-specific default unit.
+"""
+
 # Filtering
 docdict['l_freq'] = """
 l_freq : float | None


Test patch:
diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py
--- a/mne/tests/test_epochs.py
+++ b/mne/tests/test_epochs.py
@@ -183,6 +183,41 @@ def _get_data(preload=False):
 flat = dict(grad=1e-15, mag=1e-15)
 
 
+def test_get_data():
+    """Test the .get_data() method."""
+    raw, events, picks = _get_data()
+    event_id = {'a/1': 1, 'a/2': 2, 'b/1': 3, 'b/2': 4}
+    epochs = Epochs(raw, events, event_id, preload=True)
+
+    # Testing with respect to units param
+    # more tests in mne/io/tests/test_raw.py::test_get_data_units
+    # EEG is already in V, so no conversion should take place
+    d1 = epochs.get_data(picks="eeg", units=None)
+    d2 = epochs.get_data(picks="eeg", units="V")
+    np.testing.assert_array_equal(d1, d2)
+
+    with pytest.raises(ValueError, match="is not a valid unit for eeg"):
+        epochs.get_data(picks="eeg", units="")
+
+    with pytest.raises(ValueError, match="cannot be str if there is more"):
+        epochs.get_data(picks=["eeg", "meg"], units="V")
+
+    # Check combination of units with item param, scale only one ch_type
+    d3 = epochs.get_data(item=[1, 2, 3], units={"grad": "fT/cm"})
+    assert d3.shape[0] == 3
+
+    grad_idxs = np.array([i == "grad" for i in epochs.get_channel_types()])
+    eeg_idxs = np.array([i == "eeg" for i in epochs.get_channel_types()])
+    np.testing.assert_allclose(
+        d3[:, grad_idxs, :],
+        epochs.get_data("grad", item=[1, 2, 3]) * 1e13  # T/m to fT/cm
+    )
+    np.testing.assert_allclose(
+        d3[:, eeg_idxs, :],
+        epochs.get_data("eeg", item=[1, 2, 3])
+    )
+
+
 def test_hierarchical():
     """Test hierarchical access."""
     raw, events, picks = _get_data()