Repository: mne-tools/mne-bids (python)

Issue:
_read_events errors on BAD_ACQ_SKIP
### Description of the problem

I have a MEG FIF dataset that mostly doesn't have annotations (only the occasional `BAD_ACQ_SKIP`), and does have stim-channel events (which I parse beforehand and pass in as an events array). I would expect the `BAD_ACQ_SKIP` annotation to be silently preserved in the Raw file, but in fact `write_raw_bids` fails because there is no entry for `BAD_ACQ_SKIP` in the event ID dictionary.

Am I just missing something obvious here?  I've not actually used mne-bids very much so it's quite possible I'm just doing it wrong.

### Steps to reproduce

```Python
import mne
from mne_bids import BIDSPath, write_raw_bids


raw_path = mne.datasets.sample.data_path() / "MEG" / "sample" / "sample_audvis_raw.fif"

raw = mne.io.read_raw_fif(raw_path).crop(tmax=5).load_data()
raw.set_annotations(mne.Annotations([2], [0.2], ["BAD_ACQ_SKIP"]))

events = mne.find_events(raw)
event_id = dict(foo=1, bar=2, baz=3)

bp = BIDSPath(root="/home/drmccloy/Desktop/tmp-mwe", subject="sample", task="audvis")
write_raw_bids(
    raw,
    bids_path=bp,
    events=events,
    event_id=event_id,
    allow_preload=True,
    format="FIF",
)
```


### Expected results

file is written

### Actual results

```

------------------------------------------------------------------
ValueError                       Traceback (most recent call last)
Cell In[1], line 14
     11 event_id = dict(foo=1, bar=2, baz=3)
     13 bp = BIDSPath(root="/home/drmccloy/Desktop/tmp-mwe", subject="sample", task="audvis")
---> 14 write_raw_bids(
     15     raw,
     16     bids_path=bp,
     17     events=events,
     18     event_id=event_id,
     19     allow_preload=True,
     20     format="FIF",
     21 )

File <decorator-gen-445>:12, in write_raw_bids(raw, bids_path, events, event_id, anonymize, format, symlink, empty_room, allow_preload, montage, acpc_aligned, overwrite, verbose)

File /opt/mne/bids/mne_bids/write.py:1979, in write_raw_bids(raw, bids_path, events, event_id, anonymize, format, symlink, empty_room, allow_preload, montage, acpc_aligned, overwrite, verbose)
   1977 # Write events.
   1978 if not data_is_emptyroom:
-> 1979     events_array, event_dur, event_desc_id_map = _read_events(
   1980         events, event_id, raw, bids_path=bids_path
   1981     )
   1982     if events_array.size != 0:
   1983         _events_tsv(
   1984             events=events_array,
   1985             durations=event_dur,
   (...)
   1989             overwrite=overwrite,
   1990         )

File /opt/mne/bids/mne_bids/read.py:158, in _read_events(events, event_id, raw, bids_path)
    154         desc_without_id = sorted(
    155             set(raw.annotations.description) - set(event_id.keys())
    156         )
    157         if desc_without_id:
--> 158             raise ValueError(
    159                 f"The provided raw data contains annotations, but "
    160                 f'"event_id" does not contain entries for all annotation '
    161                 f"descriptions. The following entries are missing: "
    162                 f'{", ".join(desc_without_id)}'
    163             )
    165 # If we have events, convert them to Annotations so they can be easily
    166 # merged with existing Annotations.
    167 if events.size > 0:

ValueError: The provided raw data contains annotations, but "event_id" does not contain entries for all annotation descriptions. The following entries are missing: BAD_ACQ_SKIP

```

### Additional information

