Repository: mne-tools/mne-python

Issue:
Enable to apply TDDR on Hb type fNIRS data
#### Describe the new feature or enhancement
Currently it is only possible to apply temporal derivative distribution repair (TDDR) (Fishburn et al. 2019) for motion artefact correction on optical density data. The enhancement would enable to also apply it on haemoglobin concentration change data (HbO, HbR), which would be useful when one does not have access to the raw intensity or optical density data.


#### Describe your proposed implementation
Be more flexible in the channel type checks when applying the TDDR. This would consist in modifying [*_tddr.py*](https://github.com/rob-luke/mne-python/blob/59ca238896e275e0f97f2c81fc9bdd18f31066b1/mne/preprocessing/nirs/_tddr.py#L49-L52) to work on any type of fNIRS data.


#### Describe possible alternatives
Apply TDDR on optical density data and convert to Hb (with the modified Beer Lambert law) afterwards. This is however impossible when one only has access to Hb type data.


*(FYI @rob-luke @frankfishburn)*


Discussion:

Resolving patch:
diff --git a/mne/preprocessing/nirs/_tddr.py b/mne/preprocessing/nirs/_tddr.py
--- a/mne/preprocessing/nirs/_tddr.py
+++ b/mne/preprocessing/nirs/_tddr.py
@@ -6,11 +6,10 @@
 
 import numpy as np
 
-from ... import pick_types
 from ...io import BaseRaw
 from ...utils import _validate_type, verbose
 from ...io.pick import _picks_to_idx
-from ..nirs import _channel_frequencies, _check_channels_ordered
+from ..nirs import _validate_nirs_info
 
 
 @verbose
@@ -34,6 +33,11 @@ def temporal_derivative_distribution_repair(raw, *, verbose=None):
 
     Notes
     -----
+    TDDR was initially designed to be used on optical density fNIRS data but
+    has been enabled to be applied on hemoglobin concentration fNIRS data as
+    well in MNE. We recommend applying the algorithm to optical density fNIRS
+    data as intended by the original author wherever possible.
+
     There is a shorter alias ``mne.preprocessing.nirs.tddr`` that can be used
     instead of this function (e.g. if line length is an issue).
 
@@ -43,13 +47,12 @@ def temporal_derivative_distribution_repair(raw, *, verbose=None):
     """
     raw = raw.copy().load_data()
     _validate_type(raw, BaseRaw, 'raw')
-    _check_channels_ordered(
-        raw.info, np.unique(_channel_frequencies(raw.info, nominal=True)))
-
-    if not len(pick_types(raw.info, fnirs='fnirs_od')):
-        raise RuntimeError('TDDR should be run on optical density data.')
+    _validate_nirs_info(raw.info)
 
-    picks = _picks_to_idx(raw.info, 'fnirs_od', exclude=[])
+    picks = _picks_to_idx(raw.info, ['fnirs_od', 'hbo', 'hbr'], exclude=[])
+    if not len(picks):
+        raise RuntimeError('TDDR should be run on optical density or \
+                            hemoglobin data.')
     for pick in picks:
         raw._data[pick] = _TDDR(raw._data[pick], raw.info['sfreq'])
 
@@ -76,8 +79,8 @@ def _TDDR(signal, sample_rate):
     #   signals_corrected = TDDR( signals , sample_rate );
     #
     # Inputs:
-    #   signals: A [sample x channel] matrix of uncorrected optical density
-    #            data
+    #   signals: A [sample x channel] matrix of uncorrected optical density or
+    #            hemoglobin data
     #   sample_rate: A scalar reflecting the rate of acquisition in Hz
     #
     # Outputs:


Test patch:
diff --git a/mne/preprocessing/nirs/tests/test_temporal_derivative_distribution_repair.py b/mne/preprocessing/nirs/tests/test_temporal_derivative_distribution_repair.py
--- a/mne/preprocessing/nirs/tests/test_temporal_derivative_distribution_repair.py
+++ b/mne/preprocessing/nirs/tests/test_temporal_derivative_distribution_repair.py
@@ -10,7 +10,7 @@
 
 from mne.datasets.testing import data_path
 from mne.io import read_raw_nirx
-from mne.preprocessing.nirs import optical_density, tddr
+from mne.preprocessing.nirs import beer_lambert_law, optical_density, tddr
 from mne.datasets import testing
 
 
@@ -23,18 +23,35 @@
 def test_temporal_derivative_distribution_repair(fname, tmp_path):
     """Test running artifact rejection."""
     raw = read_raw_nirx(fname)
-    raw = optical_density(raw)
+    raw_od = optical_density(raw)
+    raw_hb = beer_lambert_law(raw_od)
 
+    # With optical densities
     # Add a baseline shift artifact about half way through data
-    max_shift = np.max(np.diff(raw._data[0]))
+    max_shift = np.max(np.diff(raw_od._data[0]))
     shift_amp = 5 * max_shift
-    raw._data[0, 0:30] = raw._data[0, 0:30] - shift_amp
+    raw_od._data[0, 0:30] = raw_od._data[0, 0:30] - shift_amp
     # make one channel zero std
-    raw._data[1] = 0.
-    raw._data[2] = 1.
-    assert np.max(np.diff(raw._data[0])) > shift_amp
+    raw_od._data[1] = 0.
+    raw_od._data[2] = 1.
+    assert np.max(np.diff(raw_od._data[0])) > shift_amp
     # Ensure that applying the algorithm reduces the step change
-    raw = tddr(raw)
-    assert np.max(np.diff(raw._data[0])) < shift_amp
-    assert_allclose(raw._data[1], 0.)  # unchanged
-    assert_allclose(raw._data[2], 1.)  # unchanged
+    raw_od = tddr(raw_od)
+    assert np.max(np.diff(raw_od._data[0])) < shift_amp
+    assert_allclose(raw_od._data[1], 0.)  # unchanged
+    assert_allclose(raw_od._data[2], 1.)  # unchanged
+
+    # With Hb
+    # Add a baseline shift artifact about half way through data
+    max_shift = np.max(np.diff(raw_hb._data[0]))
+    shift_amp = 5 * max_shift
+    raw_hb._data[0, 0:30] = raw_hb._data[0, 0:30] - (1.1 * shift_amp)
+    # make one channel zero std
+    raw_hb._data[1] = 0.
+    raw_hb._data[2] = 1.
+    assert np.max(np.diff(raw_hb._data[0])) > shift_amp
+    # Ensure that applying the algorithm reduces the step change
+    raw_hb = tddr(raw_hb)
+    assert np.max(np.diff(raw_hb._data[0])) < shift_amp
+    assert_allclose(raw_hb._data[1], 0.)  # unchanged
+    assert_allclose(raw_hb._data[2], 1.)  # unchanged