Repository: mne-tools/mne-python

Issue:
ENH: mne.set_bipolar_reference should not ignore bad channels
### Description of the problem

When setting the bipolar reference between one good and one bad channel, the final bipolar channel is marked as good.

Bad channels are marked as bad because they contain artefacts. The bipolar reference between a bad channel and a good channel can never yield a good channel as a result. Therefore, the resulting bipolar channels should be marked as bad by mne.

[Jupyter Notebook: Steps to reproduce](https://github.com/mne-tools/mne-python/files/9771538/bipolar_ref.pdf)


### Steps to reproduce

```Python
A jupyter notebook to reproduce the problem is uploaded as a pdf.
```


### Link to data

_No response_

### Expected results

Bipolar referencing works but the bipolar channel is automatically marked as bad.

### Actual results

Bipolar referencing works but the bipolar channel is marked as a good channel even though it is clearly a bad channel.

### Additional information

Platform:         macOS-12.6-arm64-arm-64bit
Python:           3.10.6 | packaged by conda-forge | (main, Aug 22 2022, 20:41:22) [Clang 13.0.1 ]
Executable:       /Users/moritzgerster/anaconda3/envs/bids_neu/bin/python
CPU:              arm: 10 cores
Memory:           16.0 GB

mne:              1.2.dev0
numpy:            1.23.2 {unknown linalg bindings (threadpoolctl module not found: No module named 'threadpoolctl')}
scipy:            1.9.0
matplotlib:       3.5.3 {backend=module://matplotlib_inline.backend_inline}

sklearn:          Not found
numba:            Not found
nibabel:          4.0.1
nilearn:          Not found
dipy:             Not found
openmeeg:         Not found
cupy:             Not found
pandas:           1.4.3
pyvista:          0.36.1 {OpenGL 4.1 Metal - 76.3 via Apple M1 Pro}
pyvistaqt:        0.9.0
ipyvtklink:       Not found
vtk:              9.1.0
qtpy:             2.2.0 {None=None}
ipympl:           Not found
pyqtgraph:        Not found
pooch:            v1.6.0

mne_bids:         0.10
mne_nirs:         Not found
mne_features:     Not found
mne_qt_browser:   Not found
mne_connectivity: Not found
mne_icalabel:     Not found


Discussion:
Hello! 👋 Thanks for opening your first issue here! ❤️ We will try to get back to you soon. 🚴🏽‍♂️

agreed set_bipolar_reference should update the info['bads'] and maybe even
maybe warn that a bipolar channel is created from bad channels?

> Message ID: ***@***.***>
>

> agreed set_bipolar_reference should update the info['bads'] and maybe even maybe warn that a bipolar channel is created from bad channels?
> […](#)
> Message ID: ***@***.***>

great, we currently work on a PR!
I think we should raise if a channel is bad, not just warn 🤔 
Agreed. And to give an option we could have `on_bad='raise'` as a default, that way people can override with `'warn'` or `'ignore'` if they really know what they are doing
> I think we should raise if a channel is bad, not just warn 🤔

Are you sure @hoechenberger? Usually, bad channels are easily excluded from further analysis, so it wouldn't hurt to add some bad bipolar channels to the instance. It could be very cumbersome to remove the bad channels from all anodes and cathodes manually before passing them to `set_bipolar_reference`. 

@larsoner's suggestion seems reasonable to me if `on_bad='warn'` will be the default argument?


If `on_bad='raise'`, which error should be raised? `ValueError`?
Use the `_on_missing` handler, it will take care of these conditionals and classes for you

Resolving patch:
diff --git a/mne/io/reference.py b/mne/io/reference.py
--- a/mne/io/reference.py
+++ b/mne/io/reference.py
@@ -17,7 +17,7 @@
 from ..epochs import BaseEpochs
 from ..fixes import pinv
 from ..utils import (logger, warn, verbose, _validate_type, _check_preload,
-                     _check_option, fill_doc)
+                     _check_option, fill_doc, _on_missing)
 from ..defaults import DEFAULTS
 
 
@@ -418,7 +418,8 @@ def _get_ch_type(inst, ch_type):
 
 @verbose
 def set_bipolar_reference(inst, anode, cathode, ch_name=None, ch_info=None,
-                          drop_refs=True, copy=True, verbose=None):
+                          drop_refs=True, copy=True, on_bad="warn",
+                          verbose=None):
     """Re-reference selected channels using a bipolar referencing scheme.
 
     A bipolar reference takes the difference between two channels (the anode
@@ -454,6 +455,11 @@ def set_bipolar_reference(inst, anode, cathode, ch_name=None, ch_info=None,
     copy : bool
         Whether to operate on a copy of the data (True) or modify it in-place
         (False). Defaults to True.
+    on_bad : str
+        If a bipolar channel is created from a bad anode or a bad cathode, mne
+        warns if on_bad="warns", raises ValueError if on_bad="raise", and does
+        nothing if on_bad="ignore". For "warn" and "ignore", the new bipolar
+        channel will be marked as bad. Defaults to on_bad="warns".
     %(verbose)s
 
     Returns
@@ -550,6 +556,7 @@ def set_bipolar_reference(inst, anode, cathode, ch_name=None, ch_info=None,
     # Set other info-keys from original instance.
     pick_info = {k: v for k, v in inst.info.items() if k not in
                  ['chs', 'ch_names', 'bads', 'nchan', 'sfreq']}
+
     with ref_info._unlock():
         ref_info.update(pick_info)
 
@@ -571,6 +578,18 @@ def set_bipolar_reference(inst, anode, cathode, ch_name=None, ch_info=None,
     # Add referenced instance to original instance.
     inst.add_channels([ref_inst], force_update_info=True)
 
+    # Handle bad channels.
+    bad_bipolar_chs = []
+    for ch_idx, (a, c) in enumerate(zip(anode, cathode)):
+        if a in inst.info['bads'] or c in inst.info['bads']:
+            bad_bipolar_chs.append(ch_name[ch_idx])
+
+    # Add warnings if bad channels are present.
+    if bad_bipolar_chs:
+        msg = f'Bipolar channels are based on bad channels: {bad_bipolar_chs}.'
+        _on_missing(on_bad, msg)
+        inst.info['bads'] += bad_bipolar_chs
+
     added_channels = ', '.join([name for name in ch_name])
     logger.info(f'Added the following bipolar channels:\n{added_channels}')
 


Test patch:
diff --git a/mne/io/tests/test_reference.py b/mne/io/tests/test_reference.py
--- a/mne/io/tests/test_reference.py
+++ b/mne/io/tests/test_reference.py
@@ -442,6 +442,22 @@ def test_set_bipolar_reference(inst_type):
     pytest.raises(ValueError, set_bipolar_reference, inst,
                   'EEG 001', 'EEG 002', ch_name='EEG 003')
 
+    # Test if bad anode/cathode raises error if on_bad="raise"
+    inst.info["bads"] = ["EEG 001"]
+    pytest.raises(ValueError, set_bipolar_reference, inst,
+                  'EEG 001', 'EEG 002', on_bad="raise")
+    inst.info["bads"] = ["EEG 002"]
+    pytest.raises(ValueError, set_bipolar_reference, inst,
+                  'EEG 001', 'EEG 002', on_bad="raise")
+
+    # Test if bad anode/cathode raises warning if on_bad="warn"
+    inst.info["bads"] = ["EEG 001"]
+    pytest.warns(RuntimeWarning, set_bipolar_reference, inst,
+                 'EEG 001', 'EEG 002', on_bad="warn")
+    inst.info["bads"] = ["EEG 002"]
+    pytest.warns(RuntimeWarning, set_bipolar_reference, inst,
+                 'EEG 001', 'EEG 002', on_bad="warn")
+
 
 def _check_channel_names(inst, ref_names):
     """Check channel names."""
@@ -696,3 +712,15 @@ def _check_bipolar(raw_test, ch_a, ch_b):
         raw, ['CH2', 'CH1'], ['CH1', 'CH2'], copy=True)
     _check_bipolar(raw_test, 'CH2', 'CH1')
     _check_bipolar(raw_test, 'CH1', 'CH2')
+
+    # test if bipolar channel is bad if anode is a bad channel
+    raw.info["bads"] = ["CH1"]
+    raw_test = set_bipolar_reference(raw, ['CH1'], ['CH2'], on_bad="ignore",
+                                     ch_name="bad_bipolar", copy=True)
+    assert raw_test.info["bads"] == ["bad_bipolar"]
+
+    # test if bipolar channel is bad if cathode is a bad channel
+    raw.info["bads"] = ["CH2"]
+    raw_test = set_bipolar_reference(raw, ['CH1'], ['CH2'], on_bad="ignore",
+                                     ch_name="bad_bipolar", copy=True)
+    assert raw_test.info["bads"] == ["bad_bipolar"]