Repository: mne-tools/mne-python

Issue:
Deprecate mne.epochs.add_channels_epochs()
Working on #11168, I found that we have a function [`add_channels_epochs()`](https://mne.tools/stable/generated/mne.epochs.add_channels_epochs.html), which apparently only does a subset of what `Epochs.add_channels()` does, and is used nowhere in our code (it only appears in tests).

Since

- this function is redundant (one could use the method instead),
- it only exists for Epochs and we don't have anything similar for other data types,
- it is more limited than the method

I suggest deprecating it and marking it for removal.

Thoughts?


Discussion:
Based on your summary I'd say yes, but I haven't looked at the code yet. One other thing I usually check is the blame, to see how old the code is.
> One other thing I usually check is the blame, to see how old the code is.

I did that and forgot to include it in my original posting: apparently hasn't been touched for 6–9 years :)
Yes please, let's do this for 1.2. Let me know if you want to do it or I should @hoechenberger 
Please do go ahead @larsoner 

Resolving patch:
diff --git a/mne/channels/channels.py b/mne/channels/channels.py
--- a/mne/channels/channels.py
+++ b/mne/channels/channels.py
@@ -908,9 +908,9 @@ def add_channels(self, add_list, force_update_info=False):
         shapes = np.array([dat.shape for dat in data])[:, compare_axes]
         for shape in shapes:
             if not ((shapes[0] - shape) == 0).all():
-                raise AssertionError('All data dimensions except channels '
-                                     'must match, got %s != %s'
-                                     % (shapes[0], shape))
+                raise ValueError(
+                    'All data dimensions except channels must match, got '
+                    f'{shapes[0]} != {shape}')
         del shapes
 
         # Create final data / info objects
diff --git a/mne/epochs.py b/mne/epochs.py
--- a/mne/epochs.py
+++ b/mne/epochs.py
@@ -56,7 +56,7 @@
                     check_random_state, warn, _pl,
                     sizeof_fmt, SizeMixin, copy_function_doc_to_method_doc,
                     _check_pandas_installed,
-                    _check_preload, GetEpochsMixin,
+                    _check_preload, GetEpochsMixin, deprecated,
                     _prepare_read_metadata, _prepare_write_metadata,
                     _check_event_id, _gen_events, _check_option,
                     _check_combine, _build_data_frame,
@@ -3379,6 +3379,8 @@ def _check_merge_epochs(epochs_list):
         raise NotImplementedError("Epochs with unequal values for baseline")
 
 
