Skip to content

Commit 90aee6d

Browse files
committed
FIX: browser defects found by a corner-case sweep
Route variable-duration browsing on a backend capability flag rather than the backend name, so mne-qt-browser can opt in. Duck-typed the way BrowserBase._has_time_slice already is, so an older mne-qt-browser still declines. Six tests whose skip reason claimed matplotlib-only now run on both backends. Three of these fixes are regressions on the *equal-duration* path, not ragged-only: - _recompute_epochs_vlines computed an unclamped sample offset, so a click in an epoch's last half sample rounded one sample past its end, a latency no epoch holds. Every line was dropped while the readout still showed the out-of-range value. The Qt backend kept its old path behind a guard; the matplotlib rewrite had none, so fixed-duration data went through the new code. - _draw_traces rebuilt the visible-epoch list by searchsorting the time range, which drops the last epoch when it holds one sample, and raises when that is the only visible epoch. Ask the view instead. - The colour band mask excluded each epoch's own first sample, so a one-sample epoch was drawn in its neighbour's colour and the window's first sample was never painted at all. Ragged-only: - _create_epoch_histogram called np.ptp(..., axis=2) on a list of arrays. Peak-to-peak is per trial, so compute it per epoch. - _getitem moved the per-epoch bounds but never re-derived the union time axis, so as_fixed() kept padding out to epochs that had been dropped: epochs[0] of a 100-sample epoch returned (1, 3, 280) with 540 NaN and n_contributing == 0 at 180 time points. crop() already re-derives it. - drop_bad(reject=...) reached Epochs.times and raised an internal error with no classification; it now declines clearly. The no-arg call still short-circuits, which _concatenate_epochs relies on. Also silence two ty diagnostics in _crop_variable that predate this branch. Verified against the pre-PR commit across 7,697 recorded states and 377 figures per environment: all 32 fields that determine what the reader sees are bit-identical, and the only movement is the vline landing on a real sample instead of between two.
1 parent 0cffb89 commit 90aee6d

6 files changed

Lines changed: 78 additions & 31 deletions

File tree

