Repository: mne-tools/mne-python

Issue:
Improve read_raw_eyelink
### Describe the new feature or enhancement

Per discussion with @larsoner (see [this comment](https://github.com/mne-tools/mne-python/pull/11746#issuecomment-1602813373)), while I am focused on the eye-tracking project this summer, it may be a good idea to take another pass at `mne.io.read_raw_eyelink`.


### Describe your proposed implementation

a review of `read_raw_eyelink` can be focused on:

1) increasing code coverage
2) identifying code that can be refactored to be more idiomatic to mne-python
3) identifying bottlenecks where performance can be improved 

### Describe possible alternatives

Open to suggestions.

### Additional context

I will try to pick away at this during the down time between other tasks for my GSoC project.


Discussion:

Resolving patch:
diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py
--- a/mne/io/eyelink/_utils.py
+++ b/mne/io/eyelink/_utils.py
@@ -5,65 +5,7 @@
 import re
 import numpy as np
 
-from ...utils import _check_pandas_installed, _validate_type
-
-
-def _isfloat(token):
-    """Boolean test for whether string can be of type float.
-
-    Parameters
-    ----------
-    token : str
-        Single element from tokens list.
-    """
-    _validate_type(token, str, "token")
-    try:
-        float(token)
-    except ValueError:
-        return False
-    else:
-        return True
-
-
-def _convert_types(tokens):
-    """Convert the type of each token in list.
-
-    The tokens input is a list of string elements.
-    Posix timestamp strings can be integers, eye gaze position and
-    pupil size can be floats. flags token ("...") remains as string.
-    Missing eye/head-target data (indicated by '.' or 'MISSING_DATA')
-    are replaced by np.nan.
-
-    Parameters
-    ----------
-    Tokens : list
-        List of string elements.
-
-    Returns
-    -------
-        Tokens list with elements of various types.
-    """
-    return [
-        int(token)
-        if token.isdigit()  # execute this before _isfloat()
-        else float(token)
-        if _isfloat(token)
-        else np.nan
-        if token in (".", "MISSING_DATA")
-        else token  # remains as string
-        for token in tokens
-    ]
-
-
-def _parse_line(line):
-    """Parse tab delminited string from eyelink ASCII file.
-
-    Takes a tab deliminited string from eyelink file,
-    splits it into a list of tokens, and converts the type
-    for each token in the list.
-    """
-    tokens = line.split()
-    return _convert_types(tokens)
+from ...utils import _check_pandas_installed
 
 
 def _is_sys_msg(line):
@@ -95,7 +37,7 @@ def _is_sys_msg(line):
     return "!V" in line or "!MODE" in line or ";" in line
 
 
-def _get_sfreq(rec_info):
+def _get_sfreq_from_ascii(rec_info):
     """Get sampling frequency from Eyelink ASCII file.
 
     Parameters
@@ -106,12 +48,13 @@ def _get_sfreq(rec_info):
 
     Returns
     -------
-    sfreq : int | float
+    sfreq : float
     """
-    return rec_info[rec_info.index("RATE") + 1]
+    return float(rec_info[rec_info.index("RATE") + 1])
 
 
 def _sort_by_time(df, col="time"):
+    assert col in df.columns
     df.sort_values(col, ascending=True, inplace=True)
     df.reset_index(drop=True, inplace=True)
 
@@ -146,7 +89,7 @@ def _convert_times(df, first_samp, col="time"):
             df[col] /= 1000
 
 
