diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index e66b1855886..8cf4f54c8b5 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(): - # if the value is numeric, it's not a status column + first_val = samples_df[col].iloc[0] + # 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() + if len(non_null) == 0: + # All values are None, drop this column + status_cols.append(col) + continue + first_val = non_null.iloc[0] + 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(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