Repository: mne-tools/mne-bids

Issue:
pybv for BV conversion: raw MUST drop all non Volt channels
We are currently simply making a raw.get_data() call and write the whole data using pybv, see:

https://github.com/mne-tools/mne-bids/blob/e2755da1136aa7eb473f0b05d7efc568b66a1e0a/mne_bids/write.py#L707

This is problematic, because pybv's assumption is that **all data channels are in Volts**.

This is not necessarily the case for an MNE raw object, so this can lead to bugs, where e.g. an aux channel that was in degrees Celsius, is suddenly encoded in Volts and does not make sense anymore.

Can be fixed by doing a `raw.pick_types` for all types that use Volts, prior to `raw.get_data()`.

Obviously, we'd need to warn the user if types are dropped.


Discussion:

Resolving patch:
diff --git a/mne_bids/write.py b/mne_bids/write.py
--- a/mne_bids/write.py
+++ b/mne_bids/write.py
@@ -29,6 +29,7 @@
 except ImportError:
     from mne._digitization._utils import _get_fid_coords
 from mne.channels.channels import _unit2human
+from mne.defaults import _handle_default
 from mne.utils import check_version, has_nibabel, logger, warn, _validate_type
 import mne.preprocessing
 
@@ -693,6 +694,21 @@ def _write_raw_brainvision(raw, bids_fname, events):
     if meas_date is not None:
         meas_date = _stamp_to_dt(meas_date)
 
+    # pybv currently only supports channels in Volts, except fNIRS
+    chtype_units = _handle_default('units', None)
+    chtypes_volt = {chtype: True for chtype, unit in chtype_units.items()
+                    if unit[-1] == 'V' and not chtype.startswith('fnirs')}
+    ch_idxs = mne.pick_types(raw.info, meg=False, **chtypes_volt, exclude=[])
+    if len(ch_idxs) != len(raw.ch_names):
+        non_volt_chs = set(raw.ch_names) - set(np.array(raw.ch_names)[ch_idxs])
+        msg = ('Conversion to BrainVision format needed to be stopped, '
+               'because your raw data contains channel types that are '
+               f'not represented in Volts: "{non_volt_chs}"'
+               '\n\nUntil BrainVision format conversion is improved, you '
+               'must drop these channels from your raw data before using '
+               'mne-bids. Please contact the mne-bids team about this.')
+        raise RuntimeError(msg)
+
     # XXX Always write as float32 to avoid precision loss during I/O roundtrip.
     # See https://github.com/bids-standard/pybv/issues/45
     fmt = 'binary_float32'


Test patch:
diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py
--- a/mne_bids/tests/test_write.py
+++ b/mne_bids/tests/test_write.py
@@ -337,6 +337,13 @@ def test_fif(_bids_validate):
     events = mne.find_events(raw2)
     event_id = {'auditory/left': 1, 'auditory/right': 2, 'visual/left': 3,
                 'visual/right': 4, 'smiley': 5, 'button': 32}
