Skip to content

Commit 5979f12

Browse files
committed
ENH: plot variable-duration epochs without padding
The epochs browser already draws its trials as a pseudo-continuous strip, concatenating them and ruling a line at each boundary, so ragged epochs need the samples they actually hold rather than a padded copy. plot() leaves the not-implemented table and becomes native. The x axis is built from those samples: lengths = per-epoch sample counts boundary_samples = np.r_[0, np.cumsum(lengths)] boundary_times = boundary_samples / sfreq n_times = boundary_samples[-1] _n_times_per_epoch returns len(times) when durations are equal, so this is one code path and reproduces the previous uniform grid exactly while never reading `times`, which variable-duration epochs refuse to provide. A window of k epochs from index i spans boundary_times[i + k] minus boundary_times[i]; _epoch_window computes that for both backends, and _get_start_stop and _load_data share _get_epoch_ix_range so the sample bounds and the concatenated array agree by construction. That is what makes the existing shape assertions meaningful for ragged windows. Arrow keys step whole epochs and shift steps whole windows, home and end ask the boundaries how many seconds an epoch is worth, and the scrollbar draws each epoch at its own width. Vertical lines mark a latency relative to each epoch's own event and are omitted from epochs too short to reach it, replacing arithmetic that took the remainder against one duration. Events map through each epoch's own window; the fixed path keeps its existing bounds, whose upper limit overshoots the last sample by |tmin|, rather than have that copied. _compute_scalings failed first of all, before any of the above, since it reshaped _data as an array. ICA sources reach the same browser without supplying the new per-epoch arrays, so those are derived from the boundaries when absent. Non-matplotlib backends decline with a message naming matplotlib until mne-qt-browser can consume boundary_times, boundary_samples and n_times, which the params dict now carries; the browser tests skip there for the same reason.
1 parent 34809b9 commit 5979f12

7 files changed

Lines changed: 442 additions & 59 deletions

File tree