mne/epochs.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,6 +1978,19 @@ def drop_bad(
19781978
flat = self.flat
19791979
if any(isinstance(rej, str) and rej != "existing" for rej in (reject, flat)):
19801980
raise ValueError('reject and flat, if strings, must be "existing"')
1981+
if self._variable_duration and (reject or flat):
1982+
# the no-arg call has already short-circuited above, so reaching
1983+
# here means real thresholds; amplitude rejection would run per
1984+
# epoch, but it goes through `times` on the way and there is no
1985+
# ragged path for it yet
1986+
raise NotImplementedError(
1987+
"drop_bad() with reject or flat is not implemented for "
1988+
"variable-duration epochs. Amplitude rejection is per-trial "
1989+
"and could work here, but the preloaded path needs one shared "
1990+
"time axis, which these epochs do not have. Pass reject= to "
1991+
"Epochs() at construction instead, which does apply it per "
1992+
"epoch. See https://github.com/mne-tools/mne-python/issues/14206."
1993+
)
19811994
self._reject_setup(reject, flat, allow_callable=True)
19821995
self._get_data(out=False, verbose=verbose)
19831996
return self
@@ -2855,8 +2868,8 @@ def _crop_variable(self, tmin, tmax, include_tmax):
28552868
self._tmax_per_epoch = None # ty: ignore[invalid-assignment]
28562869
start_idx, stop_idx = first_idx[0], first_idx[0] + lengths[0] - 1
28572870
else:
2858-
start_idx = int(round(starts.min() * sfreq))
2859-
stop_idx = int(round(stops.max() * sfreq))
2871+
start_idx = int(round(float(np.min(starts)) * sfreq))
2872+
stop_idx = int(round(float(np.max(stops)) * sfreq))
28602873
self._raw_times = np.arange(start_idx, stop_idx + 1) / sfreq
28612874
self._set_times(self._raw_times)
28622875

mne/utils/mixin.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,15 @@ def _getitem(
277277
# an ndarray takes a slice or an index array equally well
278278
inst._tmin_per_epoch = inst._tmin_per_epoch[select]
279279
inst._tmax_per_epoch = inst._tmax_per_epoch[select]
280+
# the union window is defined by those bounds, so it has to be
281+
# re-derived here as `crop` does; otherwise `as_fixed` keeps
282+
# padding out to epochs that are no longer present
283+
if len(inst._tmin_per_epoch):
284+
sfreq = float(inst.info["sfreq"])
285+
start_idx = int(round(inst._tmin_per_epoch.min() * sfreq))
286+
stop_idx = int(round(inst._tmax_per_epoch.max() * sfreq))
287+
inst._raw_times = np.arange(start_idx, stop_idx + 1) / sfreq
288+
inst._set_times(inst._raw_times)
280289
if drop_event_id:
281290
# update event id to reflect new content of inst
282291
inst.event_id = {

mne/viz/_figure.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,11 @@ def _create_epoch_histogram(self):
710710
"""Create peak-to-peak histogram of channel amplitudes."""
711711
epochs = self.mne.inst
712712
data = OrderedDict()
713-
ptp = np.ptp(epochs.get_data(copy=False), axis=2)
713+
# per epoch, so that variable-duration epochs (a list of arrays)
714+
# work too; peak-to-peak is a per-trial reduction either way
715+
ptp = np.array(
716+
[np.ptp(epoch, axis=-1) for epoch in epochs.get_data(copy=False)]
717+
)
714718
for ch_type in ("eeg", "mag", "grad"):
715719
if ch_type in epochs:
716720
data[ch_type] = ptp.T[self.mne.ch_types == ch_type].ravel()
@@ -808,6 +812,27 @@ def _load_backend(backend_name):
808812
return backend
809813

810814

815+
def _check_variable_duration_backend():
816+
"""Raise unless the active browser backend can draw ragged epochs.
817+
818+
Matplotlib always can. The Qt backend gained the ability in
819+
mne-qt-browser 0.8, which announces it with a module-level flag, so an
820+
older one declines here rather than drawing a wrong picture from a
821+
boundary model it does not know about.
822+
"""
823+
backend_name = get_browser_backend()
824+
if backend_name == "matplotlib":
825+
return
826+
module = _load_backend(backend_name)
827+
if not getattr(module, "_SUPPORTS_VARIABLE_DURATION", False):
828+
raise NotImplementedError(
829+
f"Browsing variable-duration epochs is not implemented for the "
830+
f"{backend_name} backend of this version, only for matplotlib. "
831+
"Upgrade mne-qt-browser, or select matplotlib with "
832+
'mne.viz.set_browser_backend("matplotlib").'
833+
)
834+
835+
811836
def _get_browser(show, block, **kwargs):
812837
"""Instantiate a new MNE browse-style figure."""
813838
from .utils import _get_figsize_from_config

mne/viz/_mpl_figure.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2263,8 +2263,10 @@ def _draw_traces(self):
22632263
# check for bad epochs
22642264
time_range = (self.mne.times + self.mne.first_time)[[0, -1]]
22652265
if self.mne.instance_type == "epochs":
2266-
epoch_ix = np.searchsorted(self.mne.boundary_times, time_range)
2267-
epoch_ix = np.arange(epoch_ix[0], epoch_ix[1])
2266+
# ask the view directly: deriving this from the time range drops
2267+
# the last epoch whenever it holds a single sample, because that
2268+
# sample's time is its own left boundary
2269+
epoch_ix = np.arange(*self._get_epoch_ix_range())
22682270
epoch_nums = self.mne.inst.selection[epoch_ix[0] : epoch_ix[-1] + 1]
22692271
(visible_bad_epoch_ix,) = np.isin(epoch_nums, self.mne.bad_epochs).nonzero()
22702272
while len(self.mne.epoch_traces):
@@ -2334,7 +2336,11 @@ def _draw_traces(self):
23342336
_starts = self.mne.boundary_times[epoch_ix][bool_ixs]
23352337
_stops = self.mne.boundary_times[epoch_ix + 1][bool_ixs]
23362338
for _start, _stop in zip(_starts, _stops):
2337-
_mask = np.logical_and(_start < this_times, this_times <= _stop)
2339+
# inclusive at both ends: an epoch owns its own first
2340+
# sample, and a one-sample epoch has nothing else
2341+
_mask = np.logical_and(
2342+
_start <= this_times, this_times <= _stop
2343+
)
23382344
mask = mask | _mask
23392345
_times = np.ma.masked_array(this_times, mask=~mask)
23402346
# always use the existing traces first
@@ -2442,7 +2448,15 @@ def _recompute_epochs_vlines(self, xdata):
24422448
len(boundary_times) - 2,
24432449
)
24442450
)
2445-
offset = round((xdata - boundary_times[clicked_ix]) * sfreq)
2451+
# clamp into the clicked epoch: a click in its last half sample would
2452+
# otherwise round up to one sample past its end, which no epoch holds,
2453+
# and every line would be dropped
2454+
n_samp = int(
2455+
round((boundary_times[clicked_ix + 1] - boundary_times[clicked_ix]) * sfreq)
2456+
)
2457+
offset = int(
2458+
np.clip(round((xdata - boundary_times[clicked_ix]) * sfreq), 0, n_samp - 1)
2459+
)
24462460
latency = self.mne.epoch_tmins[clicked_ix] + offset / sfreq
24472461
ix_start, ix_stop = self._get_epoch_ix_range()
24482462
xs = list()

mne/viz/epochs.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,15 +1133,9 @@ def plot_epochs(
11331133
)
11341134

11351135
if epochs._variable_duration:
1136-
from ._figure import get_browser_backend
1137-
1138-
backend_name = get_browser_backend()
1139-
if backend_name != "matplotlib":
1140-
raise NotImplementedError(
1141-
f"Browsing variable-duration epochs is not implemented for the "
1142-
f"{backend_name} backend yet, only for matplotlib. Select it "
1143-
'with mne.viz.set_browser_backend("matplotlib").'
1144-
)
1136+
from ._figure import _check_variable_duration_backend
1137+
1138+
_check_variable_duration_backend()
11451139

11461140
fig = _get_browser(show=show, block=block, **params)
11471141

mne/viz/tests/test_epochs.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -554,8 +554,6 @@ def variable_epochs():
554554

555555
def test_plot_variable_duration_is_native(variable_epochs, browser_backend):
556556
"""Test that browsing ragged epochs neither warns nor pads."""
557-
if browser_backend.name != "matplotlib":
558-
pytest.skip("variable-duration browsing is matplotlib-only")
559557

560558
def _boom(*args, **kwargs):
561559
raise AssertionError("plot() fell back to as_fixed() instead of browsing")
@@ -569,8 +567,6 @@ def _boom(*args, **kwargs):
569567

570568
def test_plot_variable_duration_boundaries(variable_epochs, browser_backend):
571569
"""Test that the browser lays epochs end to end at their true lengths."""
572-
if browser_backend.name != "matplotlib":
573-
pytest.skip("variable-duration browsing is matplotlib-only")
574570
fig = variable_epochs.plot(n_epochs=2)
575571
assert_allclose(fig.mne.boundary_times, _boundaries())
576572
assert_array_equal(fig.mne.boundary_samples, np.r_[0, np.cumsum(LENGTHS_VAR)])
@@ -583,8 +579,6 @@ def test_plot_variable_duration_window_spans_whole_epochs(
583579
variable_epochs, browser_backend
584580
):
585581
"""Test that n_epochs means epochs, not a representative duration."""
586-
if browser_backend.name != "matplotlib":
587-
pytest.skip("variable-duration browsing is matplotlib-only")
588582
boundaries = _boundaries()
589583
for n_epochs in (1, 2, 3, 4):
590584
fig = variable_epochs.plot(n_epochs=n_epochs)
@@ -594,8 +588,6 @@ def test_plot_variable_duration_window_spans_whole_epochs(
594588

595589
def test_plot_variable_duration_data_is_unpadded(variable_epochs, browser_backend):
596590
"""Test that a view holds exactly the source samples, in order."""
597-
if browser_backend.name != "matplotlib":
598-
pytest.skip("variable-duration browsing is matplotlib-only")
599591
fig = variable_epochs.plot(n_epochs=2)
600592
source = variable_epochs.get_data()
601593
for keys in ([], ["right"], ["right", "right"]):
@@ -616,8 +608,6 @@ def test_plot_variable_duration_data_is_unpadded(variable_epochs, browser_backen
616608

617609
def test_plot_variable_duration_navigation(variable_epochs, browser_backend):
618610
"""Test that arrow keys move by epochs and land on real boundaries."""
619-
if browser_backend.name != "matplotlib":
620-
pytest.skip("variable-duration browsing is matplotlib-only")
621611
boundaries = _boundaries()
622612
fig = variable_epochs.plot(n_epochs=2)
623613
assert fig.mne.t_start == pytest.approx(boundaries[0])
@@ -642,8 +632,6 @@ def test_plot_variable_duration_navigation(variable_epochs, browser_backend):
642632

643633
def test_plot_variable_duration_home_end(variable_epochs, browser_backend):
644634
"""Test that home/end change the epoch count and recompute the duration."""
645-
if browser_backend.name != "matplotlib":
646-
pytest.skip("variable-duration browsing is matplotlib-only")
647635
boundaries = _boundaries()
648636
fig = variable_epochs.plot(n_epochs=2)
649637
assert fig.mne.duration == pytest.approx(boundaries[2])
@@ -672,7 +660,7 @@ def test_plot_variable_duration_hscroll_patches(variable_epochs, browser_backend
672660
def test_plot_variable_duration_bad_epoch(variable_epochs, browser_backend):
673661
"""Test that a click finds the right epoch when the widths differ."""
674662
if browser_backend.name != "matplotlib":
675-
pytest.skip("variable-duration browsing is matplotlib-only")
663+
pytest.skip("epoch marking by click is matplotlib-specific")
676664
boundaries = _boundaries()
677665
fig = variable_epochs.plot(n_epochs=4)
678666
y = fig.mne.traces[0].get_ydata()[0]
@@ -723,10 +711,14 @@ def test_plot_variable_duration_events(browser_backend):
723711
assert_allclose(got, want)
724712

725713

726-
def test_plot_variable_duration_refuses_other_backends(variable_epochs, monkeypatch):
727-
"""Test that non-matplotlib backends decline rather than fail obscurely."""
714+
def test_plot_variable_duration_refuses_old_backends(variable_epochs, monkeypatch):
715+
"""Test that a backend without the boundary model declines, not fails."""
728716
import mne.viz._figure
729717

718+
class _OldBackend: # an mne-qt-browser that predates the boundary model
719+
pass
720+
730721
monkeypatch.setattr(mne.viz._figure, "get_browser_backend", lambda: "qt")
722+
monkeypatch.setattr(mne.viz._figure, "_load_backend", lambda name: _OldBackend())
731723
with pytest.raises(NotImplementedError, match="not implemented for the qt"):
732724
variable_epochs.plot()

0 commit comments

Comments
 (0)