+    # XXX: Need to remove "Status" channel until pybv supports
+    # channels that are non-Volt
+    idxs = mne.pick_types(raw.info, meg=False, stim=True)
+    stim_ch_names = np.array(raw.ch_names)[idxs]
+    raw2.drop_channels(stim_ch_names)
+    raw.drop_channels(stim_ch_names)
+
     epochs = mne.Epochs(raw2, events, event_id=event_id, tmin=-0.2, tmax=0.5,
                         preload=True)
     with pytest.warns(RuntimeWarning,
@@ -357,7 +364,7 @@ def test_fif(_bids_validate):
     raw2 = read_raw_bids(bids_path=bids_path)
     os.remove(op.join(bids_root, 'test-raw.fif'))
 
-    events2 = mne.find_events(raw2)
+    events2, _ = mne.events_from_annotations(raw2, event_id)
     epochs2 = mne.Epochs(raw2, events2, event_id=event_id, tmin=-0.2, tmax=0.5,
                          preload=True)
     assert_array_almost_equal(raw.get_data(), raw2.get_data())
@@ -1088,6 +1095,12 @@ def test_eegieeg(dir_name, fname, reader, _bids_validate):
     if check_version('pybv', '0.3'):
         daysback_min, daysback_max = _get_anonymization_daysback(raw)
         daysback = (daysback_min + daysback_max) // 2
+
+        # XXX: Need to remove "Status" channel until pybv supports
+        # channels that are non-Volt
+        if dir_name == 'EDF':
+            raw.drop_channels("Status")
+
         kwargs = dict(raw=raw, bids_path=bids_path,
                       anonymize=dict(daysback=daysback), overwrite=True)
         if dir_name == 'EDF':
@@ -1186,6 +1199,12 @@ def test_eegieeg(dir_name, fname, reader, _bids_validate):
     # test anonymize and convert
     if check_version('pybv', '0.3'):
         raw = reader(raw_fname)
+
+        # XXX: Need to remove "Status" channel until pybv supports
+        # channels that are non-Volt
+        if dir_name == 'EDF':
+            raw.drop_channels("Status")
+
         kwargs = dict(raw=raw, bids_path=bids_path, overwrite=True)
         if dir_name == 'NihonKohden':
             with pytest.warns(RuntimeWarning,
@@ -1259,6 +1278,12 @@ def test_bdf(_bids_validate):
     # test anonymize and convert
     if check_version('pybv', '0.3'):
         raw = _read_raw_bdf(raw_fname)
+        # XXX: Need to remove "Status" channel until pybv supports
+        # channels that are non-Volt
+        with pytest.raises(RuntimeError, match='''in Volts: "{'Status'}"'''):
+            output_path = _test_anonymize(raw, bids_path)
+
+        raw.pick_types(eeg=True, exclude=[])
         with pytest.warns(RuntimeWarning,
                           match='Encountered data in "int" format'):
             output_path = _test_anonymize(raw, bids_path)
@@ -1721,7 +1746,6 @@ def test_mark_bad_channels(_bids_validate,
                            existing_ch_names, existing_descriptions,
                            datatype, overwrite):
     """Test marking channels of an existing BIDS dataset as "bad"."""
-
     # Setup: Create a fresh BIDS dataset.
     bids_root = _TempDir()
     bids_path = _bids_path.copy().update(root=bids_root, datatype='meg',
@@ -1827,14 +1851,15 @@ def test_mark_bad_channels_files():
     """Test validity of bad channel writing."""
     # BV
     bids_root = _TempDir()
-    data_path = op.join(base_path, 'brainvision', 'tests', 'data')
-    raw_fname = op.join(data_path, 'test.vhdr')
+    data_path = op.join(testing.data_path(), 'montage')
+    raw_fname = op.join(data_path, 'bv_dig_test.vhdr')
 
     raw = _read_raw_brainvision(raw_fname)
+    raw.set_channel_types({'HEOG': 'eog', 'VEOG': 'eog', 'ECG': 'ecg'})
 
     # inject a bad channel
     assert not raw.info['bads']
-    injected_bad = ['FP1']
+    injected_bad = ['Fp1']
     raw.info['bads'] = injected_bad
 
     bids_path = _bids_path.copy().update(root=bids_root)
@@ -1842,9 +1867,8 @@ def test_mark_bad_channels_files():
     # write with injected bad channels
     write_raw_bids(raw, bids_path, overwrite=True)
 
-    # TODO: allow write_brain_vision to write different units
     # mark bad channels that get stored as uV in write_brain_vision
-    bads = ['CP5', 'CP6', 'HL', 'HR', 'Vb', 'ReRef']
+    bads = ['CP5', 'CP6']
 
     with pytest.warns(RuntimeWarning,
                       match='Encountered data in "short" format'):