diff --git a/doc/changes/dev/13555.bugfix.rst b/doc/changes/dev/13555.bugfix.rst new file mode 100644 index 00000000000..0ba15a93d88 --- /dev/null +++ b/doc/changes/dev/13555.bugfix.rst @@ -0,0 +1 @@ +Fix bug where :func:`mne.io.read_raw_eyelink` raised an error when reading Eyelink files with an empty first recording block, by :newcontrib:`Varun Kasyap Pentamaraju` (:gh:`13555`). \ No newline at end of file diff --git a/doc/changes/names.inc b/doc/changes/names.inc index 77e665ec6ed..cc6800a1b49 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -336,6 +336,7 @@ .. _Tziona NessAiver: https://github.com/TzionaN .. _user27182: https://github.com/user27182 .. _Valerii Chirkov: https://github.com/vagechirkov +.. _Varun Kasyap Pentamaraju: https://github.com/varunkasyap .. _Velu Prabhakar Kumaravel: https://github.com/vpKumaravel .. _Victor Ferat: https://github.com/vferat .. _Victoria Peterson: https://github.com/vpeterson diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index e66b1855886..2b9f0f1c769 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -90,7 +90,14 @@ def _parse_eyelink_ascii( raw_extras["dfs"][key], max_time=overlap_threshold ) # ======================== Info for BaseRaw ======================== - eye_ch_data = raw_extras["dfs"]["samples"][ch_names].to_numpy().T + dfs = raw_extras["dfs"] + + if "samples" not in dfs or dfs["samples"].empty: + logger.info("No sample data found, creating empty Raw object.") + eye_ch_data = np.empty((len(ch_names), 0)) + else: + eye_ch_data = dfs["samples"][ch_names].to_numpy().T + info = _create_info(ch_names, raw_extras) return eye_ch_data, info, raw_extras @@ -103,7 +110,7 @@ def _parse_recording_blocks(fname): samples lines start with a posix-like string, and contain eyetracking sample info. Event Lines start with an upper case string and contain info - about occular events (i.e. blink/saccade), or experiment + about ocular events (i.e. blink/saccade), or experiment messages sent by the stimulus presentation software. """ with fname.open() as file: @@ -182,7 +189,7 @@ def _validate_data(data_blocks: list): pupil_units.append(block["info"]["pupil_unit"]) if "GAZE" in units: logger.info( - "Pixel coordinate data detected." + "Pixel coordinate data detected. " "Pass `scalings=dict(eyegaze=1e3)` when using plot" " method to make traces more legible." ) @@ -369,7 +376,7 @@ def _create_dataframes_for_block(block, apply_offsets): df_dict["samples"] = pd.DataFrame(block["samples"]) df_dict["samples"] = _drop_status_col(df_dict["samples"]) # drop STATUS col - # dataframe for each type of occular event in this block + # dataframe for each type of ocular event in this block for event, label in zip( ["EFIX", "ESACC", "EBLINK"], ["fixations", "saccades", "blinks"] ): @@ -697,7 +704,7 @@ def _adjust_times( ----- After _parse_recording_blocks, Files with multiple recording blocks will have missing timestamps for the duration of the period between the blocks. - This would cause the occular annotations (i.e. blinks) to not line up with + This would cause the ocular annotations (i.e. blinks) to not line up with the signal. """ pd = _check_pandas_installed() @@ -723,7 +730,7 @@ def _find_overlaps(df, max_time=0.05): Parameters ---------- df : pandas.DataFrame - Pandas DataFrame with occular events (fixations, saccades, blinks) + Pandas DataFrame with ocular events (fixations, saccades, blinks) max_time : float (default 0.05) Time in seconds. Defaults to .05 (50 ms) diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index 192a5555465..af610e5f43b 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -69,7 +69,7 @@ def read_raw_eyelink( @fill_doc class RawEyelink(BaseRaw): - """Raw object from an XXX file. + """Raw object from an Eyelink file. Parameters ---------- @@ -123,7 +123,9 @@ def __init__( eye_annots = _make_eyelink_annots( self._raw_extras[0]["dfs"], create_annotations, apply_offsets ) - if gap_annots and eye_annots: # set both + if self.n_times == 0: + logger.info("No samples found in recording, skipping annotation creation.") + elif gap_annots and eye_annots: # set both self.set_annotations(gap_annots + eye_annots) elif gap_annots: self.set_annotations(gap_annots) diff --git a/mne/io/eyelink/tests/test_eyelink.py b/mne/io/eyelink/tests/test_eyelink.py index 596c4468b7a..911a52708a2 100644 --- a/mne/io/eyelink/tests/test_eyelink.py +++ b/mne/io/eyelink/tests/test_eyelink.py @@ -523,3 +523,24 @@ def test_href_eye_events(tmp_path): # Just check that we actually parsed the Saccade and Fixation events assert "saccade" in raw.annotations.description assert "fixation" in raw.annotations.description + + +@requires_testing_data +def test_empty_first_trial(tmp_path): + """Test reading a file with an empty first trial.""" + out_file = tmp_path / "tmp_eyelink.asc" + # Use a real eyelink file as base + lines = fname.read_text("utf-8").splitlines() + # Find first START and END + end_idx = next(i for i, line in enumerate(lines) if line.startswith("END")) + # Keep headers + START..END but REMOVE all numeric sample lines + first_block = [] + for line in lines[: end_idx + 1]: + tokens = line.split() + if line.startswith("START") or not tokens or not tokens[0].isdigit(): + first_block.append(line) + + # Append rest of file (second trial onwards) + rest = lines[end_idx + 1 :] + out_file.write_text("\n".join(first_block + rest), encoding="utf-8") + read_raw_eyelink(out_file)