-def _fill_times(
+def _adjust_times(
     df,
     sfreq,
     time_col="time",
@@ -213,7 +156,8 @@ def _find_overlaps(df, max_time=0.05):
     """
     pd = _check_pandas_installed()
 
-    df = df.copy()
+    if not len(df):
+        return
     df["overlap_start"] = df.sort_values("time")["time"].diff().lt(max_time)
 
     df["overlap_end"] = df["end_time"].diff().abs().lt(max_time)
@@ -240,7 +184,7 @@ def _find_overlaps(df, max_time=0.05):
     return ovrlp.drop(columns=tmp_cols).reset_index(drop=True)
 
 
-# Used by read_eyelinke_calibration
+# Used by read_eyelink_calibration
 
 
 def _find_recording_start(lines):
diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py
--- a/mne/io/eyelink/eyelink.py
+++ b/mne/io/eyelink/eyelink.py
@@ -12,17 +12,23 @@
 import numpy as np
 from ._utils import (
     _convert_times,
-    _fill_times,
+    _adjust_times,
     _find_overlaps,
-    _get_sfreq,
+    _get_sfreq_from_ascii,
     _is_sys_msg,
-    _parse_line,
 )  # helper functions
 from ..constants import FIFF
 from ..base import BaseRaw
 from ..meas_info import create_info
 from ...annotations import Annotations
-from ...utils import _check_fname, _check_pandas_installed, fill_doc, logger, verbose
+from ...utils import (
+    _check_fname,
+    _check_pandas_installed,
+    fill_doc,
+    logger,
+    verbose,
+    warn,
+)
 
 EYELINK_COLS = {
     "timestamp": ("time",),
@@ -36,9 +42,7 @@
     },
     "resolution": ("xres", "yres"),
     "input": ("DIN",),
-    "flags": ("flags",),
     "remote": ("x_head", "y_head", "distance"),
-    "remote_flags": ("head_flags",),
     "block_num": ("block",),
     "eye_event": ("eye", "time", "end_time", "duration"),
     "fixation": ("fix_avg_x", "fix_avg_y", "fix_avg_pupil_size"),
@@ -183,9 +187,6 @@ class RawEyelink(BaseRaw):
     ----------
     fname : pathlib.Path
         Eyelink filename
-    dataframes : dict
-        Dictionary of pandas DataFrames. One for eyetracking samples,
-        and one for each type of eyelink event (blinks, messages, etc)
 
     See Also
     --------
@@ -209,14 +210,14 @@ def __init__(
         self.fname = Path(fname)
         self._sample_lines = None  # sample lines from file
         self._event_lines = None  # event messages from file
-        self._system_lines = None  # unparsed lines of system messages from file
         self._tracking_mode = None  # assigned in self._infer_col_names
         self._meas_date = None
         self._rec_info = None
+        self._ascii_sfreq = None
         if gap_desc is None:
             gap_desc = "BAD_ACQ_SKIP"
         else:
-            logger.warn(
+            warn(
                 "gap_description is deprecated in 1.5 and will be removed in 1.6, "
                 "use raw.annotations.rename to use a description other than "
                 "'BAD_ACQ_SKIP'",
@@ -225,28 +226,54 @@ def __init__(
         self._gap_desc = gap_desc
         self.dataframes = {}
 
+        # ======================== Parse ASCII File =========================
         self._get_recording_datetime()  # sets self._meas_date
         self._parse_recording_blocks()  # sets sample, event, & system lines
 
-        sfreq = _get_sfreq(self._event_lines["SAMPLES"][0])
+        # ======================== Create DataFrames ========================
+        self._create_dataframes()
+        del self._sample_lines  # free up memory
+        # add column names to dataframes
         col_names, ch_names = self._infer_col_names()
-        self._create_dataframes(
-            col_names, sfreq, find_overlaps=find_overlaps, threshold=overlap_threshold
-        )
-        info = self._create_info(ch_names, sfreq)
+        self._assign_col_names(col_names)
+        self._set_df_dtypes()  # set dtypes for each dataframe
+        if "HREF" in self._rec_info:
+            self._convert_href_samples()
+        # fill in times between recording blocks with BAD_ACQ_SKIP
+        n_blocks = len(self._event_lines["START"])
+        if n_blocks > 1:
+            logger.info(
+                f"There are {n_blocks} recording blocks in this file. Times between"
+                f"  blocks will be annotated with {self._gap_desc}."
+            )
+            self.dataframes["samples"] = _adjust_times(
+                self.dataframes["samples"], self._ascii_sfreq
+            )
+        # Convert timestamps to seconds
+        for df in self.dataframes.values():
+            first_samp = float(self._event_lines["START"][0][0])
+            _convert_times(df, first_samp)
+        # Find overlaps between left and right eye events
+        if find_overlaps:
+            for key in self.dataframes:
+                if key not in ["blinks", "fixations", "saccades"]:
+                    continue
+                self.dataframes[key] = _find_overlaps(
+                    self.dataframes[key], max_time=overlap_threshold
+                )
+
+        # ======================== Create Raw Object =========================
+        info = self._create_info(ch_names, self._ascii_sfreq)
         eye_ch_data = self.dataframes["samples"][ch_names]
         eye_ch_data = eye_ch_data.to_numpy().T
-
-        # create mne object
         super(RawEyelink, self).__init__(
             info, preload=eye_ch_data, filenames=[self.fname], verbose=verbose
         )
-        # set meas_date
         self.set_meas_date(self._meas_date)
 
-        # Make Annotations
+        # ======================== Make Annotations =========================
         gap_annots = None
-        if len(self.dataframes["recording_blocks"]) > 1:
+        if len(self._event_lines["START"]) > 1:
             gap_annots = self._make_gap_annots()
         eye_annots = None
         if create_annotations:
@@ -262,6 +289,10 @@ def __init__(
         else:
             logger.info("Not creating any annotations")
 
+        # Free up memory
+        del self.dataframes
+        del self._event_lines
+
     def _parse_recording_blocks(self):
         """Parse Eyelink ASCII file.
 
@@ -273,7 +304,6 @@ def _parse_recording_blocks(self):
         messages sent by the stimulus presentation software.
         """
         with self.fname.open() as file:
-            block_num = 1
             self._sample_lines = []
             self._event_lines = {
                 "START": [],
@@ -295,25 +325,16 @@ def _parse_recording_blocks(self):
                 if line.startswith("START"):  # start of recording block
                     is_recording_block = True
                 if is_recording_block:
-                    if _is_sys_msg(line):
-                        self._system_lines.append(line)
-                        continue  # system messages don't need to be parsed.
-                    tokens = _parse_line(line)
-                    tokens.append(block_num)  # add current block number
-                    if isinstance(tokens[0], (int, float)):  # Samples
+                    tokens = line.split()
+                    if tokens[0][0].isnumeric():  # Samples
                         self._sample_lines.append(tokens)
                     elif tokens[0] in self._event_lines.keys():
+                        if _is_sys_msg(line):
+                            continue  # system messages don't need to be parsed.
                         event_key, event_info = tokens[0], tokens[1:]
                         self._event_lines[event_key].append(event_info)
-                    if tokens[0] == "END":  # end of recording block
-                        is_recording_block = False
-                        block_num += 1
-            if not self._event_lines["START"]:
-                raise ValueError(
-                    "Could not determine the start of the"
-                    " recording. When converting to ASCII, START"
-                    " events should not be suppressed."
-                )
+                        if tokens[0] == "END":  # end of recording block
+                            is_recording_block = False
             if not self._sample_lines:  # no samples parsed
                 raise ValueError(f"Couldn't find any samples in {self.fname}")
             self._validate_data()
@@ -321,63 +342,37 @@ def _parse_recording_blocks(self):
     def _validate_data(self):
         """Check the incoming data for some known problems that can occur."""
         self._rec_info = self._event_lines["SAMPLES"][0]
-        pupil_info = self._event_lines["PUPIL"][0]
-        n_blocks = len(self._event_lines["START"])
-        sfreq = int(_get_sfreq(self._rec_info))
-        first_samp = self._event_lines["START"][0][0]
+        self._pupil_info = self._event_lines["PUPIL"][0]
+        self._n_blocks = len(self._event_lines["START"])
+        self._ascii_sfreq = _get_sfreq_from_ascii(self._event_lines["SAMPLES"][0])
         if ("LEFT" in self._rec_info) and ("RIGHT" in self._rec_info):
             self._tracking_mode = "binocular"
         else:
             self._tracking_mode = "monocular"
         # Detect the datatypes that are in file.
         if "GAZE" in self._rec_info:
-            logger.info("Pixel coordinate data detected.")
-            logger.warning(
+            logger.info(
+                "Pixel coordinate data detected."
                 "Pass `scalings=dict(eyegaze=1e3)` when using plot"
                 " method to make traces more legible."
             )
+
         elif "HREF" in self._rec_info:
-            logger.info("Head-referenced eye angle data detected.")
+            logger.info("Head-referenced eye-angle (HREF) data detected.")
         elif "PUPIL" in self._rec_info:
-            logger.warning("Raw eyegaze coordinates detected. Analyze with" " caution.")
-        if "AREA" in pupil_info:
-            logger.info("Pupil-size area reported.")
-        elif "DIAMETER" in pupil_info:
-            logger.info("Pupil-size diameter reported.")
-        # Check sampling frequency.
-        if sfreq == 2000 and isinstance(first_samp, int):
-            raise ValueError(
-                f"The sampling rate is {sfreq}Hz but the"
-                " timestamps were not output as float values."
-                " Check the settings in the EDF2ASC application."
-            )
-        elif sfreq != 2000 and isinstance(first_samp, float):
-            raise ValueError(
-                "For recordings with a sampling rate less than"
-                " 2000Hz, timestamps should not be output to the"
-                " ASCII file as float values. Check the"
-                " settings in the EDF2ASC application. Got a"
-                f" sampling rate of {sfreq}Hz."
-            )
-        # If more than 1 recording period, make sure sfreq didn't change.
-        if n_blocks > 1:
-            err_msg = (
-                "The sampling frequency changed during the recording."
-                " This file cannot be read into MNE."
-            )
-            for block_info in self._event_lines["SAMPLES"][1:]:
-                block_sfreq = int(_get_sfreq(block_info))
-                if block_sfreq != sfreq:
-                    raise ValueError(
-                        err_msg + f" Got both {sfreq} and {block_sfreq} Hz."
-                    )
+            warn("Raw eyegaze coordinates detected. Analyze with caution.")
+        if "AREA" in self._pupil_info:
+            logger.info("Pupil-size area detected.")
+        elif "DIAMETER" in self._pupil_info:
+            logger.info("Pupil-size diameter detected.")
+        # If more than 1 recording period, check whether eye being tracked changed.
+        if self._n_blocks > 1:
             if self._tracking_mode == "monocular":
-                assert self._rec_info[1] in ["LEFT", "RIGHT"]
                 eye = self._rec_info[1]
                 blocks_list = self._event_lines["SAMPLES"]
                 eye_per_block = [block_info[1] for block_info in blocks_list]
                 if not all([this_eye == eye for this_eye in eye_per_block]):
-                    logger.warning(
+                    warn(
                         "The eye being tracked changed during the"
                         " recording. The channel names will reflect"
                         " the eye that was tracked at the start of"
@@ -404,6 +399,17 @@ def _get_recording_datetime(self):
                         dt_naive = datetime.strptime(dt_str, fmt)
                         dt_aware = dt_naive.replace(tzinfo=tz)
                         self._meas_date = dt_aware
+                        break
+
+    def _convert_href_samples(self):
+        """Convert HREF eyegaze samples to radians."""
+        # grab the xpos and ypos channel names
+        pos_names = EYELINK_COLS["pos"]["left"][:-1] + EYELINK_COLS["pos"]["right"][:-1]
+        for col in self.dataframes["samples"].columns:
+            if col not in pos_names:  # 'xpos_left' ... 'ypos_right'
+                continue
+            series = self._href_to_radian(self.dataframes["samples"][col])
+            self.dataframes["samples"][col] = series
 
     def _href_to_radian(self, opposite, f=15_000):
         """Convert HREF eyegaze samples to radians.
@@ -413,7 +419,8 @@ def _href_to_radian(self, opposite, f=15_000):
         opposite : int
             The x or y coordinate in an HREF gaze sample.
         f : int (default 15_000)
-            distance of plane from the eye.
+            distance of plane from the eye. Defaults to 15,000 units, which was taken
+            from the Eyelink 1000 plus user manual.
 
         Returns
         -------
@@ -430,21 +437,21 @@ def _infer_col_names(self):
         """Build column and channel names for data from Eyelink ASCII file.
 
         Returns the expected column names for the sample lines and event
-        lines, to be passed into pd.DataFrame. Sample and event lines in
-        eyelink files have a fixed order of columns, but the columns that
-        are present can vary. The order that col_names is built below should
-        NOT change.
+        lines, to be passed into pd.DataFrame. The columns present in an eyelink ASCII
+        file can vary. The order that col_names are built below should NOT change.
         """
         col_names = {}
         # initiate the column names for the sample lines
-        col_names["sample"] = list(EYELINK_COLS["timestamp"])
+        col_names["samples"] = list(EYELINK_COLS["timestamp"])
 
         # and for the eye message lines
-        col_names["blink"] = list(EYELINK_COLS["eye_event"])
-        col_names["fixation"] = list(
+        col_names["blinks"] = list(EYELINK_COLS["eye_event"])
+        col_names["fixations"] = list(
             EYELINK_COLS["eye_event"] + EYELINK_COLS["fixation"]
         )
-        col_names["saccade"] = list(EYELINK_COLS["eye_event"] + EYELINK_COLS["saccade"])
+        col_names["saccades"] = list(
+            EYELINK_COLS["eye_event"] + EYELINK_COLS["saccade"]
+        )
 
         # Recording was either binocular or monocular
         # If monocular, find out which eye was tracked and append to ch_name
@@ -454,50 +461,39 @@ def _infer_col_names(self):
             ch_names = list(EYELINK_COLS["pos"][eye])
         elif self._tracking_mode == "binocular":
             ch_names = list(EYELINK_COLS["pos"]["left"] + EYELINK_COLS["pos"]["right"])
-        col_names["sample"].extend(ch_names)
+        col_names["samples"].extend(ch_names)
 
         # The order of these if statements should not be changed.
         if "VEL" in self._rec_info:  # If velocity data are reported
             if self._tracking_mode == "monocular":
                 ch_names.extend(EYELINK_COLS["velocity"][eye])
-                col_names["sample"].extend(EYELINK_COLS["velocity"][eye])
+                col_names["samples"].extend(EYELINK_COLS["velocity"][eye])
             elif self._tracking_mode == "binocular":
                 ch_names.extend(
                     EYELINK_COLS["velocity"]["left"] + EYELINK_COLS["velocity"]["right"]
                 )
-                col_names["sample"].extend(
+                col_names["samples"].extend(
                     EYELINK_COLS["velocity"]["left"] + EYELINK_COLS["velocity"]["right"]
                 )
         # if resolution data are reported
         if "RES" in self._rec_info:
             ch_names.extend(EYELINK_COLS["resolution"])
-            col_names["sample"].extend(EYELINK_COLS["resolution"])
-            col_names["fixation"].extend(EYELINK_COLS["resolution"])
-            col_names["saccade"].extend(EYELINK_COLS["resolution"])
+            col_names["samples"].extend(EYELINK_COLS["resolution"])
+            col_names["fixations"].extend(EYELINK_COLS["resolution"])
+            col_names["saccades"].extend(EYELINK_COLS["resolution"])
         # if digital input port values are reported
         if "INPUT" in self._rec_info:
             ch_names.extend(EYELINK_COLS["input"])
-            col_names["sample"].extend(EYELINK_COLS["input"])
-
-        # add flags column
-        col_names["sample"].extend(EYELINK_COLS["flags"])
+            col_names["samples"].extend(EYELINK_COLS["input"])
 
-        # if head target info was reported, add its cols after flags col.
+        # if head target info was reported, add its cols
         if "HTARGET" in self._rec_info:
             ch_names.extend(EYELINK_COLS["remote"])
-            col_names["sample"].extend(
-                EYELINK_COLS["remote"] + EYELINK_COLS["remote_flags"]
-            )
-
-        # finally add a column for recording block number
-        # FYI this column does not exist in the asc file..
-        # but it is added during _parse_recording_blocks
-        for col in col_names.values():
-            col.extend(EYELINK_COLS["block_num"])
+            col_names["samples"].extend(EYELINK_COLS["remote"])
 
         return col_names, ch_names
 
-    def _create_dataframes(self, col_names, sfreq, find_overlaps=False, threshold=0.05):
+    def _create_dataframes(self):
         """Create pandas.DataFrame for Eyelink samples and events.
 
         Creates a pandas DataFrame for self._sample_lines and for each
@@ -505,59 +501,16 @@ def _create_dataframes(self, col_names, sfreq, find_overlaps=False, threshold=0.
         """
         pd = _check_pandas_installed()
 
-        # First sample should be the first line of the first recording block
-        first_samp = self._event_lines["START"][0][0]
-
         # dataframe for samples
-        self.dataframes["samples"] = pd.DataFrame(
-            self._sample_lines, columns=col_names["sample"]
-        )
-        if "HREF" in self._rec_info:
-            pos_names = (
-                EYELINK_COLS["pos"]["left"][:-1] + EYELINK_COLS["pos"]["right"][:-1]
-            )
-            for col in self.dataframes["samples"].columns:
-                if col not in pos_names:  # 'xpos_left' ... 'ypos_right'
-                    continue
-                series = self._href_to_radian(self.dataframes["samples"][col])
-                self.dataframes["samples"][col] = series
-
-        n_block = len(self._event_lines["START"])
-        if n_block > 1:
-            logger.info(
-                f"There are {n_block} recording blocks in this"
-                " file. Times between blocks will be annotated with"
-                f" {self._gap_desc}."
-            )
-            # if there is more than 1 recording block we must account for
-            # the missing timestamps and samples bt the blocks
-            self.dataframes["samples"] = _fill_times(
-                self.dataframes["samples"], sfreq=sfreq
-            )
-        _convert_times(self.dataframes["samples"], first_samp)
+        self.dataframes["samples"] = pd.DataFrame(self._sample_lines)
+        self._drop_status_col()  # Remove STATUS column
 
         # dataframe for each type of occular event
-        for event, columns, label in zip(
-            ["EFIX", "ESACC", "EBLINK"],
-            [col_names["fixation"], col_names["saccade"], col_names["blink"]],
-            ["fixations", "saccades", "blinks"],
+        for event, label in zip(
+            ["EFIX", "ESACC", "EBLINK"], ["fixations", "saccades", "blinks"]
         ):
             if self._event_lines[event]:  # an empty list returns False
-                self.dataframes[label] = pd.DataFrame(
-                    self._event_lines[event], columns=columns
-                )
-                _convert_times(self.dataframes[label], first_samp)
-
-                if find_overlaps is True:
-                    if self._tracking_mode == "monocular":
-                        raise ValueError(
-                            "find_overlaps is only valid with"
-                            " binocular recordings, this file is"
-                            f" {self._tracking_mode}"
-                        )
-                    df = _find_overlaps(self.dataframes[label], max_time=threshold)
-                    self.dataframes[label] = df
-
+                self.dataframes[label] = pd.DataFrame(self._event_lines[event])
             else:
                 logger.info(
                     f"No {label} were found in this file. "
@@ -569,42 +522,89 @@ def _create_dataframes(self, col_names, sfreq, find_overlaps=False, threshold=0.
             msgs = []
             for tokens in self._event_lines["MSG"]:
                 timestamp = tokens[0]
-                block = tokens[-1]
-                # if offset token exists, it will be the 1st index
-                # and is an int or float
-                if isinstance(tokens[1], (int, float)):
-                    offset = tokens[1]
-                    msg = " ".join(str(x) for x in tokens[2:-1])
+                # if offset token exists, it will be the 1st index and is numeric
+                if tokens[1].lstrip("-").replace(".", "", 1).isnumeric():
+                    offset = float(tokens[1])
+                    msg = " ".join(str(x) for x in tokens[2:])
                 else:
                     # there is no offset token
                     offset = np.nan
-                    msg = " ".join(str(x) for x in tokens[1:-1])
-                msgs.append([timestamp, offset, msg, block])
-
-            cols = ["time", "offset", "event_msg", "block"]
-            self.dataframes["messages"] = pd.DataFrame(msgs, columns=cols)
-            _convert_times(self.dataframes["messages"], first_samp)
+                    msg = " ".join(str(x) for x in tokens[1:])
+                msgs.append([timestamp, offset, msg])
+            self.dataframes["messages"] = pd.DataFrame(msgs)
 
         # make dataframe for recording block start, end times
-        assert len(self._event_lines["START"]) == len(self._event_lines["END"])
-        blocks = [
-            [bgn[0], end[0], bgn[-1]]  # start, end, block_num
-            for bgn, end in zip(self._event_lines["START"], self._event_lines["END"])
-        ]
+        i = 1
+        blocks = list()
+        for bgn, end in zip(self._event_lines["START"], self._event_lines["END"]):
+            blocks.append((float(bgn[0]), float(end[0]), i))
+            i += 1
         cols = ["time", "end_time", "block"]
         self.dataframes["recording_blocks"] = pd.DataFrame(blocks, columns=cols)
-        _convert_times(self.dataframes["recording_blocks"], first_samp)
 
         # make dataframe for digital input port
         if self._event_lines["INPUT"]:
-            cols = ["time", "DIN", "block"]
-            self.dataframes["DINS"] = pd.DataFrame(
-                self._event_lines["INPUT"], columns=cols
-            )
-            _convert_times(self.dataframes["DINS"], first_samp)
+            cols = ["time", "DIN"]
+            self.dataframes["DINS"] = pd.DataFrame(self._event_lines["INPUT"])
 
         # TODO: Make dataframes for other eyelink events (Buttons)
 
+    def _drop_status_col(self):
+        """Drop STATUS column from samples dataframe.
+
+        see https://github.com/mne-tools/mne-python/issues/11809, and section 4.9.2.1 of
+        the Eyelink 1000 Plus User Manual, version 1.0.19. We know that the STATUS
+        column is either 3, 5, 13, or 17 characters long, i.e. "...", ".....", ".C."
+        """
+        status_cols = []
+        # we know the first 3 columns will be the time, xpos, ypos
+        for col in self.dataframes["samples"].columns[3:]:
+            if self.dataframes["samples"][col][0][0].isnumeric():
+                # if the value is numeric, it's not a status column
+                continue
+            if len(self.dataframes["samples"][col][0]) in [3, 5, 13, 17]:
+                status_cols.append(col)
+        self.dataframes["samples"].drop(columns=status_cols, inplace=True)
+
+    def _assign_col_names(self, col_names):
+        """Assign column names to dataframes.
+
+        Parameters
+        ----------
+        col_names : dict
+            Dictionary of column names for each dataframe.
+        """
+        for key, df in self.dataframes.items():
+            if key in ("samples", "blinks", "fixations", "saccades"):
+                df.columns = col_names[key]
+            elif key == "messages":
+                cols = ["time", "offset", "event_msg"]
+                df.columns = cols
+            elif key == "DINS":
+                cols = ["time", "DIN"]
+                df.columns = cols
+
+    def _set_df_dtypes(self):
+        from ...utils import _set_pandas_dtype
+
+        for key, df in self.dataframes.items():
+            if key in ["samples", "DINS"]:
+                # convert missing position values to NaN
+                self._set_missing_values(df)
+                _set_pandas_dtype(df, df.columns, float, verbose="warning")
+            elif key in ["blinks", "fixations", "saccades"]:
+                _set_pandas_dtype(df, df.columns[1:], float, verbose="warning")
+            elif key == "messages":
+                _set_pandas_dtype(df, ["time"], float, verbose="warning")  # timestamp
+
+    def _set_missing_values(self, df):
+        """Set missing values to NaN. operates in-place."""
+        missing_vals = (".", "MISSING_DATA")
+        for col in df.columns:
+            if col.startswith(("xpos", "ypos")):
+                # we explicitly use numpy instead of pd.replace because it is faster
+                df[col] = np.where(df[col].isin(missing_vals), np.nan, df[col])
+
     def _create_info(self, ch_names, sfreq):
         """Create info object for RawEyelink."""
         # assign channel type from ch_name
@@ -723,8 +723,6 @@ def _make_eyelink_annots(self, df_dict, create_annots, apply_offsets):
             elif annots:
                 annots += this_annot
         if not annots:
-            logger.warning(
-                f"Annotations for {descs} were requested but" " none could be made."
-            )
+            warn(f"Annotations for {descs} were requested but none could be made.")
             return
         return annots


Test patch:
diff --git a/mne/io/eyelink/tests/test_eyelink.py b/mne/io/eyelink/tests/test_eyelink.py
--- a/mne/io/eyelink/tests/test_eyelink.py
+++ b/mne/io/eyelink/tests/test_eyelink.py
@@ -1,3 +1,5 @@
+from pathlib import Path
+
 import pytest
 
 import numpy as np
@@ -9,6 +11,7 @@
 from mne.io.pick import _DATA_CH_TYPES_SPLIT
 from mne.utils import _check_pandas_installed, requires_pandas
 
+
 MAPPING = {
     "left": ["xpos_left", "ypos_left", "pupil_left"],
     "right": ["xpos_right", "ypos_right", "pupil_right"],
@@ -123,7 +126,8 @@ def test_eyelink(fname, create_annotations, find_overlaps, apply_offsets):
 @pytest.mark.parametrize("fname_href", [(fname_href)])
 def test_radian(fname_href):
     """Test converting HREF position data to radians."""
-    raw = read_raw_eyelink(fname_href, create_annotations=["blinks"])
+    with pytest.warns(RuntimeWarning, match="Annotations for"):
+        raw = read_raw_eyelink(fname_href, create_annotations=["blinks"])
     # Test channel types
     assert raw.get_channel_types() == ["eyegaze", "eyegaze", "pupil"]
 
@@ -149,17 +153,17 @@ def test_fill_times(fname):
     with np.arange don't result in the time columns not merging
     correctly - i.e. 1560687.0 and 1560687.000001 should merge.
     """
-    from ..eyelink import _fill_times
+    from ..eyelink import _adjust_times
 
     raw = read_raw_eyelink(fname, create_annotations=False)
     sfreq = raw.info["sfreq"]
     # just take first 1000 points for testing
-    df = raw.dataframes["samples"].iloc[:1000].reset_index(drop=True)
+    df = raw.to_data_frame()[:1000]
     # even during blinks, pupil val is 0, so there should be no nans
     # in this column
     assert not df["pupil_left"].isna().sum()
     nan_count = df["pupil_left"].isna().sum()  # i.e 0
-    df_merged = _fill_times(df, sfreq)
+    df_merged = _adjust_times(df, sfreq)
     # If times dont merge correctly, there will be additional rows in
     # in df_merged with all nan values
     assert df_merged["pupil_left"].isna().sum() == nan_count  # i.e. 0
@@ -192,6 +196,98 @@ def test_find_overlaps():
     assert overlap_df["eye"].iloc[0] == "both"
 
 
+def _simulate_eye_tracking_data(in_file, out_file):
+    out_file = Path(out_file)
+
+    new_samples_line = (
+        "SAMPLES\tPUPIL\tLEFT\tVEL\tRES\tHTARGET\tRATE\t1000.00"
+        "\tTRACKING\tCR\tFILTER\t2\tINPUT"
+    )
+    with out_file.open("w") as fp:
+        in_recording_block = False
+        events = []
+
+        for line in Path(in_file).read_text().splitlines():
+            if line.startswith("START"):
+                in_recording_block = True
+            if in_recording_block:
+                tokens = line.split()
+                event_type = tokens[0]
+                if event_type.isnumeric():  # samples
+                    tokens[4:4] = ["100", "20", "45", "45", "127.0"]  # vel, res, DIN
+                    tokens.extend(["1497.0", "5189.0", "512.5", "............."])
+                elif event_type in ("EFIX", "ESACC"):
+                    tokens.extend(["45", "45"])  # resolution
+                elif event_type == "SAMPLES":
+                    tokens[1] = "PUPIL"  # simulate raw coordinate data
+                    tokens[3:3] = ["VEL", "RES", "HTARGET"]
+                    tokens.append("INPUT")
+                elif event_type == "EBLINK":
+                    continue  # simulate no blink events
+                elif event_type == "END":
+                    pass
+                else:
+                    fp.write("%s\n" % line)
+                    continue
+                events.append("\t".join(tokens))
+                if event_type == "END":
+                    fp.write("\n".join(events) + "\n")
+                    events.clear()
+                    in_recording_block = False
+            else:
+                fp.write("%s\n" % line)
+
+        fp.write("%s\n" % "START\t7452389\tRIGHT\tSAMPLES\tEVENTS")
+        fp.write("%s\n" % new_samples_line)
+
+        for timestamp in np.arange(7452389, 7453390):  # simulate a second block
+            fp.write(
+                "%s\n"
+                % (
+                    f"{timestamp}\t-2434.0\t-1760.0\t840.0\t100\t20\t45\t45\t127.0\t"
+                    "...\t1497\t5189\t512.5\t............."
+                )
+            )
+
+        fp.write("%s\n" % "END\t7453390\tRIGHT\tSAMPLES\tEVENTS")
+
+
+@requires_testing_data
+@requires_pandas
+@pytest.mark.parametrize("fname", [fname_href])
+def test_multi_block_misc_channels(fname, tmp_path):
+    """Test an eyelink file with multiple blocks and additional misc channels."""
+    out_file = tmp_path / "tmp_eyelink.asc"
+    _simulate_eye_tracking_data(fname, out_file)
+
+    with pytest.warns(RuntimeWarning, match="Raw eyegaze coordinates"):
+        raw = read_raw_eyelink(out_file)
+
+    chs_in_file = [
+        "xpos_right",
+        "ypos_right",
+        "pupil_right",
+        "xvel_right",
+        "yvel_right",
+        "xres",
+        "yres",
+        "DIN",
+        "x_head",
+        "y_head",
+        "distance",
+    ]
+
+    assert raw.ch_names == chs_in_file
+    assert raw.annotations.description[1] == "SYNCTIME"
+    assert raw.annotations.description[-1] == "BAD_ACQ_SKIP"
+    assert np.isclose(raw.annotations.onset[-1], 1.001)
+    assert np.isclose(raw.annotations.duration[-1], 0.1)
+
+    data, times = raw.get_data(return_times=True)
+    assert not np.isnan(data[0, np.where(times < 1)[0]]).any()
+    assert np.isnan(data[0, np.logical_and(times > 1, times <= 1.1)]).all()
+
+
 @pytest.mark.xfail(reason="Attributes and test_preloading fail")
 @requires_testing_data
 @pytest.mark.parametrize("this_fname", (fname, fname_href))