Skip to content

Commit ea3b9ae

Browse files
Let the stride path handle EDF+ annotation channels
The stride decoder reshaped a whole data record into an (n_channels, max_samp) matrix, which requires every channel to share a sampling rate. An EDF+ annotation channel usually does not: for a 64-channel 256 Hz file with a 60-sample TAL channel a record holds 16444 values while the reshape needs 16640, so 'stride_layout' was None and every EDF+ read fell back to the per-channel loop. Gate on the *selected* channels instead, and when a record is not rectangular gather each picked channel by its own offset. Files whose records are rectangular keep the existing reshape unchanged. Fast-path coverage across the EDF/BDF corpus goes from 6/22 files to 18/22. EDF+ windowed reads 1.76x, EDF+ preload 1.28x, BDF+ windowed reads 1.42x; files without an annotation channel are unaffected. Output stays bit-identical to main: 73/73 array snapshots and 52/52 annotation snapshots across 26 files.
1 parent e9770d6 commit ea3b9ae

2 files changed

Lines changed: 33 additions & 15 deletions

File tree

mne/io/edf/edf.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -649,9 +649,6 @@ def _read_uniform_segment(
649649
subtype = raw_extras["subtype"]
650650
if subtype not in ("edf", "bdf") or not isinstance(filenames, str | Path):
651651
return False
652-
if len(raw_extras.get("tal_idx", ())) != 0:
653-
return False
654-
655652
idx_is_slice = isinstance(idx, slice)
656653
if idx_is_slice and idx.step not in (None, 1):
657654
return False
@@ -666,7 +663,7 @@ def _read_uniform_segment(
666663
return False
667664
n_samps = raw_extras["n_samps"]
668665
buf_len = int(raw_extras["max_samp"])
669-
ch_offsets, n_per, sel_in_physical_order = stride_layout
666+
ch_offsets, n_per, sel_in_physical_order, rectangular = stride_layout
670667

671668
dtype = raw_extras["dtype_np"]
672669
dtype_byte = raw_extras["dtype_byte"]
@@ -735,11 +732,19 @@ def _read_uniform_segment(
735732
many_chunk = _read_ch(
736733
fid, subtype, ch_offsets[-1] * n_read, dtype_byte, dtype
737734
)
738-
record_grid = many_chunk.reshape(n_read, len(n_samps), buf_len)
739-
if in_physical_order:
740-
view = record_grid.transpose(1, 0, 2)
735+
if rectangular:
736+
record_grid = many_chunk.reshape(n_read, len(n_samps), buf_len)
737+
if in_physical_order:
738+
view = record_grid.transpose(1, 0, 2)
739+
else:
740+
view = record_grid[:, read_sel, :].transpose(1, 0, 2)
741741
else:
742-
view = record_grid[:, read_sel, :].transpose(1, 0, 2)
742+
# a record is not a matrix (e.g. an EDF+ annotation channel is
743+
# stored at its own rate), so slice out each picked channel
744+
records = many_chunk.reshape(n_read, -1)
745+
view = np.empty((len(read_sel), n_read, buf_len), many_chunk.dtype)
746+
for j, ci in enumerate(read_sel):
747+
view[j] = records[:, ch_offsets[ci] : ch_offsets[ci + 1]]
743748

744749
r_sidx = r_lims[ai][0]
745750
r_eidx = buf_len * (n_read - 1) + r_lims[ai + n_read - 1][1]
@@ -1115,20 +1120,24 @@ def _get_info(
11151120
edf_info["max_samp"] = max_samp = n_samps.max()
11161121
all_n_samps = edf_info["n_samps"]
11171122
edf_info["stride_layout"] = None
1118-
if (
1119-
edf_info["subtype"] in ("edf", "bdf")
1120-
and len(edf_info.get("tal_idx", ())) == 0
1121-
and np.all(all_n_samps == max_samp)
1123+
# Only the *selected* channels have to share a sampling rate. An EDF+
1124+
# annotation channel usually does not, which is why `rectangular` is checked
1125+
# separately: it says whether a data record is a plain (n_channels, max_samp)
1126+
# matrix that can simply be reshaped, or has to be gathered channel by channel.
1127+
if edf_info["subtype"] in ("edf", "bdf") and np.all(
1128+
all_n_samps[edf_info["sel"]] == max_samp
11221129
):
11231130
ch_offsets = np.cumsum(np.concatenate([[0], all_n_samps]), dtype=np.int64)
11241131
n_per = max(10 * 1024 * 1024 // (ch_offsets[-1] * edf_info["dtype_byte"]), 1)
1125-
sel_in_physical_order = np.array_equal(
1132+
rectangular = bool(np.all(all_n_samps == max_samp))
1133+
sel_in_physical_order = rectangular and np.array_equal(
11261134
edf_info["sel"], np.arange(len(all_n_samps))
11271135
)
11281136
edf_info["stride_layout"] = (
11291137
ch_offsets,
11301138
n_per,
11311139
sel_in_physical_order,
1140+
rectangular,
11321141
)
11331142

11341143
# Info structure

mne/io/edf/tests/test_edf.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,12 +266,11 @@ def test_uniform_stride_bdf(pick_kind, window_kind, monkeypatch, tmp_path):
266266
_assert_uniform_stride_matches(monkeypatch, raw, picks, start, stop)
267267

268268

269-
@pytest.mark.parametrize("fallback_kind", ("mixed_rate", "tal", "projection", "memory"))
269+
@pytest.mark.parametrize("fallback_kind", ("mixed_rate", "projection", "memory"))
270270
def test_uniform_stride_edf_falls_back(fallback_kind, monkeypatch):
271271
"""Test EDF layouts requiring special handling retain legacy behavior."""
272272
path = {
273273
"mixed_rate": edf_uneven_path,
274-
"tal": edf_path,
275274
"projection": edf_stim_channel_path,
276275
"memory": edf_stim_channel_path,
277276
}[fallback_kind]
@@ -293,6 +292,16 @@ def test_uniform_stride_edf_falls_back(fallback_kind, monkeypatch):
293292
assert_array_equal(got, want)
294293

295294

295+
def test_uniform_stride_edf_annotations(monkeypatch):
296+
"""Test an EDF+ annotation channel does not disable the stride path."""
297+
raw = read_raw_edf(edf_path, preload=False, verbose="error")
298+
buffer_length = int(raw._raw_extras[0]["max_samp"])
299+
picks = np.array([0, 2])
300+
_assert_uniform_stride_matches(
301+
monkeypatch, raw, picks, buffer_length // 3, 3 * buffer_length + 7
302+
)
303+
304+
296305
def test_uniform_stride_concurrent(monkeypatch, tmp_path):
297306
"""Test concurrent stride reads do not share seekable file state."""
298307
repeated = tmp_path / "concurrent.edf"

0 commit comments

Comments
 (0)