```

Platform             Linux-5.10.0-29-amd64-x86_64-with-glibc2.31
Python               3.12.3 | packaged by conda-forge | (main, Apr 15 2024, 18:38:13) [GCC 12.3.0]
Executable           /opt/mambaforge/envs/mnedev/bin/python3.12
CPU                   (12 cores)
Memory               62.7 GB

Core
├☑ mne               1.8.0.dev16+g768e5b322 (devel, latest release is 1.7.0)
├☑ numpy             1.26.4 (OpenBLAS 0.3.23.dev with 12 threads)
├☑ scipy             1.13.1
└☑ matplotlib        3.9.0 (backend=qtagg)

Numerical (optional)
├☑ sklearn           1.5.0
├☑ numba             0.59.1
├☑ nibabel           5.2.1
├☑ nilearn           0.10.4
├☑ dipy              1.9.0
├☑ openmeeg          2.5.10
├☑ pandas            2.2.2
├☑ h5io              0.2.2
├☑ h5py              3.11.0
└☐ unavailable       cupy

Visualization (optional)
├☑ pyvista           0.43.8 (OpenGL 4.6 (Core Profile) Mesa 21.2.6 via Mesa Intel(R) UHD Graphics (CML GT2))
├☑ pyvistaqt         0.11.0
├☑ vtk               9.3.0
├☑ qtpy              2.4.1 (PyQt6=6.7.0)
├☑ ipympl            0.9.4
├☑ pyqtgraph         0.13.7
├☑ mne-qt-browser    0.7.0.dev12+g1c9819a
├☑ ipywidgets        8.1.3
├☑ trame_client      3.0.3
├☑ trame_server      3.0.0
├☑ trame_vtk         2.8.8
└☑ trame_vuetify     2.5.0

Ecosystem (optional)
├☑ mne-bids          0.15.0.dev54+gc87a8233
├☑ mne-nirs          0.7.0.dev0
├☑ mne-connectivity  0.7.0.dev0
├☑ neo               0.13.1
├☑ eeglabio          0.0.2-4
├☑ edfio             0.4.2
├☑ mffpy             0.9.0
├☑ pybv              0.7.5
└☐ unavailable       mne-features, mne-icalabel, mne-bids-pipeline

```

