From 51ffe87d79a72e60d928da7ad5052bc6f4d27dae Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Mon, 29 Dec 2025 01:18:28 +0800 Subject: [PATCH 1/2] FIX: Handle None values in _drop_status_col for EyeLink reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix TypeError when read_raw_eyelink() processes .asc files with multiple recording segments where a segment starts with null data. The error occurred because _drop_status_col assumed all column values were strings, but pandas fills missing columns with None when sample lines have varying column counts. The fix checks for None values and looks for the first non-None value in the column to determine if it's a status column. If all values are None, the column is dropped. Closes #13567 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 Signed-off-by: majiayu000 <1835304752@qq.com> --- mne/io/eyelink/_utils.py | 15 ++++- mne/io/eyelink/tests/test_eyelink.py | 86 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index e66b1855886..a2281f0c9af 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -559,10 +559,21 @@ 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(): + first_val = samples_df[col].iloc[0] + # Handle None values - can occur in files with multiple recording segments + # where a segment starts with null data. See gh-13567. + if first_val is None: + # Check if there's any non-None value to determine column type + non_null = samples_df[col].dropna() + if len(non_null) == 0: + # All values are None, drop this column + status_cols.append(col) + continue + first_val = non_null.iloc[0] + if first_val[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]: + if len(first_val) in [3, 5, 13, 17]: status_cols.append(col) return samples_df.drop(columns=status_cols) diff --git a/mne/io/eyelink/tests/test_eyelink.py b/mne/io/eyelink/tests/test_eyelink.py index 596c4468b7a..0487bd9c9b0 100644 --- a/mne/io/eyelink/tests/test_eyelink.py +++ b/mne/io/eyelink/tests/test_eyelink.py @@ -523,3 +523,89 @@ 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_null_sample_values(tmp_path): + """Test file with multiple recording segments starting with null data. + + Regression test for gh-13567: the _drop_status_col function failed when + the first sample of a recording segment had None values in a column. + This happens when sample lines have different numbers of columns, causing + pandas to fill missing values with None. + """ + out_file = tmp_path / "tmp_eyelink.asc" + lines = fname.read_text("utf-8").splitlines() + + # Find the END line of the first block and insert a second block after it + end_idx = None + for li, line in enumerate(lines): + if line.startswith("END"): + end_idx = li + break + assert end_idx is not None, "Could not find END line in test file" + + # Create a second block with samples having varying column counts + # This simulates the condition from gh-13567 where pandas fills + # missing columns with None + second_block = [] + new_ts = 888993 + start = ["START", f"{new_ts}", "LEFT", "SAMPLES", "EVENTS"] + samples_info = [ + "SAMPLES", + "GAZE", + "LEFT", + "RATE", + "500.00", + "TRACKING", + "CR", + "FILTER", + "2", + ] + events_info = [ + "EVENTS", + "GAZE", + "LEFT", + "RATE", + "500.00", + "TRACKING", + "CR", + "FILTER", + "2", + ] + pupil_info = ["PUPIL", "DIAMETER"] + second_block.append("\t".join(start)) + second_block.append("\t".join(pupil_info)) + second_block.append("\t".join(samples_info)) + second_block.append("\t".join(events_info)) + + # First few samples have FEWER columns (no status column) + # This creates None values in the status column position after DataFrame creation + # columns: timestamp, xpos, ypos, pupil (missing status -> becomes None) + for ii in range(12): + ts = new_ts + ii + tokens = [f"{ts}", ".", ".", "0.0"] # Only 4 columns + second_block.append("\t".join(tokens)) + + # Rest of samples have status column (5 columns) + for ii in range(12, 100): + ts = new_ts + ii + tokens = [f"{ts}", "960.0", "540.0", "1000.0", "..."] # 5 columns + second_block.append("\t".join(tokens)) + + end_ts = new_ts + 100 + end_block = ["END", f"{end_ts}", "SAMPLES", "EVENTS", "RES", "45", "45"] + second_block.append("\t".join(end_block)) + + # Insert the second block after the first END line + lines = lines[: end_idx + 1] + second_block + lines[end_idx + 1 :] + out_file.write_text("\n".join(lines), encoding="utf-8") + + # This should not raise TypeError from _drop_status_col + with pytest.warns( + RuntimeWarning, match="This recording switched between monocular and binocular" + ): + raw = read_raw_eyelink(out_file, create_annotations=False) + + assert raw is not None + assert len(raw.ch_names) > 0 From ebabeee07e37e7484e12c6b93a16a57ed546c71a Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Mon, 29 Dec 2025 21:34:12 +0800 Subject: [PATCH 2/2] Fix PR review comments: correct comment and add type check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update comment to accurately describe the issue as "blocks that start with fewer columns than after" instead of "multiple recording segments" - Add isinstance check before calling isnumeric() to handle float values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- mne/io/eyelink/_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index a2281f0c9af..8cf4f54c8b5 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -560,8 +560,8 @@ def _drop_status_col(samples_df): # we know the first 3 columns will be the time, xpos, ypos for col in samples_df.columns[3:]: first_val = samples_df[col].iloc[0] - # Handle None values - can occur in files with multiple recording segments - # where a segment starts with null data. See gh-13567. + # Handle None values - can occur in files with blocks that start with + # fewer columns than after. See gh-13567. if first_val is None: # Check if there's any non-None value to determine column type non_null = samples_df[col].dropna() @@ -570,8 +570,8 @@ def _drop_status_col(samples_df): status_cols.append(col) continue first_val = non_null.iloc[0] - if first_val[0].isnumeric(): - # if the value is numeric, it's not a status column + if not isinstance(first_val, str) or first_val[0].isnumeric(): + # if the value is not a string or is numeric, it's not a status column continue if len(first_val) in [3, 5, 13, 17]: status_cols.append(col)