Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/13571.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix bug where :func:`mne.io.read_raw_eyelink` fails to handle data starting with empty blocks or blocks starting with missing values, by :newcontrib:`Varun Kasyap Pentamaraju` and `Qian Chu`_.
1 change: 1 addition & 0 deletions doc/changes/names.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 29 additions & 11 deletions mne/io/eyelink/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -182,14 +189,14 @@ 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."
)
if "HREF" in units:
logger.info("Head-referenced eye-angle (HREF) data detected.")
elif "PUPIL" in units:
warn("Raw eyegaze coordinates detected. Analyze with caution.")
warn("Raw pupil position data detected. Analyze with caution.")
if "AREA" in pupil_units:
logger.info("Pupil-size area detected.")
elif "DIAMETER" in pupil_units:
Expand Down Expand Up @@ -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"]
):
Expand Down Expand Up @@ -559,11 +566,22 @@ def _drop_status_col(samples_df):
status_cols = []
# we know the first 3 columns will be the time, xpos, ypos
for col in samples_df.columns[3:]:
if samples_df[col][0][0].isnumeric():
# if the value is numeric, it's not a status column
continue
if len(samples_df[col][0]) in [3, 5, 13, 17]:
# use first valid index and value to ignore leading empty values
# see https://github.com/mne-tools/mne-python/issues/13567
first_valid_index = samples_df[col].first_valid_index()
if first_valid_index is None:
# The entire column is NaN, so we can drop it
status_cols.append(col)
continue
first_value = samples_df.loc[first_valid_index, col]
try:
float(first_value)
continue # if the value is numeric, it's not a status column
except (ValueError, TypeError):
# cannot convert to float, so it might be a status column
# further check the length of the string value
if len(first_value) in [3, 5, 13, 17]:
status_cols.append(col)
return samples_df.drop(columns=status_cols)


Expand Down Expand Up @@ -697,7 +715,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()
Expand All @@ -723,7 +741,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)

Expand Down
2 changes: 1 addition & 1 deletion mne/io/eyelink/eyelink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
15 changes: 11 additions & 4 deletions mne/io/eyelink/tests/test_eyelink.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ def test_fill_times(fname):


def test_find_overlaps():
"""Test finding overlapping occular events between the left and right eyes.
"""Test finding overlapping ocular events between the left and right eyes.

In the simulated blink df below, the first two rows
will be considered an overlap because the diff() of both the 'time' and
Expand Down Expand Up @@ -360,7 +360,14 @@ def _simulate_eye_tracking_data(in_file, out_file):
fp.write("START\t7452389\tRIGHT\tSAMPLES\tEVENTS\n")
fp.write(f"{new_samples_line}\n")

for timestamp in np.arange(7452389, 7453390): # simulate a second block
# simulate a second block that starts with empty samples
for timestamp in np.arange(7452389, 7453154):
fp.write(
f"{timestamp}\t.\t.\t0.0\t.\t.\t.\t.\t0.0\t"
"...\t.\t.\t.\n" # no last column
)

for timestamp in np.arange(7453154, 7453390): # second block continues
fp.write(
f"{timestamp}\t-2434.0\t-1760.0\t840.0\t100\t20\t45\t45\t127.0\t"
"...\t1497\t5189\t512.5\t.............\n"
Expand Down Expand Up @@ -388,7 +395,7 @@ def _simulate_eye_tracking_data(in_file, out_file):
@requires_testing_data
@pytest.mark.parametrize("fname", [fname_href])
def test_multi_block_misc_channels(fname, tmp_path):
"""Test a file with many edge casses.
"""Test a file with many edge cases.

This file has multiple acquisition blocks, each tracking a different eye.
The coordinates are in raw units (not pixels or radians).
Expand All @@ -399,7 +406,7 @@ def test_multi_block_misc_channels(fname, tmp_path):

with (
_record_warnings(),
pytest.warns(RuntimeWarning, match="Raw eyegaze coordinates"),
pytest.warns(RuntimeWarning, match="Raw pupil position data detected"),
pytest.warns(RuntimeWarning, match="The eye being tracked changed"),
):
raw = read_raw_eyelink(out_file, apply_offsets=True)
Expand Down
4 changes: 2 additions & 2 deletions mne/utils/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1592,10 +1592,10 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):

docdict["eyelink_create_annotations"] = """
create_annotations : bool | list (default True)
Whether to create :class:`~mne.Annotations` from occular events
Whether to create :class:`~mne.Annotations` from ocular events
(blinks, fixations, saccades) and experiment messages. If a list, must
contain one or more of ``['fixations', 'saccades',' blinks', messages']``.
If True, creates :class:`~mne.Annotations` for both occular events and
If True, creates :class:`~mne.Annotations` for both ocular events and
experiment messages.
"""

Expand Down