Repository: mne-tools/mne-python

Issue:
API: Use annotations for read_raw_egi events rather than synthetic channel 
In https://github.com/mne-tools/mne-python/pull/4017#issuecomment-299519890 we added `RawEGI.event_id` which disappears on round-trip. This is due to not knowing which/how many DIN channels will get triggers.

Nowadays we should use Annotations rather than synthesize an event channel I think. Not sure if it's worth keeping the event channel synthesizing around for backward compat or deprecating it. But either way adding the annotations will make it possible for people to use modern code rather than relying on `raw.event_id` here.

See also [discourse](https://mne.discourse.group/t/saving-event-id-attribute-in-raw-object/7932/4) for discussion.

Discussion:
larsoner: ... bonus points if this somehow takes care of https://github.com/mne-tools/mne-python/pull/11674
scott-huberty: > ... bonus points if this somehow takes care of https://github.com/mne-tools/mne-python/pull/11674 / https://github.com/mne-tools/mne-python/issues/11626 / https://github.com/mne-tools/mne-python/pull/11627

As suggested [here](https://github.com/mne-tools/mne-python/pull/11674#issuecomment-1631560083) I can quickly check if simply allowing the initial one-shot event  fixes the issue without breaking a bunch of tests. 

If it ends up being more complicated than that, maybe it will be a better use of time instead to get a PR up that uses mffpy which should solve those issues, as suggested [here](https://github.com/mne-tools/mne-python/pull/11674#issuecomment-1631548528)

I can report back. 


larsoner: @scott-huberty we're planning to release in 3ish weeks and it would be great to get this in, let me know if you want help
scott-huberty: > @scott-huberty we're planning to release in 3ish weeks and it would be great to get this in, let me know if you want help

I've just moved to a new city and started a postdoc last week, so I'm a bit pressed for time right now 😅.

Initially, due to time constraints, I was a little lazy with this PR since it only creates annotations using the generated stim channel.

However, it was suggested that if the new parameter `events_as_annotations=True`, we should not generate a stim channel. Instead, we should create the annotations directly from the event information in the file.

This has been a blocker for me because I haven't had the time to really dig into the `read_raw_egi` code and figure out where this event information is generated from the file.


larsoner: Okay this one should be ready for review/merge @drammock !
drammock: related CI failures:

```
_____________________________ test_egi_dig_montage _____________________________
mne/channels/tests/test_montage.py:1077: in test_egi_dig_montage
    raw_egi = read_raw_egi(egi_raw_fname, channel_naming="EEG %03d")
<decorator-gen-500>:12: in read_raw_egi
    ???
mne/io/egi/egi.py:174: in read_raw_egi
    warn(
mne/utils/_logging.py:415: in warn
    warnings.warn_explicit(
E   FutureWarning: events_as_annotations defaults to False in 1.8 but will change to True in 1.9, set it explicitly to avoid this warning
```
larsoner: Thanks for the quick review, pushed a commit to hopefully fix things @drammock !

Patch:
diff --git a/doc/changes/devel/12300.apichange.rst b/doc/changes/devel/12300.apichange.rst
new file mode 100644
index 000000000..f9d082a4b
--- /dev/null
+++ b/doc/changes/devel/12300.apichange.rst
@@ -0,0 +1,3 @@
+A new argument ``events_as_annotations`` has been added to :func:`mne.io.read_raw_egi`
+with a default value of ``False`` that will change to ``True`` in version 1.9, by
+`Scott Huberty`_ and `Eric Larson`_.
diff --git a/doc/changes/devel/12300.bugfix.rst b/doc/changes/devel/12300.bugfix.rst
new file mode 100644
index 000000000..c5c167d01
--- /dev/null
+++ b/doc/changes/devel/12300.bugfix.rst
@@ -0,0 +1,1 @@
+Fix bug where an event that occurred only once was excluded in :func:`mne.io.read_raw_egi`, by :newcontrib:`Ping-Keng Jao`.
diff --git a/doc/changes/names.inc b/doc/changes/names.inc
index c6f35fade..d54368e06 100644
--- a/doc/changes/names.inc
+++ b/doc/changes/names.inc
@@ -454,6 +454,8 @@
 
 .. _Pierre-Antoine Bannier: https://github.com/PABannier
 
+.. _Ping-Keng Jao: https://github.com/nafraw
+
 .. _Proloy Das: https://github.com/proloyd
 
 .. _Qian Chu: https://github.com/qian-chu
diff --git a/mne/io/egi/egi.py b/mne/io/egi/egi.py
index e95577f86..980b6a7b5 100644
--- a/mne/io/egi/egi.py
+++ b/mne/io/egi/egi.py
@@ -13,10 +13,11 @@ import numpy as np
 from ..._fiff.constants import FIFF
 from ..._fiff.meas_info import _empty_info
 from ..._fiff.utils import _create_chs, _read_segments_file
+from ...annotations import Annotations
 from ...utils import _check_fname, _validate_type, logger, verbose, warn
 from ..base import BaseRaw
 from .egimff import _read_raw_egi_mff
-from .events import _combine_triggers
+from .events import _combine_triggers, _triage_include_exclude
 
 
 def _read_header(fid):
@@ -101,13 +102,12 @@ def read_raw_egi(
     exclude=None,
     preload=False,
     channel_naming="E%d",
+    *,
+    events_as_annotations=None,
     verbose=None,
 ) -> "RawEGI":
     """Read EGI simple binary as raw object.
 
-    .. note:: This function attempts to create a synthetic trigger channel.
-              See the Notes section below.
-
     Parameters
     ----------
     input_fname : path-like
@@ -120,14 +120,13 @@ def read_raw_egi(
         Names of channels or list of indices that should be designated
         MISC channels. Default is None.
     include : None | list
-       The event channels to be ignored when creating the synthetic
-       trigger. Defaults to None.
+       The event channels to be included when creating the synthetic
+       trigger or annotations. Defaults to None.
        Note. Overrides ``exclude`` parameter.
     exclude : None | list
        The event channels to be ignored when creating the synthetic
-       trigger. Defaults to None. If None, channels that have more than
-       one event and the ``sync`` and ``TREV`` channels will be
-       ignored.
+       trigger or annotations. Defaults to None. If None, the ``sync`` and ``TREV``
+       channels will be ignored. This is ignored when ``include`` is not None.
     %(preload)s
 
         .. versionadded:: 0.11
@@ -135,8 +134,15 @@ def read_raw_egi(
         Channel naming convention for the data channels. Defaults to ``'E%%d'``
         (resulting in channel names ``'E1'``, ``'E2'``, ``'E3'``...). The
         effective default prior to 0.14.0 was ``'EEG %%03d'``.
+        .. versionadded:: 0.14.0
 
-         .. versionadded:: 0.14.0
+    events_as_annotations : bool
+        If True, annotations are created from experiment events. If False (default),
+        a synthetic trigger channel ``STI 014`` is created from experiment events.
+        See the Notes section for details.
+        The default will change from False to True in version 1.9.
+
+        .. versionadded:: 1.8.0
     %(verbose)s
 
     Returns
@@ -151,27 +157,51 @@ def read_raw_egi(
 
     Notes
     -----
-    The trigger channel names are based on the arbitrary user dependent event
-    codes used. However this function will attempt to generate a **synthetic
-    trigger channel** named ``STI 014`` in accordance with the general
-    Neuromag / MNE naming pattern.
-
-    The event_id assignment equals ``np.arange(n_events) + 1``. The resulting
-    ``event_id`` mapping is stored as attribute to the resulting raw object but
-    will be ignored when saving to a fiff. Note. The trigger channel is
-    artificially constructed based on timestamps received by the Netstation.
-    As a consequence, triggers have only short durations.
-
-    This step will fail if events are not mutually exclusive.
+    When ``events_from_annotations=True``, event codes on stimulus channels like
+    ``DIN1`` are stored as annotations with the ``description`` set to the stimulus
+    channel name.
+
+    When ``events_from_annotations=False`` and events are present on the included
+    stimulus channels, a new stim channel ``STI014`` will be synthesized from the
+    events. It will contain 1-sample pulses where the Netstation file had event
+    timestamps. A ``raw.event_id`` dictionary is added to the raw object that will have
+    arbitrary sequential integer IDs for the events. This will fail if any timestamps
+    are duplicated. The ``event_id`` will also not survive a save/load roundtrip.
+
+    For these reasons, it is recommended to use ``events_as_annotations=True``.
     """
     _validate_type(input_fname, "path-like", "input_fname")
     input_fname = str(input_fname)
+    if events_as_annotations is None:
+        warn(
+            "events_as_annotations defaults to False in 1.8 but will change to "
+            "True in 1.9, set it explicitly to avoid this warning",
+            FutureWarning,
+        )
+        events_as_annotations = False
+
     if input_fname.rstrip("/\\").endswith(".mff"):  # allows .mff or .mff/
         return _read_raw_egi_mff(
-            input_fname, eog, misc, include, exclude, preload, channel_naming, verbose
+            input_fname,
+            eog,
+            misc,
+            include,
+            exclude,
+            preload,
+            channel_naming,
+            events_as_annotations=events_as_annotations,
+            verbose=verbose,
         )
     return RawEGI(
-        input_fname, eog, misc, include, exclude, preload, channel_naming, verbose
+        input_fname,
+        eog,
+        misc,
+        include,
+        exclude,
+        preload,
+        channel_naming,
+        events_as_annotations=events_as_annotations,
+        verbose=verbose,
     )
 
 
@@ -190,6 +220,8 @@ class RawEGI(BaseRaw):
         exclude=None,
         preload=False,
         channel_naming="E%d",
+        *,
+        events_as_annotations=True,
         verbose=None,
     ):
         input_fname = str(_check_fname(input_fname, "read", True, "input_fname"))
@@ -209,58 +241,18 @@ class RawEGI(BaseRaw):
 
         logger.info("    Assembling measurement info ...")
 
-        event_codes = []
-        if egi_info["n_events"] > 0:
-            event_codes = list(egi_info["event_codes"])
-            if include is None:
-                exclude_list = ["sync", "TREV"] if exclude is None else exclude
-                exclude_inds = [
-                    i for i, k in enumerate(event_codes) if k in exclude_list
-                ]
-                more_excludes = []
-                if exclude is None:
-                    for ii, event in enumerate(egi_events):
-                        if event.sum() <= 1 and event_codes[ii]:
-                            more_excludes.append(ii)
-                if len(exclude_inds) + len(more_excludes) == len(event_codes):
-                    warn(
-                        "Did not find any event code with more than one event.",
-                        RuntimeWarning,
-                    )
-                else:
-                    exclude_inds.extend(more_excludes)
-
-                exclude_inds.sort()
-                include_ = [
-                    i for i in np.arange(egi_info["n_events"]) if i not in exclude_inds
-                ]
-                include_names = [k for i, k in enumerate(event_codes) if i in include_]
-            else:
-                include_ = [i for i, k in enumerate(event_codes) if k in include]
-                include_names = include
-
-            for kk, v in [("include", include_names), ("exclude", exclude)]:
-                if isinstance(v, list):
-                    for k in v:
-                        if k not in event_codes:
-                            raise ValueError(f'Could find event named "{k}"')
-                elif v is not None:
-                    raise ValueError(f"`{kk}` must be None or of type list")
-
-            event_ids = np.arange(len(include_)) + 1
+        event_codes = egi_info["event_codes"]
+        include = _triage_include_exclude(include, exclude, egi_events, egi_info)
+        if egi_info["n_events"] > 0 and not events_as_annotations:
+            event_ids = np.arange(len(include)) + 1
             logger.info('    Synthesizing trigger channel "STI 014" ...')
-            excl_events = ", ".join(
-                k for i, k in enumerate(event_codes) if i not in include_
-            )
-            logger.info(f"    Excluding events {{{excl_events}}} ...")
             egi_info["new_trigger"] = _combine_triggers(
-                egi_events[include_], remapping=event_ids
+                egi_events[[e in include for e in event_codes]], remapping=event_ids
             )
             self.event_id = dict(
-                zip([e for e in event_codes if e in include_names], event_ids)
+                zip([e for e in event_codes if e in include], event_ids)
             )
         else:
-            # No events
             self.event_id = None
             egi_info["new_trigger"] = None
         info = _empty_info(egi_info["samp_rate"])
@@ -275,11 +267,12 @@ class RawEGI(BaseRaw):
         my_timestamp = time.mktime(my_time.timetuple())
         info["meas_date"] = (my_timestamp, 0)
         ch_names = [channel_naming % (i + 1) for i in range(egi_info["n_channels"])]
-        ch_names.extend(list(egi_info["event_codes"]))
+        cals = np.repeat(cal, len(ch_names))
+        ch_names.extend(list(event_codes))
+        cals = np.concatenate([cals, np.ones(egi_info["n_events"])])
         if egi_info["new_trigger"] is not None:
             ch_names.append("STI 014")  # our new_trigger
-        nchan = len(ch_names)
-        cals = np.repeat(cal, nchan)
+            cals = np.concatenate([cals, [1.0]])
         ch_coil = FIFF.FIFFV_COIL_EEG
         ch_kind = FIFF.FIFFV_EEG_CH
         chs = _create_chs(ch_names, cals, ch_coil, ch_kind, eog, (), (), misc)
@@ -292,7 +285,6 @@ class RawEGI(BaseRaw):
             chs[idx].update(
                 {
                     "unit_mul": FIFF.FIFF_UNITM_NONE,
-                    "cal": 1.0,
                     "kind": FIFF.FIFFV_STIM_CH,
                     "coil_type": FIFF.FIFFV_COIL_NONE,
                     "unit": FIFF.FIFF_UNIT_NONE,
@@ -314,6 +306,17 @@ class RawEGI(BaseRaw):
             raw_extras=[egi_info],
             verbose=verbose,
         )
+        if events_as_annotations:
+            annot = dict(onset=list(), duration=list(), description=list())
+            for code, row in zip(egi_info["event_codes"], egi_events):
+                if code not in include:
+                    continue
+                onset = np.where(row)[0] / self.info["sfreq"]
+                annot["onset"].extend(onset)
+                annot["duration"].extend([0.0] * len(onset))
+                annot["description"].extend([code] * len(onset))
+            if annot:
+                self.set_annotations(Annotations(**annot))
 
     def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
         """Read a segment of data from a file."""
diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py
index b8093a5e9..1b76ff60c 100644
--- a/mne/io/egi/egimff.py
+++ b/mne/io/egi/egimff.py
@@ -20,7 +20,7 @@ from ...channels.montage import make_dig_montage
 from ...evoked import EvokedArray
 from ...utils import _check_fname, _check_option, _soft_import, logger, verbose, warn
 from ..base import BaseRaw
-from .events import _combine_triggers, _read_events
+from .events import _combine_triggers, _read_events, _triage_include_exclude
 from .general import (
     _block_r,
     _extract,
@@ -351,68 +351,21 @@ def _read_raw_egi_mff(
     exclude=None,
     preload=False,
     channel_naming="E%d",
+    *,
+    events_as_annotations=False,
     verbose=None,
 ):
-    """Read EGI mff binary as raw object.
-
-    .. note:: This function attempts to create a synthetic trigger channel.
-              See notes below.
-
-    Parameters
-    ----------
-    input_fname : path-like
-        Path to the raw file.
-    eog : list or tuple
-        Names of channels or list of indices that should be designated
-        EOG channels. Default is None.
-    misc : list or tuple
-        Names of channels or list of indices that should be designated
-        MISC channels. Default is None.
-    include : None | list
-       The event channels to be ignored when creating the synthetic
-       trigger. Defaults to None.
-       Note. Overrides `exclude` parameter.
-    exclude : None | list
-       The event channels to be ignored when creating the synthetic
-       trigger. Defaults to None. If None, channels that have more than
-       one event and the ``sync`` and ``TREV`` channels will be
-       ignored.
-    %(preload)s
-    channel_naming : str
-        Channel naming convention for the data channels. Defaults to 'E%%d'
-        (resulting in channel names 'E1', 'E2', 'E3'...). The effective default
-        prior to 0.14.0 was 'EEG %%03d'.
-    %(verbose)s
-
-    Returns
-    -------
-    raw : instance of RawMff
-        A Raw object containing EGI mff data.
-        See :class:`mne.io.Raw` for documentation of attributes and methods.
-
-    Notes
-    -----
-    The trigger channel names are based on the arbitrary user dependent event
-    codes used. However this function will attempt to generate a synthetic
-    trigger channel named ``STI 014`` in accordance with the general
-    Neuromag / MNE naming pattern.
-
-    The event_id assignment equals ``np.arange(n_events) + 1``. The resulting
-    ``event_id`` mapping is stored as attribute to the resulting raw object but
-    will be ignored when saving to a fiff. Note. The trigger channel is
-    artificially constructed based on timestamps received by the Netstation.
-    As a consequence, triggers have only short durations.
-
-    This step will fail if events are not mutually exclusive.
-
-    See Also
-    --------
-    mne.io.Raw : Documentation of attributes and methods of RawMff.
-
-    .. versionadded:: 0.15.0
-    """
+    """Read EGI mff binary as raw object."""
     return RawMff(
-        input_fname, eog, misc, include, exclude, preload, channel_naming, verbose
+        input_fname,
+        eog,
+        misc,
+        include,
+        exclude,
+        preload,
+        channel_naming,
+        events_as_annotations=events_as_annotations,
+        verbose=verbose,
     )
 
 
@@ -431,6 +384,8 @@ class RawMff(BaseRaw):
         exclude=None,
         preload=False,
         channel_naming="E%d",
+        *,
+        events_as_annotations=True,
         verbose=None,
     ):
         """Init the RawMff class."""
@@ -451,75 +406,36 @@ class RawMff(BaseRaw):
             misc = np.where(np.array(egi_info["chan_type"]) != "eeg")[0].tolist()
 
         logger.info("    Reading events ...")
-        egi_events, egi_info = _read_events(input_fname, egi_info)
+        egi_events, egi_info, mff_events = _read_events(input_fname, egi_info)
         cals = _get_eeg_calibration_info(input_fname, egi_info)
         logger.info("    Assembling measurement info ...")
-        if egi_info["n_events"] > 0:
-            event_codes = list(egi_info["event_codes"])
-            if include is None:
-                exclude_list = ["sync", "TREV"] if exclude is None else exclude
-                exclude_inds = [
-                    i for i, k in enumerate(event_codes) if k in exclude_list
-                ]
-                more_excludes = []
-                if exclude is None:
-                    for ii, event in enumerate(egi_events):
-                        if event.sum() <= 1 and event_codes[ii]:
-                            more_excludes.append(ii)
-                if len(exclude_inds) + len(more_excludes) == len(event_codes):
-                    warn(
-                        "Did not find any event code with more than one event.",
-                        RuntimeWarning,
-                    )
-                else:
-                    exclude_inds.extend(more_excludes)
-
-                exclude_inds.sort()
-                include_ = [
-                    i for i in np.arange(egi_info["n_events"]) if i not in exclude_inds
-                ]
-                include_names = [k for i, k in enumerate(event_codes) if i in include_]
-            else:
-                include_ = [i for i, k in enumerate(event_codes) if k in include]
-                include_names = include
-
-            for kk, v in [("include", include_names), ("exclude", exclude)]:
-                if isinstance(v, list):
-                    for k in v:
-                        if k not in event_codes:
-                            raise ValueError(f"Could not find event named {repr(k)}")
-                elif v is not None:
-                    raise ValueError(f"`{kk}` must be None or of type list")
+        event_codes = egi_info["event_codes"]
+        include = _triage_include_exclude(include, exclude, egi_events, egi_info)
+        if egi_info["n_events"] > 0 and not events_as_annotations:
             logger.info('    Synthesizing trigger channel "STI 014" ...')
-            excl_events = ", ".join(
-                k for i, k in enumerate(event_codes) if i not in include_
-            )
-            logger.info(f"    Excluding events {{{excl_events}}} ...")
-            if all(ch.startswith("D") for ch in include_names):
+            if all(ch.startswith("D") for ch in include):
                 # support the DIN format DIN1, DIN2, ..., DIN9, DI10, DI11, ... DI99,
                 # D100, D101, ..., D255 that we get when sending 0-255 triggers on a
                 # parallel port.
                 events_ids = list()
-                for ch in include_names:
+                for ch in include:
                     while not ch[0].isnumeric():
                         ch = ch[1:]
                     events_ids.append(int(ch))
             else:
-                events_ids = np.arange(len(include_)) + 1
+                events_ids = np.arange(len(include)) + 1
             egi_info["new_trigger"] = _combine_triggers(
-                egi_events[include_], remapping=events_ids
+                egi_events[[c in include for c in event_codes]], remapping=events_ids
             )
             self.event_id = dict(
-                zip([e for e in event_codes if e in include_names], events_ids)
+                zip([e for e in event_codes if e in include], events_ids)
             )
             if egi_info["new_trigger"] is not None:
                 egi_events = np.vstack([egi_events, egi_info["new_trigger"]])
-            assert egi_events.shape[1] == egi_info["last_samps"][-1]
         else:
-            # No events
             self.event_id = None
             egi_info["new_trigger"] = None
-            event_codes = []
+        assert egi_events.shape[1] == egi_info["last_samps"][-1]
 
         meas_dt_utc = egi_info["meas_dt_local"].astimezone(datetime.timezone.utc)
         info = _empty_info(egi_info["sfreq"])
@@ -531,15 +447,16 @@ class RawMff(BaseRaw):
         ch_names, mon = _read_locs(input_fname, egi_info, channel_naming)
         # Second: Stim
         ch_names.extend(list(egi_info["event_codes"]))
+        n_extra = len(event_codes) + len(misc) + len(eog) + len(egi_info["pns_names"])
         if egi_info["new_trigger"] is not None:
             ch_names.append("STI 014")  # channel for combined events
-        cals = np.concatenate(
-            [cals, np.repeat(1, len(event_codes) + 1 + len(misc) + len(eog))]
-        )
+            n_extra += 1
 
         # Third: PNS
         ch_names.extend(egi_info["pns_names"])
-        cals = np.concatenate([cals, np.repeat(1, len(egi_info["pns_names"]))])
+
+        cals = np.concatenate([cals, np.ones(n_extra)])
+        assert len(cals) == len(ch_names), (len(cals), len(ch_names))
 
         # Actually create channels as EEG, then update stim and PNS
         ch_coil = FIFF.FIFFV_COIL_EEG
@@ -608,6 +525,7 @@ class RawMff(BaseRaw):
         last_samps = [egi_info["last_samps"][-1] - 1]
 
         annot = dict(onset=list(), duration=list(), description=list())
+
         if len(idx["pns"]):
             # PNS Data is present and should be read:
             egi_info["pns_filepath"] = op.join(input_fname, egi_info["pns_fname"])
@@ -650,8 +568,17 @@ class RawMff(BaseRaw):
                 annot["duration"].append(gap / egi_info["sfreq"])
                 annot["description"].append("BAD_ACQ_SKIP")
 
+        # create events from annotations
+        if events_as_annotations:
+            for code, samples in mff_events.items():
+                if code not in include:
+                    continue
+                annot["onset"].extend(np.array(samples) / egi_info["sfreq"])
+                annot["duration"].extend([0.0] * len(samples))
+                annot["description"].extend([code] * len(samples))
+
         if len(annot["onset"]):
-            self.set_annotations(Annotations(**annot, orig_time=None))
+            self.set_annotations(Annotations(**annot))
 
     def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
         """Read a chunk of data."""
diff --git a/mne/io/egi/events.py b/mne/io/egi/events.py
index 500848ee7..35f305e0a 100644
--- a/mne/io/egi/events.py
+++ b/mne/io/egi/events.py
@@ -8,7 +8,7 @@ from os.path import basename, join, splitext
 
 import numpy as np
 
-from ...utils import _soft_import, logger
+from ...utils import _soft_import, _validate_type, logger, warn
 
 
 def _read_events(input_fname, info):
@@ -31,7 +31,7 @@ def _read_events(input_fname, info):
             if (i < 0) or (i >= events.shape[1]):
                 continue
             events[n][i] = n + 1
-    return events, info
+    return events, info, mff_events
 
 
 def _read_mff_events(filename, sfreq):
@@ -168,3 +168,39 @@ def _combine_triggers(data, remapping=None):
         if np.any(idx):
             new_trigger[idx] += event_id
     return new_trigger
+
+
+def _triage_include_exclude(include, exclude, egi_events, egi_info):
+    """Triage include and exclude."""
+    _validate_type(exclude, (list, None), "exclude")
+    _validate_type(include, (list, None), "include")
+    event_codes = list(egi_info["event_codes"])
+    for name, lst in dict(exclude=exclude, include=include).items():
+        for ii, item in enumerate(lst or []):
+            what = f"{name}[{ii}]"
+            _validate_type(item, str, what)
+            if item not in event_codes:
+                raise ValueError(
+                    f"Could not find event channel named {what}={repr(item)}"
+                )
+    if include is None:
+        if exclude is None:
+            default_exclude = ["sync", "TREV"]
+            exclude = [code for code in default_exclude if code in event_codes]
+            for code, event in zip(event_codes, egi_events):
+                if event.sum() < 1 and code:
+                    exclude.append(code)
+            if (
+                len(exclude) == len(event_codes)
+                and egi_info["n_events"]
+                and set(exclude) - set(default_exclude)
+            ):
+                warn(
+                    "Did not find any event code with at least one event.",
+                    RuntimeWarning,
+                )
+        include = [k for k in event_codes if k not in exclude]
+    del exclude
+    excl_events = ", ".join(k for k in event_codes if k not in include)
+    logger.info(f"    Excluding events {{{excl_events}}} ...")
+    return include
diff --git a/tutorials/preprocessing/90_eyetracking_data.py b/tutorials/preprocessing/90_eyetracking_data.py
index 6c44fb29f..bad4eeeda 100644
--- a/tutorials/preprocessing/90_eyetracking_data.py
+++ b/tutorials/preprocessing/90_eyetracking_data.py
@@ -37,7 +37,7 @@ et_fpath = data_path() / "eeg-et" / "sub-01_task-plr_eyetrack.asc"
 eeg_fpath = data_path() / "eeg-et" / "sub-01_task-plr_eeg.mff"
 
 raw_et = mne.io.read_raw_eyelink(et_fpath, create_annotations=["blinks"])
-raw_eeg = mne.io.read_raw_egi(eeg_fpath, preload=True, verbose="warning")
+raw_eeg = mne.io.read_raw_egi(eeg_fpath, events_as_annotations=True).load_data()
 raw_eeg.filter(1, 30)
 
 # %%


Test patch:
diff --git a/mne/channels/tests/test_montage.py b/mne/channels/tests/test_montage.py
index 706d830a5..3c756e726 100644
--- a/mne/channels/tests/test_montage.py
+++ b/mne/channels/tests/test_montage.py
@@ -1074,7 +1074,11 @@ def test_egi_dig_montage(tmp_path):
     )
 
     # Test accuracy and embedding within raw object
-    raw_egi = read_raw_egi(egi_raw_fname, channel_naming="EEG %03d")
+    raw_egi = read_raw_egi(
+        egi_raw_fname,
+        channel_naming="EEG %03d",
+        events_as_annotations=True,
+    )
 
     raw_egi.set_montage(dig_montage)
     test_raw_egi = read_raw_fif(egi_fif_fname)
diff --git a/mne/io/egi/tests/test_egi.py b/mne/io/egi/tests/test_egi.py
index 71120d8d6..2f680fd7b 100644
--- a/mne/io/egi/tests/test_egi.py
+++ b/mne/io/egi/tests/test_egi.py
@@ -15,7 +15,7 @@ import pytest
 from numpy.testing import assert_allclose, assert_array_equal
 from scipy import io as sio
 
-from mne import find_events, pick_types
+from mne import events_from_annotations, find_events, pick_types
 from mne._fiff.constants import FIFF
 from mne.datasets.testing import data_path, requires_testing_data
 from mne.io import read_evokeds_mff, read_raw_egi, read_raw_fif
@@ -58,6 +58,9 @@ egi_eprime_pause_skips = [(1344000.0, 1804000.0)]
 egi_pause_w1337_events = None
 egi_pause_w1337_skips = [(21956000.0, 40444000.0), (60936000.0, 89332000.0)]
 
+# TODO: Remove once complete deprecation / FutureWarning about events_as_annonations
+pytestmark = pytest.mark.filterwarnings("ignore:.*events_as_annotation.*:FutureWarning")
+
 
 @requires_testing_data
 @pytest.mark.parametrize(
@@ -142,7 +145,8 @@ def test_egi_mff_pause_chunks(fname, tmp_path):
 
 
 @requires_testing_data
-def test_io_egi_mff():
+@pytest.mark.parametrize("events_as_annotations", (True, False))
+def test_io_egi_mff(events_as_annotations):
     """Test importing EGI MFF simple binary files."""
     pytest.importorskip("defusedxml")
     # want vars for n chans
@@ -150,7 +154,7 @@ def test_io_egi_mff():
     n_eeg = 128
     n_card = 3
 
-    raw = read_raw_egi(egi_mff_fname, include=None)
+    raw = read_raw_egi(egi_mff_fname, events_as_annotations=events_as_annotations)
     assert "RawMff" in repr(raw)
     assert raw.orig_format == "single"
     include = ["DIN1", "DIN2", "DIN3", "DIN4", "DIN5", "DIN7"]
@@ -160,6 +164,7 @@ def test_io_egi_mff():
         include=include,
         channel_naming="EEG %03d",
         test_scaling=False,  # XXX probably some bug
+        events_as_annotations=events_as_annotations,
     )
     assert raw.info["sfreq"] == 1000.0
     assert len(raw.info["dig"]) == n_card + n_eeg + n_ref
@@ -183,21 +188,43 @@ def test_io_egi_mff():
     # test our custom channel naming logic functionality
     eeg_chan = [c for c in raw.ch_names if "EEG" in c]
     assert len(eeg_chan) == n_eeg  # 128: VREF will not match in comprehension
-    assert "STI 014" in raw.ch_names
+    if events_as_annotations:
+        assert "STI 014" not in raw.ch_names
+        assert raw.event_id is None
+        event_id = {"DIN1": 1, "DIN2": 2, "DIN3": 3, "DIN4": 4, "DIN5": 5, "DIN7": 7}
+        events, _ = events_from_annotations(raw, event_id=event_id)
+    else:
+        assert "STI 014" in raw.ch_names
+        events = find_events(raw, stim_channel="STI 014")
+        event_id = raw.event_id
 
-    events = find_events(raw, stim_channel="STI 014")
     assert len(events) == 8
     assert np.unique(events[:, 1])[0] == 0
     assert np.unique(events[:, 0])[0] != 0
     assert np.unique(events[:, 2])[0] != 0
+    assert "DIN1" in event_id
 
     with pytest.raises(ValueError, match="Could not find event"):
         read_raw_egi(egi_mff_fname, include=["Foo"])
     with pytest.raises(ValueError, match="Could not find event"):
         read_raw_egi(egi_mff_fname, exclude=["Bar"])
     for ch in include:
-        assert ch in raw.event_id
-        assert raw.event_id[ch] == int(ch[-1])
+        assert ch in event_id
+        assert event_id[ch] == int(ch[-1])
+    # test converting stim triggers to annotations
+    if events_as_annotations:
+        # Grab the first annotation. Should be the first "DIN1" event.
+        assert len(raw.annotations)
+        onset, dur, desc, _ = raw.annotations[0].values()
+        assert_allclose(onset, 2.438)
+        assert np.isclose(dur, 0)
+        assert desc == "DIN1"
+        # grab the DIN1 channel
+        din1 = raw.get_data(picks="DIN1")
+        # Check that the time in sec of first event is the same as the first annotation
+        pin_hi_idx = np.where(din1 == 1)[1]
+        pin_hi_sec = pin_hi_idx / raw.info["sfreq"]
+        assert np.isclose(pin_hi_sec[0], onset)
 
 
 def test_io_egi():
@@ -209,12 +236,10 @@ def test_io_egi():
     data = data[1:]
     data *= 1e-6  # µV
 
-    with pytest.warns(RuntimeWarning, match="Did not find any event code"):
-        raw = read_raw_egi(egi_fname, include=None)
+    raw = read_raw_egi(egi_fname, events_as_annotations=False)
 
     # The reader should accept a Path, too.
-    with pytest.warns(RuntimeWarning, match="Did not find any event code"):
-        raw = read_raw_egi(Path(egi_fname), include=None)
+    raw_annot = read_raw_egi(Path(egi_fname), events_as_annotations=True)
 
     assert "RawEGI" in repr(raw)
     data_read, t_read = raw[:256]
@@ -251,11 +276,18 @@ def test_io_egi():
     new_trigger = _combine_triggers(triggers, events_ids)
     assert_array_equal(np.unique(new_trigger), np.unique([0, 12, 24]))
 
-    pytest.raises(ValueError, read_raw_egi, egi_fname, include=["Foo"], preload=False)
-    pytest.raises(ValueError, read_raw_egi, egi_fname, exclude=["Bar"], preload=False)
+    with pytest.raises(ValueError, match="Could not find.*include.*"):
+        read_raw_egi(egi_fname, include=["Foo"])
+    with pytest.raises(ValueError, match="Could not find.*exclude.*"):
+        read_raw_egi(egi_fname, exclude=["Bar"])
     for ii, k in enumerate(include, 1):
         assert k in raw.event_id
         assert raw.event_id[k] == ii
+    assert raw_annot.event_id is None
+    events, event_id = events_from_annotations(raw_annot, event_id=raw.event_id)
+    assert event_id == raw.event_id
+    events_2 = find_events(raw)
+    assert_array_equal(events, events_2)
 
 
 @requires_testing_data
@@ -371,7 +403,6 @@ def test_io_egi_crop_no_preload():
     assert_allclose(raw._data, raw_preload._data)
 
 
-@pytest.mark.filterwarnings("ignore::FutureWarning")
 @requires_testing_data
 @pytest.mark.parametrize(
     "idx, cond, tmax, signals, bads",
@@ -453,7 +484,6 @@ def test_io_egi_evokeds_mff(idx, cond, tmax, signals, bads):
     assert evoked_cond.info["device_info"]["type"] == "HydroCel GSN 256 1.0"
 
 
-@pytest.mark.filterwarnings("ignore::FutureWarning")
 @requires_testing_data
 def test_read_evokeds_mff_bad_input():
     """Test errors are thrown when reading invalid input file."""