mne/epochs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4066,7 +4066,6 @@ def _events_from_annotations(raw, events, event_id, annotations, on_missing):
40664066
#: rather than a slow one, so they raise until implemented natively.
40674067
_VARIABLE_NOT_IMPLEMENTED = {
40684068
"filter": "filtering",
4069-
"plot": "browsing",
40704069
"apply_function": "applying a function",
40714070
"apply_baseline": "baseline correction",
40724071
"crop": "cropping",

mne/tests/test_epochs_variable_duration.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,13 @@ def test_display_methods_warn_and_fall_back(variable, meth):
259259
assert getattr(variable, meth)() is not None
260260

261261

262+
def test_plot_is_not_a_fallback():
263+
"""Test that browsing is native, not padded (see mne/viz/tests/test_epochs)."""
264+
assert "plot" not in _VARIABLE_FALLBACK
265+
assert "plot" not in _VARIABLE_NEEDS_POLICY
266+
assert "plot" not in _VARIABLE_NOT_IMPLEMENTED
267+
268+
262269
# -- operations that stay native -------------------------------------------
263270
def test_pick_keeps_durations(variable):
264271
"""Test that channel selection leaves the time axis alone."""

mne/viz/_figure.py

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,37 @@ def __init__(self, **kwargs):
4444
vars(self).update(**kwargs)
4545

4646

47+
def _epoch_window(boundary_times, start_ix, n_epochs):
48+
"""Return the start time and duration of a window of whole epochs.
49+
50+
Epochs may differ in duration, so a window of ``n_epochs`` of them spans
51+
whatever lies between the two boundaries rather than a fixed number of
52+
seconds. ``start_ix`` is clamped so the requested epochs stay visible when
53+
the object is long enough to allow it.
54+
55+
Parameters
56+
----------
57+
boundary_times : array
58+
Cumulative epoch edges in seconds, including both ends.
59+
start_ix : int
60+
Index of the first epoch to show.
61+
n_epochs : int
62+
Number of epochs to show.
63+
64+
Returns
65+
-------
66+
t_start : float
67+
Time of the first boundary.
68+
duration : float
69+
Seconds spanned by the requested epochs.
70+
"""
71+
n_total = len(boundary_times) - 1
72+
n_epochs = int(np.clip(n_epochs, 1, n_total))
73+
start_ix = int(np.clip(start_ix, 0, n_total - n_epochs))
74+
stop_ix = start_ix + n_epochs
75+
return boundary_times[start_ix], boundary_times[stop_ix] - boundary_times[start_ix]
76+
77+
4778
class BrowserBase(ABC):
4879
"""A base class containing for the 2D browser.
4980
@@ -78,7 +109,13 @@ def __init__(self, **kwargs):
78109
f"Expected an instance of Raw, Epochs, or ICA, got {type(inst)}."
79110
)
80111

81-
if len(inst.times) < 2:
112+
# variable-duration epochs have no one time axis, so count the samples
113+
# the browser will lay end to end instead
114+
if self.mne.instance_type == "epochs":
115+
n_inst_times = self.mne.n_times
116+
else:
117+
n_inst_times = len(inst.times)
118+
if n_inst_times < 2:
82119
raise ValueError(
83120
"Data from at least two time points are required to open the browser."
84121
)
@@ -120,7 +157,12 @@ def __init__(self, **kwargs):
120157
self.mne.epoch_traces = list()
121158
self.mne.bad_epochs = list()
122159
if inst is not None:
123-
self.mne.sampling_period = np.diff(inst.times[:2])[0] / inst.info["sfreq"]
160+
# NB: this is 1 / sfreq**2, not a sampling period; it is only ever
161+
# used as a small nudge before searchsorted on boundary_times, so
162+
# keep the value while deriving it without touching inst.times
163+
# (which variable-duration epochs refuse to provide).
164+
sfreq = inst.info["sfreq"]
165+
self.mne.sampling_period = (1.0 / sfreq) / sfreq
124166
# annotations
125167
self.mne.annotations = list()
126168
self.mne.hscroll_annotations = list()
@@ -157,6 +199,23 @@ def __init__(self, **kwargs):
157199
self.mne.midpoints = (
158200
np.convolve(self.mne.boundary_times, np.ones(2), mode="valid") / 2
159201
)
202+
# callers that only ever deal with equal-length epochs (ICA sources)
203+
# do not supply these, so derive them from the boundaries
204+
sfreq = self.mne.info["sfreq"]
205+
if not hasattr(self.mne, "boundary_samples"):
206+
self.mne.boundary_samples = np.round(
207+
np.asarray(self.mne.boundary_times) * sfreq
208+
).astype(int)
209+
if not hasattr(self.mne, "epoch_tmins"):
210+
n_epochs_total = len(self.mne.boundary_times) - 1
211+
self.mne.epoch_tmins = np.full(
212+
n_epochs_total, float(self.mne.inst.times[0])
213+
)
214+
self.mne.epoch_tmaxs = (
215+
self.mne.epoch_tmins
216+
+ np.diff(self.mne.boundary_times)
217+
- 1.0 / sfreq
218+
)
160219

161220
# initialize picks and projectors
162221
self._update_picks()
@@ -328,14 +387,36 @@ def _make_butterfly_selections_dict(self):
328387
# MANAGE DATA
329388
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
330389

390+
def _get_epoch_ix_range(self):
391+
"""Return the first and last+1 epoch index currently in view.
392+
393+
Both :meth:`_get_start_stop` and :meth:`_load_data` go through here so
394+
the sample bounds and the concatenated data cannot disagree, which is
395+
what keeps the shape assertions in :meth:`_update_data` meaningful when
396+
epochs differ in duration.
397+
"""
398+
# subtract one sample from tstart before searchsorted, to make sure
399+
# we land on the left side of the boundary time (avoid precision
400+
# errors)
401+
ix_start = int(
402+
np.searchsorted(
403+
self.mne.boundary_times, self.mne.t_start - self.mne.sampling_period
404+
)
405+
)
406+
n_total = len(self.mne.boundary_times) - 1
407+
ix_start = min(ix_start, max(n_total - 1, 0))
408+
ix_stop = min(ix_start + self.mne.n_epochs, n_total)
409+
return ix_start, ix_stop
410+
331411
def _get_start_stop(self):
332412
# update time
333413
start_sec = self.mne.t_start - self.mne.first_time
334414
if self.mne.is_epochs:
335-
start, stop = np.round(
336-
np.array([start_sec, start_sec + self.mne.duration])
337-
* self.mne.info["sfreq"]
338-
).astype(int)
415+
# take the samples the visible epochs really hold, so that this
416+
# agrees with _load_data by construction rather than by arithmetic
417+
ix_start, ix_stop = self._get_epoch_ix_range()
418+
start = int(self.mne.boundary_samples[ix_start])
419+
stop = int(self.mne.boundary_samples[ix_stop])
339420
else:
340421
# ensure our end time includes the last sample
341422
disp_duration = (
@@ -355,13 +436,7 @@ def _load_data(self, start=None, stop=None):
355436
else:
356437
return self.mne.inst[:, start:stop]
357438
else:
358-
# subtract one sample from tstart before searchsorted, to make sure
359-
# we land on the left side of the boundary time (avoid precision
360-
# errors)
361-
ix_start = np.searchsorted(
362-
self.mne.boundary_times, self.mne.t_start - self.mne.sampling_period
363-
)
364-
ix_stop = ix_start + self.mne.n_epochs
439+
ix_start, ix_stop = self._get_epoch_ix_range()
365440
item = slice(ix_start, ix_stop)
366441
data = np.concatenate(
367442
self.mne.inst.get_data(item=item, copy=False), axis=-1

mne/viz/_mpl_figure.py

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
from ..defaults import DEFAULTS
5959
from ..fixes import _close_event
6060
from ..utils import Bunch, _click_ch_name, logger
61-
from ._figure import BrowserBase
61+
from ._figure import BrowserBase, _epoch_window
6262
from .utils import (
6363
_BLIT_KWARGS,
6464
DraggableLine,
@@ -489,7 +489,7 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs):
489489
epoch_nums = self.mne.inst.selection
490490
for ix, _ in enumerate(epoch_nums):
491491
start = self.mne.boundary_times[ix]
492-
width = np.diff(self.mne.boundary_times[:2])[0]
492+
width = self.mne.boundary_times[ix + 1] - start
493493
ax_hscroll.add_patch(
494494
Rectangle(
495495
(start, 0),
@@ -791,12 +791,20 @@ def _keypress(self, event):
791791
old_t_start = self.mne.t_start
792792
direction = 1 if key.endswith("right") else -1
793793
if self.mne.is_epochs:
794-
denom = 1 if key.startswith("shift") else self.mne.n_epochs
794+
# step whole epochs, since they need not share a duration: one
795+
# epoch normally, a whole window with shift
796+
step = self.mne.n_epochs if key.startswith("shift") else 1
797+
ix_start, _ = self._get_epoch_ix_range()
798+
self.mne.t_start, self.mne.duration = _epoch_window(
799+
self.mne.boundary_times,
800+
ix_start + direction * step,
801+
self.mne.n_epochs,
802+
)
795803
else:
796804
denom = 1 if key.startswith("shift") else 4
797-
t_max = last_time - self.mne.duration
798-
t_start = self.mne.t_start + direction * self.mne.duration / denom
799-
self.mne.t_start = np.clip(t_start, self.mne.first_time, t_max)
805+
t_max = last_time - self.mne.duration
806+
t_start = self.mne.t_start + direction * self.mne.duration / denom
807+
self.mne.t_start = np.clip(t_start, self.mne.first_time, t_max)
800808
if self.mne.t_start != old_t_start:
801809
self._update_hscroll()
802810
self._redraw(annotations=True, skip_hscroll=True)
@@ -828,13 +836,17 @@ def _keypress(self, event):
828836
old_dur = self.mne.duration
829837
dur_delta = 1 if key == "end" else -1
830838
if self.mne.is_epochs:
839+
ix_start, _ = self._get_epoch_ix_range()
831840
# prevent from showing zero epochs, or more epochs than we have
832-
self.mne.n_epochs = np.clip(
833-
self.mne.n_epochs + dur_delta, 1, len(self.mne.inst)
841+
self.mne.n_epochs = int(
842+
np.clip(self.mne.n_epochs + dur_delta, 1, len(self.mne.inst))
843+
)
844+
# the epochs added or removed have their own durations, so ask
845+
# the boundaries how many seconds that actually is
846+
self.mne.t_start, new_dur = _epoch_window(
847+
self.mne.boundary_times, ix_start, self.mne.n_epochs
834848
)
835-
# use the length of one epoch as duration change
836-
min_dur = len(self.mne.inst.times) / self.mne.info["sfreq"]
837-
new_dur = self.mne.duration + dur_delta * min_dur
849+
min_dur = np.diff(self.mne.boundary_times).min()
838850
else:
839851
# never show fewer than 3 samples
840852
min_dur = 3 * np.diff(self.mne.inst.times[:2])[0]
@@ -843,8 +855,10 @@ def _keypress(self, event):
843855
new_dur = self.mne.duration * dur_delta
844856
self.mne.duration = np.clip(new_dur, min_dur, last_time)
845857
if self.mne.duration != old_dur:
846-
if self.mne.t_start + self.mne.duration > last_time:
847-
self.mne.t_start = last_time - self.mne.duration
858+
if not self.mne.is_epochs:
859+
if self.mne.t_start + self.mne.duration > last_time:
860+
self.mne.t_start = last_time - self.mne.duration
861+
# (the epochs branch above already clamped t_start to a boundary)
848862
self._update_hscroll()
849863
self._redraw(annotations=True, skip_hscroll=True)
850864
elif key == "?": # help window
@@ -998,7 +1012,10 @@ def _mouse_move(self, event):
9981012
time = np.clip(time, self.mne.first_time, max_time)
9991013
if self.mne.is_epochs:
10001014
ix = np.searchsorted(self.mne.boundary_times[1:], time, side="right")
1001-
time = self.mne.boundary_times[ix]
1015+
# the epochs from here on have their own durations
1016+
time, self.mne.duration = _epoch_window(
1017+
self.mne.boundary_times, ix, self.mne.n_epochs
1018+
)
10021019
if self.mne.t_start != time:
10031020
self.mne.t_start = time
10041021
self._update_hscroll()
@@ -1917,7 +1934,10 @@ def _check_update_hscroll_clicked(self, event):
19171934
time = np.clip(time, self.mne.first_time, max_time)
19181935
if self.mne.is_epochs:
19191936
ix = np.searchsorted(self.mne.boundary_times[1:], time, side="right")
1920-
time = self.mne.boundary_times[ix]
1937+
# the epochs from here on have their own durations
1938+
time, self.mne.duration = _epoch_window(
1939+
self.mne.boundary_times, ix, self.mne.n_epochs
1940+
)
19211941
if self.mne.t_start != time:
19221942
self.mne.t_start = time
19231943
self._update_hscroll()
@@ -2252,11 +2272,9 @@ def _draw_traces(self):
22522272
# handle custom epoch colors (for autoreject integration)
22532273
if self.mne.epoch_colors is None:
22542274
# shape: n_traces × RGBA → n_traces × n_epochs × RGBA
2255-
custom_colors = np.tile(
2256-
ch_colors[:, None, :], (1, self.mne.n_epochs, 1)
2257-
)
2275+
custom_colors = np.tile(ch_colors[:, None, :], (1, len(epoch_ix), 1))
22582276
else:
2259-
custom_colors = np.empty((len(self.mne.picks), self.mne.n_epochs, 4))
2277+
custom_colors = np.empty((len(self.mne.picks), len(epoch_ix), 4))
22602278
for ii, _epoch_ix in enumerate(epoch_ix):
22612279
this_colors = self.mne.epoch_colors[_epoch_ix]
22622280
custom_colors[:, ii] = to_rgba_array(
@@ -2408,26 +2426,43 @@ def _recompute_epochs_vlines(self, xdata):
24082426
# special case: changed view duration w/ "home" or "end" key
24092427
# (no click event, hence no xdata)
24102428
if xdata is None:
2411-
xdata = np.array(self.mne.vline.get_segments())[0, 0, 0]
2412-
# compute the (continuous) times for the lines on each epoch
2413-
epoch_dur = np.diff(self.mne.boundary_times[:2])[0]
2414-
rel_time = xdata % epoch_dur
2415-
abs_time = self.mne.times[0]
2416-
xs = np.arange(self.mne.n_epochs) * epoch_dur + abs_time + rel_time
2417-
segs = np.array(self.mne.vline.get_segments())
2429+
segments = self.mne.vline.get_segments()
2430+
if not len(segments): # no visible epoch reaches that latency
2431+
return None
2432+
xdata = np.array(segments)[0, 0, 0]
2433+
# Work out which latency relative to its own event was clicked, then
2434+
# mark that same latency on every visible epoch. Epochs need not share a
2435+
# duration, so an epoch that never reaches this latency gets no line.
2436+
sfreq = self.mne.info["sfreq"]
2437+
boundary_times = self.mne.boundary_times
2438+
clicked_ix = int(
2439+
np.clip(
2440+
np.searchsorted(boundary_times[1:], xdata, side="right"),
2441+
0,
2442+
len(boundary_times) - 2,
2443+
)
2444+
)
2445+
offset = round((xdata - boundary_times[clicked_ix]) * sfreq)
2446+
latency = self.mne.epoch_tmins[clicked_ix] + offset / sfreq
2447+
ix_start, ix_stop = self._get_epoch_ix_range()
2448+
xs = list()
2449+
for ix in range(ix_start, ix_stop):
2450+
tmin, tmax = self.mne.epoch_tmins[ix], self.mne.epoch_tmaxs[ix]
2451+
if tmin - 0.5 / sfreq <= latency <= tmax + 0.5 / sfreq:
2452+
xs.append(boundary_times[ix] + (latency - tmin))
2453+
xs = np.array(xs, float)
24182454
# recreate segs from scratch in case view duration changed
24192455
# (i.e., handle case when n_segments != n_epochs)
24202456
segs = np.tile([[0.0], [1.0]], (len(xs), 1, 2)) # y values
2421-
segs[..., 0] = np.tile(xs[:, None], 2) # x values
2457+
segs[..., 0] = np.tile(xs[:, None], 2) if len(xs) else segs[..., 0]
24222458
self.mne.vline.set_segments(segs)
2423-
return rel_time
2459+
return latency
24242460

24252461
def _show_vline(self, xdata):
24262462
"""Show the vertical line(s)."""
24272463
if self.mne.is_epochs:
2428-
# convert xdata to be epoch-relative (for the text)
2429-
rel_time = self._recompute_epochs_vlines(xdata)
2430-
xdata = rel_time + self.mne.inst.times[0]
2464+
# the label shows the latency relative to each epoch's own event
2465+
xdata = self._recompute_epochs_vlines(xdata)
24312466
else:
24322467
self.mne.vline.set_xdata([xdata])
24332468
self.mne.vline_hscroll.set_xdata([xdata])

0 commit comments

Comments
 (0)