Pull request description:
[MRG] auto-add event_id key for BAD_ACQ_SKIP
<!--
Thanks for contributing this pull request (PR).
If this is your first time, make sure to read
[CONTRIBUTING.md](https://github.com/mne-tools/mne-bids/blob/main/CONTRIBUTING.md)
-->

PR Description
--------------

closes #1257 

`write_raw_bids` will now allow the situation where `BAD_ACQ_SKIP` annotations are present in the raw file, but no corresponding key is present in the user-passed `event_id` dict.

Merge checklist
---------------

Maintainer, please confirm the following before merging.
If applicable:

- [ ] All comments are resolved
- [ ] This is not your own PR
- [ ] All CIs are happy
- [x] PR title starts with [MRG]
- [x] [whats_new.rst](https://github.com/mne-tools/mne-bids/blob/main/doc/whats_new.rst) is updated
- [x] New contributors have been added to [CITATION.cff](https://github.com/mne-tools/mne-bids/blob/main/CITATION.cff)
- [x] PR description includes phrase "closes <#issue-number>"


Patch:
diff --git a/doc/whats_new.rst b/doc/whats_new.rst
index 317860aa..dd6e37a1 100644
--- a/doc/whats_new.rst
+++ b/doc/whats_new.rst
@@ -28,6 +28,7 @@ The following authors had contributed before. Thank you for sticking around! 
 * `Laetitia Fesselier`_
 * `Richard Höchenberger`_
 * `Stefan Appelhoff`_
+* `Daniel McCloy`_
 
 Detailed list of changes
 ~~~~~~~~~~~~~~~~~~~~~~~~
@@ -46,6 +47,8 @@ Detailed list of changes
   For example, If ``run=1`` is passed to MNE-BIDS, it will no longer be silently auto-converted to ``run-01``, by `Alex Rockhill`_ (:gh:`1215`)
 - MNE-BIDS will no longer warn about missing leading punctuation marks for extensions passed :class:`~mne_bids.BIDSPath`.
   For example, MNE-BIDS will now silently auto-convert ``edf`` to ```.edf``, by `Alex Rockhill`_ (:gh:`1215`)
+- MNE-BIDS will no longer error during `~mne_bids.write_raw_bids` if there are ``BAD_ACQ_SKIP`` annotations in the raw file and no corresponding key in ``event_id``.
+  Instead, it will automatically add the necessary key and assign an unused integer event code to it. By `Daniel McCloy`_ (:gh:`1258`)
 
 🛠 Requirements
 ^^^^^^^^^^^^^^^
diff --git a/mne_bids/read.py b/mne_bids/read.py
index 45089d8f..cf91f43d 100644
--- a/mne_bids/read.py
+++ b/mne_bids/read.py
@@ -151,9 +151,18 @@ def _read_events(events, event_id, raw, bids_path=None):
                 'To specify custom event codes, please pass "event_id".'
             )
         else:
+            special_annots = {"BAD_ACQ_SKIP"}
             desc_without_id = sorted(
                 set(raw.annotations.description) - set(event_id.keys())
             )
+            # auto-add entries to `event_id` for "special" annotation values
+            # (but only if they're needed)
+            if set(desc_without_id) & special_annots:
+                for annot in special_annots:
+                    # use a value guaranteed to not be in use
+                    event_id = {annot: max(event_id.values()) + 90000} | event_id
+                # remove the "special" annots from the list of problematic annots
+                desc_without_id = sorted(set(desc_without_id) - special_annots)
             if desc_without_id:
                 raise ValueError(
                     f"The provided raw data contains annotations, but "


Test patch:
diff --git a/mne_bids/tests/test_read.py b/mne_bids/tests/test_read.py
index b5830bc7..560f71ac 100644
--- a/mne_bids/tests/test_read.py
+++ b/mne_bids/tests/test_read.py
@@ -46,6 +46,15 @@ run = "01"
 acq = "01"
 task = "testing"
 
+sample_data_event_id = {
+    "Auditory/Left": 1,
+    "Auditory/Right": 2,
+    "Visual/Left": 3,
+    "Visual/Right": 4,
+    "Smiley": 5,
+    "Button": 32,
+}
+
 _bids_path = BIDSPath(
     subject=subject_id, session=session_id, run=run, acquisition=acq, task=task
 )
@@ -214,14 +223,6 @@ def test_get_head_mri_trans(tmp_path):
     """Test getting a trans object from BIDS data."""
     nib = pytest.importorskip("nibabel")
 
-    event_id = {
-        "Auditory/Left": 1,
-        "Auditory/Right": 2,
-        "Visual/Left": 3,
-        "Visual/Right": 4,
-        "Smiley": 5,
-        "Button": 32,
-    }
     events_fname = op.join(
         data_path, "MEG", "sample", "sample_audvis_trunc_raw-eve.fif"
     )
@@ -234,7 +235,9 @@ def test_get_head_mri_trans(tmp_path):
     # Write it to BIDS
     raw = _read_raw_fif(raw_fname)
     bids_path = _bids_path.copy().update(root=tmp_path, datatype="meg", suffix="meg")
-    write_raw_bids(raw, bids_path, events=events, event_id=event_id, overwrite=False)
+    write_raw_bids(
+        raw, bids_path, events=events, event_id=sample_data_event_id, overwrite=False
+    )
 
     # We cannot recover trans if no MRI has yet been written
     with pytest.raises(FileNotFoundError, match="Did not find"):
@@ -300,7 +303,7 @@ def test_get_head_mri_trans(tmp_path):
     # sidecar, and also accept "nasion" instead of just "NAS"
     raw = _read_raw_fif(raw_fname)
     write_raw_bids(
-        raw, bids_path, events=events, event_id=event_id, overwrite=True
+        raw, bids_path, events=events, event_id=sample_data_event_id, overwrite=True
     )  # overwrite with new acq
     t1w_bids_path = write_anat(
         t1w_mgh, bids_path=t1w_bids_path, landmarks=landmarks, overwrite=True
@@ -578,6 +581,27 @@ def test_keep_essential_annotations(tmp_path):
     assert raw_read.annotations[0]["description"] == raw.annotations[0]["description"]
 
 
+@testing.requires_testing_data
+def test_adding_essential_annotations_to_dict(tmp_path):
+    """Test that essential Annotations are auto-added to the `event_id` dictionary."""
+    raw = _read_raw_fif(raw_fname)
+    annotations = mne.Annotations(
+        onset=[raw.times[0]], duration=[1], description=["BAD_ACQ_SKIP"]
+    )
+    raw.set_annotations(annotations)
+    events = mne.find_events(raw)
+    event_id = sample_data_event_id.copy()
+    obj_id = id(event_id)
+
+    # see that no error is raised for missing event_id key for BAD_ACQ_SKIP
+    bids_path = BIDSPath(subject="01", task="task", datatype="meg", root=tmp_path)
+    with pytest.warns(RuntimeWarning, match="Acquisition skips detected"):
+        write_raw_bids(raw, bids_path, overwrite=True, events=events, event_id=event_id)
+    # make sure we didn't modify the user-passed dict
+    assert event_id == sample_data_event_id
+    assert obj_id == id(event_id)
+
+
 @pytest.mark.filterwarnings(warning_str["channel_unit_changed"])
 @testing.requires_testing_data
 def test_handle_scans_reading(tmp_path):