Repository: mne-tools/mne-bids

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

```


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

@drammock From the documentation of the `event_id` parameter:

> If Annotations are present, their descriptions must be included in event_id as well

The problem we're facing is that BIDS demands we assign an event code, and currently we're not picking any automatically for Annotations
Would it make sense to add those entries automatically for "special" annotation values like this?
I believe we once considered this but didn't want to choose an arbitrary event code 

But now I feeel -- why not?
Just ensure that the event code doesn't exist already in event_id and it should be good... 
Given that we are an "MNE-Python library", I would be fine to add some special treatment for "classic" MNE-Python annotations that have a defined meaning, like `BAD_ACQ_SKIP`.

Resolving patch:
diff --git a/mne_bids/read.py b/mne_bids/read.py
--- 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
--- a/mne_bids/tests/test_read.py
+++ b/mne_bids/tests/test_read.py
@@ -46,6 +46,15 @@
 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):