+@deprecated('add_channels_epochs is deprecated and will be removed in 1.3, '
+            'use epochs.add_channels instead')
 @verbose
 def add_channels_epochs(epochs_list, verbose=None):
     """Concatenate channels, info and data from two Epochs objects.


Test patch:
diff --git a/mne/io/fiff/tests/test_raw_fiff.py b/mne/io/fiff/tests/test_raw_fiff.py
--- a/mne/io/fiff/tests/test_raw_fiff.py
+++ b/mne/io/fiff/tests/test_raw_fiff.py
@@ -1387,7 +1387,7 @@ def test_add_channels():
 
     pytest.raises(RuntimeError, raw_meg.add_channels, [raw_nopre])
     pytest.raises(RuntimeError, raw_meg.add_channels, [raw_badsf])
-    pytest.raises(AssertionError, raw_meg.add_channels, [raw_eeg])
+    pytest.raises(ValueError, raw_meg.add_channels, [raw_eeg])
     pytest.raises(ValueError, raw_meg.add_channels, [raw_meg])
     pytest.raises(TypeError, raw_meg.add_channels, raw_badsf)
 
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
@@ -2628,7 +2628,7 @@ def make_epochs(picks, proj):
         epochs_meg.info._check_consistency()
         epochs_eeg.info._check_consistency()
 
-        epochs2 = add_channels_epochs([epochs_meg, epochs_eeg])
+        epochs2 = epochs_meg.copy().add_channels([epochs_eeg])
 
         assert_equal(len(epochs.info['projs']), len(epochs2.info['projs']))
         assert_equal(len(epochs.info.keys()), len(epochs_meg.info.keys()))
@@ -2648,78 +2648,33 @@ def make_epochs(picks, proj):
     assert not epochs_meg.times.flags['WRITEABLE']
     assert not epochs_meg2.times.flags['WRITEABLE']
     epochs_meg2.set_meas_date(0)
-    add_channels_epochs([epochs_meg2, epochs_eeg])
+    epochs_meg2.copy().add_channels([epochs_eeg])
 
     epochs_meg2 = epochs_meg.copy()
-    epochs2 = add_channels_epochs([epochs_meg, epochs_eeg])
+    epochs2 = epochs_meg.copy().add_channels([epochs_eeg])
 
     epochs_meg2 = epochs_meg.copy()
     epochs_meg2.events[3, 2] -= 1
-    pytest.raises(ValueError, add_channels_epochs, [epochs_meg2, epochs_eeg])
 
-    pytest.raises(ValueError, add_channels_epochs,
-                  [epochs_meg, epochs_eeg[:2]])
-
-    epochs_meg.info['chs'].pop(0)
-    epochs_meg.info._update_redundant()
-    pytest.raises(RuntimeError, add_channels_epochs, [epochs_meg, epochs_eeg])
-
-    epochs_meg2 = epochs_meg.copy()
-    with epochs_meg2.info._unlock():
-        epochs_meg2.info['sfreq'] = None
-    pytest.raises(RuntimeError, add_channels_epochs, [epochs_meg2, epochs_eeg])
+    with pytest.raises(ValueError, match='must match'):
+        epochs_meg.add_channels([epochs_eeg[:2]])
 
     epochs_meg2 = epochs_meg.copy()
     with epochs_meg2.info._unlock():
         epochs_meg2.info['sfreq'] += 10
-    pytest.raises(RuntimeError, add_channels_epochs, [epochs_meg2, epochs_eeg])
+    assert 'eeg' not in epochs_meg
+    assert 'meg' not in epochs_eeg
+    with pytest.raises(RuntimeError, match='how to merge'):
+        epochs_meg2.add_channels([epochs_eeg])
 
     epochs_meg2 = epochs_meg.copy()
     with epochs_meg2.info._unlock():
         epochs_meg2.info['chs'][1]['ch_name'] = epochs_meg2.info['ch_names'][0]
     epochs_meg2.info._update_redundant()
-    with pytest.warns(RuntimeWarning, match='not unique'):
-        pytest.raises(RuntimeError, add_channels_epochs,
-                      [epochs_meg2, epochs_eeg])
-
-    epochs_meg2 = epochs_meg.copy()
-    with epochs_meg2.info._unlock():
-        epochs_meg2.info['dev_head_t']['to'] += 1
-    pytest.raises(ValueError, add_channels_epochs, [epochs_meg2, epochs_eeg])
-
-    epochs_meg2 = epochs_meg.copy()
-    with epochs_meg2.info._unlock():
-        epochs_meg2.info['dev_head_t']['to'] += 1
-    pytest.raises(ValueError, add_channels_epochs, [epochs_meg2, epochs_eeg])
-
-    epochs_meg2 = epochs_meg.copy()
-    epochs_meg2.info['experimenter'] = 'foo'
-    pytest.raises(RuntimeError, add_channels_epochs, [epochs_meg2, epochs_eeg])
-
-    epochs_meg2 = epochs_meg.copy()
-    epochs_meg2.preload = False
-    pytest.raises(ValueError, add_channels_epochs, [epochs_meg2, epochs_eeg])
-
-    epochs_meg2 = epochs_meg.copy()
-    epochs_meg2._set_times(epochs_meg.times + 0.4)
-    pytest.raises(NotImplementedError, add_channels_epochs,
-                  [epochs_meg2, epochs_eeg])
-
-    epochs_meg2 = epochs_meg.copy()
-    epochs_meg2._set_times(epochs_meg2.times + 0.5)
-    assert not epochs_meg2.times.flags['WRITEABLE']
-    pytest.raises(NotImplementedError, add_channels_epochs,
-                  [epochs_meg2, epochs_eeg])
-
-    epochs_meg2 = epochs_meg.copy()
-    epochs_meg2.baseline = None
-    pytest.raises(NotImplementedError, add_channels_epochs,
-                  [epochs_meg2, epochs_eeg])
-
-    epochs_meg2 = epochs_meg.copy()
-    epochs_meg2.event_id['b'] = 2
-    pytest.raises(NotImplementedError, add_channels_epochs,
-                  [epochs_meg2, epochs_eeg])
+    # There are other error modes, but let the other add_channels test validate
+    # them.
+    with pytest.warns(FutureWarning, match=r'epochs\.add_channels'):
+        add_channels_epochs([epochs_meg, epochs_eeg])
 
     # use delayed projection, add channel, ensure projectors match
     epochs_meg2 = make_epochs(picks=picks_meg, proj='delayed')
@@ -2947,7 +2902,7 @@ def test_add_channels():
     epoch_meg.load_data()
     pytest.raises(RuntimeError, epoch_meg.add_channels, [epoch_nopre])
     pytest.raises(RuntimeError, epoch_meg.add_channels, [epoch_badsf])
-    pytest.raises(AssertionError, epoch_meg.add_channels, [epoch_eeg])
+    pytest.raises(ValueError, epoch_meg.add_channels, [epoch_eeg])
     pytest.raises(ValueError, epoch_meg.add_channels, [epoch_meg])
     pytest.raises(TypeError, epoch_meg.add_channels, epoch_badsf)
 
diff --git a/mne/tests/test_evoked.py b/mne/tests/test_evoked.py
--- a/mne/tests/test_evoked.py
+++ b/mne/tests/test_evoked.py
@@ -808,7 +808,7 @@ def test_add_channels():
     evoked_eeg = evoked_eeg.crop(-.1, .1)
 
     pytest.raises(RuntimeError, evoked_meg.add_channels, [evoked_badsf])
-    pytest.raises(AssertionError, evoked_meg.add_channels, [evoked_eeg])
+    pytest.raises(ValueError, evoked_meg.add_channels, [evoked_eeg])
     pytest.raises(ValueError, evoked_meg.add_channels, [evoked_meg])
     pytest.raises(TypeError, evoked_meg.add_channels, evoked_badsf)
 
diff --git a/mne/time_frequency/tests/test_tfr.py b/mne/time_frequency/tests/test_tfr.py
--- a/mne/time_frequency/tests/test_tfr.py
+++ b/mne/time_frequency/tests/test_tfr.py
@@ -719,7 +719,7 @@ def test_add_channels():
     tfr_eeg = tfr_eeg.crop(.1, .1)
 
     pytest.raises(RuntimeError, tfr_meg.add_channels, [tfr_badsf])
-    pytest.raises(AssertionError, tfr_meg.add_channels, [tfr_eeg])
+    pytest.raises(ValueError, tfr_meg.add_channels, [tfr_eeg])
     pytest.raises(ValueError, tfr_meg.add_channels, [tfr_meg])
     pytest.raises(TypeError, tfr_meg.add_channels, tfr_badsf)
 
diff --git a/mne/utils/tests/test_docs.py b/mne/utils/tests/test_docs.py
--- a/mne/utils/tests/test_docs.py
+++ b/mne/utils/tests/test_docs.py
@@ -1,20 +1,17 @@
 import pytest
 
 from mne import open_docs, grade_to_tris
-from mne.epochs import add_channels_epochs
 from mne.utils import (copy_function_doc_to_method_doc, copy_doc,
                        linkcode_resolve, deprecated, deprecated_alias, legacy,
                        catch_logging)
 import webbrowser
 
 
-@pytest.mark.parametrize('obj', (grade_to_tris, add_channels_epochs))
+@pytest.mark.parametrize('obj', (grade_to_tris,))
 def test_doc_filling(obj):
     """Test that docs are filled properly."""
     doc = obj.__doc__
     assert 'verbose : ' in doc
-    if obj is add_channels_epochs:
-        assert 'passed as a keyword' in doc
 
 
 def test_deprecated_alias():