From 37021d1ec47f936548fcb5bdb61ad7891128f17c Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 10 Oct 2026 17:16:55 +1000 Subject: [PATCH 01/14] added plot joint to cluster_result --- mne/stats/cluster_level.py | 83 +++++++++++++++++++ .../76_new_cluster_test_api.py | 34 ++------ 2 files changed, 90 insertions(+), 27 deletions(-) diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index d09c14c4342..ed883359507 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -2937,3 +2937,86 @@ def __repr__(self): # noqa: D105 f"" ) + + def plot_joint( + self, + info, + times_sec, + times="peaks", + title=None, + picks=None, + exclude="bads", + show=True, + ts_args=None, + topomap_args=None, + ): + """Plot the observed cluster test statistic as a joint plot. + + Visualizes test statistics as a time series (butterfly plot) with + topographic maps at selected time points, preserving spatial relationships + between channels. + + Parameters + ---------- + info : instance of Info + The Info object from the evoked data, containing channel information. + times_sec : array-like + The time vector (in seconds) corresponding to the stat_obs time axis. + Should match the length of ``stat_obs.shape[1]``. + times : float | array of float | "auto" | "peaks" + The time point(s) at which to show topomaps. If ``"auto"``, 5 evenly + spaced topographies will be shown. If ``"peaks"``, automatically finds + time points by checking for local maxima. Defaults to ``"peaks"``. + title : str | None + The title for the plot. If ``None``, a default title is created based + on the test statistic name and contrast. If an empty string, no title + is shown. Defaults to ``None``. + picks : str | array-like | slice | None + Channels to include. Defaults to ``None`` (all channels). + exclude : list of str | "bads" + Channels to exclude. Defaults to ``"bads"``. + show : bool + Show figure if ``True``. Defaults to ``True``. + ts_args : dict | None + Arguments forwarded to :func:`mne.Evoked.plot`. + Defaults to ``None``. + topomap_args : dict | None + Arguments forwarded to :func:`mne.Evoked.plot_topomap`. + Defaults to ``None``. + + Returns + ------- + fig : instance of matplotlib.figure.Figure | list + The figure object(s) containing the plot. + """ + from mne import EvokedArray + from mne.viz.evoked import plot_evoked_joint + + # Create EvokedArray from test statistics + stat_evoked = EvokedArray( + self.stat_obs, + info, + tmin=times_sec[0], + ) + + # Create default title if not provided + if title is None: + if self.contrast is not None: + title = ( + f"Observed cluster {self.stat_name} " + f"({self.contrast[0]} − {self.contrast[1]})" + ) + else: + title = f"Observed cluster {self.stat_name}" + + # Plot with units="None" to skip automatic scaling of test statistics + return plot_evoked_joint( + stat_evoked, + times=times, + title=title, + picks=picks, + exclude=exclude, + show=show, + ts_args=ts_args, + topomap_args=topomap_args, + ) diff --git a/tutorials/stats-sensor-space/76_new_cluster_test_api.py b/tutorials/stats-sensor-space/76_new_cluster_test_api.py index 3faccb8e6ae..2b2ba535b54 100644 --- a/tutorials/stats-sensor-space/76_new_cluster_test_api.py +++ b/tutorials/stats-sensor-space/76_new_cluster_test_api.py @@ -28,8 +28,6 @@ from pathlib import Path -import matplotlib.pyplot as plt -import numpy as np import pandas as pd import mne @@ -178,29 +176,11 @@ print(f"Number of permutations run: {cluster_result.n_permutations}") -# times (in seconds) and channel names come from the evoked data -times = grand_avg_diff.times -ch_names = grand_avg_diff.ch_names - -# stat_obs holds the observed t-values, arranged as (channels, times) -stat_obs = cluster_result.stat_obs - -# symmetric colour limits so the diverging colormap is centred on zero -vlim = np.abs(stat_obs).max() - -fig, ax = plt.subplots(layout="constrained") -im = ax.imshow( - stat_obs, - aspect="auto", - origin="lower", - extent=[times[0], times[-1], 0, len(ch_names)], - cmap="RdBu_r", - vmin=-vlim, - vmax=vlim, +# Plot cluster result +fig = cluster_result.plot_joint( + info=grand_avg_diff.info, + times_sec=grand_avg_diff.times, + times="peaks", + title="Observed cluster statistic (target - non-target)", + show=False, ) -ax.set_yticks(np.arange(len(ch_names)) + 0.5) -ax.set_yticklabels(ch_names) -ax.set_xlabel("time (s)") -ax.set_ylabel("channel") -ax.set_title("Observed cluster statistic (target - non-target)") -fig.colorbar(im, ax=ax, label="t-value") From 1426be480f08a79af51ca97ed3c28d7ccf3d829b Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 10 Oct 2026 17:33:30 +1000 Subject: [PATCH 02/14] change units and scaling for plotting of stats --- mne/stats/cluster_level.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index ed883359507..961f3d8dc2b 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -3009,7 +3009,15 @@ def plot_joint( else: title = f"Observed cluster {self.stat_name}" - # Plot with units="None" to skip automatic scaling of test statistics + # Test statistics are not in Volts: disable the V -> uV scaling + # and label the axes with t-values instead. + ch_types = set(stat_evoked.get_channel_types()) + scalings = {ch_type: 1.0 for ch_type in ch_types} + units = {ch_type: "t-values" for ch_type in ch_types} + + ts_args = {"scalings": scalings, "units": units, **(ts_args or {})} + topomap_args = {"scalings": scalings, "units": units, **(topomap_args or {})} + return plot_evoked_joint( stat_evoked, times=times, From 463add273a28f9081174abfd05cc7d54978b0fea Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 10 Oct 2026 17:58:32 +1000 Subject: [PATCH 03/14] get unit from stat_fun --- mne/stats/cluster_level.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index 961f3d8dc2b..b99f5ec973a 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -2938,6 +2938,17 @@ def __repr__(self): # noqa: D105 f"{len(self.clusters)} clusters{contrast}>" ) + @staticmethod + def _get_stat_unit(stat_fun, default="stat"): + """Return the unit label for a test statistic (e.g. "t" or "F").""" + if stat_fun is f_oneway: + return "F" + if stat_fun is ttest_1samp_no_p: + return "t" + if isinstance(stat_fun, partial) and stat_fun.func is _rm_anova_stat_fun: + return "F" + return default + def plot_joint( self, info, @@ -3011,9 +3022,10 @@ def plot_joint( # Test statistics are not in Volts: disable the V -> uV scaling # and label the axes with t-values instead. + unit = self._get_stat_unit(self.stat_fun) ch_types = set(stat_evoked.get_channel_types()) scalings = {ch_type: 1.0 for ch_type in ch_types} - units = {ch_type: "t-values" for ch_type in ch_types} + units = {ch_type: unit for ch_type in ch_types} ts_args = {"scalings": scalings, "units": units, **(ts_args or {})} topomap_args = {"scalings": scalings, "units": units, **(topomap_args or {})} From a4210414b9ddbd6f5085d65c1fff475bad431598 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 11 Oct 2026 14:51:36 +1000 Subject: [PATCH 04/14] use df as input --- tutorials/stats-sensor-space/76_new_cluster_test_api.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tutorials/stats-sensor-space/76_new_cluster_test_api.py b/tutorials/stats-sensor-space/76_new_cluster_test_api.py index 2b2ba535b54..656a680fb2e 100644 --- a/tutorials/stats-sensor-space/76_new_cluster_test_api.py +++ b/tutorials/stats-sensor-space/76_new_cluster_test_api.py @@ -178,9 +178,7 @@ # Plot cluster result fig = cluster_result.plot_joint( - info=grand_avg_diff.info, - times_sec=grand_avg_diff.times, - times="peaks", - title="Observed cluster statistic (target - non-target)", + info=df["evoked"][0].info, + times_sec=df["evoked"][0].times, show=False, ) From f24dc495f6be03f09a9c570a9863f97d7fcbb8d3 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 11 Oct 2026 15:13:37 +1000 Subject: [PATCH 05/14] add minimal test for cluser plotting --- mne/stats/tests/test_cluster_level_modern.py | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/mne/stats/tests/test_cluster_level_modern.py b/mne/stats/tests/test_cluster_level_modern.py index f1c5c7cd92b..1c972f91d09 100644 --- a/mne/stats/tests/test_cluster_level_modern.py +++ b/mne/stats/tests/test_cluster_level_modern.py @@ -12,10 +12,13 @@ cluster_test, combine_adjacency, f_mway_rm, + f_oneway, f_threshold_mway_rm, permutation_cluster_1samp_test, permutation_cluster_test, + ttest_1samp_no_p, ) +from mne.stats.cluster_level import ClusterResult from mne.time_frequency import AverageTFRArray, BaseTFR, EpochsTFRArray from mne.utils import GetEpochsMixin @@ -419,3 +422,30 @@ def test_cluster_test_adjacency(): ) with pytest.raises(ValueError, match='"auto" could not infer sensor adjacency'): cluster_test(df_no_montage, "data ~ condition", **kwargs) + + +def _plot(stat_fun, **kwargs): + info = create_info(["Fz", "Cz", "Pz", "Oz"], 1000.0, "eeg").set_montage( + "fsaverage_1020" + ) + times = np.linspace(0, 1, 10) + res = ClusterResult( + stat_obs=np.random.default_rng(0).standard_normal((4, 10)), + clusters=[], + cluster_p_values=np.array([]), + H0=np.array([]), + stat_fun=stat_fun, + n_permutations=1, + **kwargs, + ) + return res.plot_joint(info, times, show=False) + + +@pytest.mark.parametrize( + ("stat_fun", "unit"), + [(ttest_1samp_no_p, "t"), (f_oneway, "F")], # TODO: how many tests do we support? +) +def test_plot_joint_unit_matches_stat_fun(stat_fun, unit): + """Axis label uses the unit for the statistic, not V or µV.""" + fig = _plot(stat_fun) + assert unit in fig.axes[3].get_ylabel() From 79ae97a90e4eb5e73100567c1d5e23bcdc4ff84f Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 11 Oct 2026 15:14:04 +1000 Subject: [PATCH 06/14] changelog --- doc/changes/dev/14339.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14339.newfeature.rst diff --git a/doc/changes/dev/14339.newfeature.rst b/doc/changes/dev/14339.newfeature.rst new file mode 100644 index 00000000000..8c8b0586511 --- /dev/null +++ b/doc/changes/dev/14339.newfeature.rst @@ -0,0 +1 @@ +Add :meth:`mne.stats.ClusterResult.plot_joint` to visualize the observed cluster test statistic as a joint plot, by `CarinaFo`_. \ No newline at end of file From 0d121e8baab4ca1a1e6f8a2cfdb34cd7b514da6c Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 11 Oct 2026 15:19:32 +1000 Subject: [PATCH 07/14] wrong PR number in docchange --- doc/changes/dev/14399.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14399.newfeature.rst diff --git a/doc/changes/dev/14399.newfeature.rst b/doc/changes/dev/14399.newfeature.rst new file mode 100644 index 00000000000..8c8b0586511 --- /dev/null +++ b/doc/changes/dev/14399.newfeature.rst @@ -0,0 +1 @@ +Add :meth:`mne.stats.ClusterResult.plot_joint` to visualize the observed cluster test statistic as a joint plot, by `CarinaFo`_. \ No newline at end of file From aa0ce7c45617261807406639ea7e17ace915c979 Mon Sep 17 00:00:00 2001 From: Carina Date: Sun, 11 Oct 2026 15:32:37 +1000 Subject: [PATCH 08/14] Delete doc/changes/dev/14339.newfeature.rst --- doc/changes/dev/14339.newfeature.rst | 1 - 1 file changed, 1 deletion(-) delete mode 100644 doc/changes/dev/14339.newfeature.rst diff --git a/doc/changes/dev/14339.newfeature.rst b/doc/changes/dev/14339.newfeature.rst deleted file mode 100644 index 8c8b0586511..00000000000 --- a/doc/changes/dev/14339.newfeature.rst +++ /dev/null @@ -1 +0,0 @@ -Add :meth:`mne.stats.ClusterResult.plot_joint` to visualize the observed cluster test statistic as a joint plot, by `CarinaFo`_. \ No newline at end of file From acd4acc1869dbe525eabdc412b7ff660eeaa45da Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 11 Oct 2026 15:36:07 +1000 Subject: [PATCH 09/14] wrong username --- doc/changes/dev/14399.newfeature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/dev/14399.newfeature.rst b/doc/changes/dev/14399.newfeature.rst index 8c8b0586511..f5efc3a6169 100644 --- a/doc/changes/dev/14399.newfeature.rst +++ b/doc/changes/dev/14399.newfeature.rst @@ -1 +1 @@ -Add :meth:`mne.stats.ClusterResult.plot_joint` to visualize the observed cluster test statistic as a joint plot, by `CarinaFo`_. \ No newline at end of file +Add :meth:`mne.stats.ClusterResult.plot_joint` to visualize the observed cluster test statistic as a joint plot, by `Carina Forster`_. \ No newline at end of file From 178d2a524788b8de27745dae2dc546438de1a804 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 11 Oct 2026 15:47:04 +1000 Subject: [PATCH 10/14] add scaling test --- mne/stats/tests/test_cluster_level_modern.py | 35 +++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/mne/stats/tests/test_cluster_level_modern.py b/mne/stats/tests/test_cluster_level_modern.py index 1c972f91d09..0a6bcea999e 100644 --- a/mne/stats/tests/test_cluster_level_modern.py +++ b/mne/stats/tests/test_cluster_level_modern.py @@ -2,6 +2,8 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from unittest import mock + import numpy as np import pytest from numpy.testing import assert_array_almost_equal, assert_array_equal @@ -424,12 +426,14 @@ def test_cluster_test_adjacency(): cluster_test(df_no_montage, "data ~ condition", **kwargs) -def _plot(stat_fun, **kwargs): - info = create_info(["Fz", "Cz", "Pz", "Oz"], 1000.0, "eeg").set_montage( +def _info(): + return create_info(["Fz", "Cz", "Pz", "Oz"], 1000.0, "eeg").set_montage( "fsaverage_1020" ) - times = np.linspace(0, 1, 10) - res = ClusterResult( + + +def _result(stat_fun, **kwargs): + return ClusterResult( stat_obs=np.random.default_rng(0).standard_normal((4, 10)), clusters=[], cluster_p_values=np.array([]), @@ -438,7 +442,6 @@ def _plot(stat_fun, **kwargs): n_permutations=1, **kwargs, ) - return res.plot_joint(info, times, show=False) @pytest.mark.parametrize( @@ -446,6 +449,22 @@ def _plot(stat_fun, **kwargs): [(ttest_1samp_no_p, "t"), (f_oneway, "F")], # TODO: how many tests do we support? ) def test_plot_joint_unit_matches_stat_fun(stat_fun, unit): - """Axis label uses the unit for the statistic, not V or µV.""" - fig = _plot(stat_fun) - assert unit in fig.axes[3].get_ylabel() + """Units passed to MNE match the statistic, not V or µV.""" + TIMES = np.linspace(0, 0.09, 10) + PLOT = "mne.viz.evoked.plot_evoked_joint" + with mock.patch(PLOT) as plot: + _result(stat_fun).plot_joint(_info(), TIMES, times=[0.02], show=False) + for key in ("ts_args", "topomap_args"): + assert plot.call_args.kwargs[key]["units"]["eeg"] == unit + + +@pytest.mark.parametrize("stat_fun", [ttest_1samp_no_p, f_oneway, lambda x: x]) +def test_plot_joint_disables_scaling(stat_fun): + """Test statistics must not be scaled from V to µV.""" + TIMES = np.linspace(0, 0.09, 10) + PLOT = "mne.viz.evoked.plot_evoked_joint" + with mock.patch(PLOT) as plot: + _result(stat_fun).plot_joint(_info(), TIMES, times=[0.02], show=False) + for key in ("ts_args", "topomap_args"): + scalings = plot.call_args.kwargs[key]["scalings"] + assert scalings and all(v == 1.0 for v in scalings.values()) From 6463b3bbfff66d81eb75c88982125ba610008909 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sun, 18 Oct 2026 19:20:29 +1000 Subject: [PATCH 11/14] improved test --- mne/stats/tests/test_cluster_level_modern.py | 58 ++++++++------------ 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/mne/stats/tests/test_cluster_level_modern.py b/mne/stats/tests/test_cluster_level_modern.py index 0a6bcea999e..6e6806d429d 100644 --- a/mne/stats/tests/test_cluster_level_modern.py +++ b/mne/stats/tests/test_cluster_level_modern.py @@ -2,6 +2,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from functools import partial from unittest import mock import numpy as np @@ -20,7 +21,7 @@ permutation_cluster_test, ttest_1samp_no_p, ) -from mne.stats.cluster_level import ClusterResult +from mne.stats.cluster_level import ClusterResult, _rm_anova_stat_fun from mne.time_frequency import AverageTFRArray, BaseTFR, EpochsTFRArray from mne.utils import GetEpochsMixin @@ -426,45 +427,32 @@ def test_cluster_test_adjacency(): cluster_test(df_no_montage, "data ~ condition", **kwargs) -def _info(): - return create_info(["Fz", "Cz", "Pz", "Oz"], 1000.0, "eeg").set_montage( - "fsaverage_1020" - ) - - -def _result(stat_fun, **kwargs): - return ClusterResult( - stat_obs=np.random.default_rng(0).standard_normal((4, 10)), +@pytest.mark.parametrize( + ("stat_fun", "unit"), + [ + (ttest_1samp_no_p, "t"), + (f_oneway, "F"), + pytest.param( + partial(_rm_anova_stat_fun, factor_levels=[2, 2], effects="A:B"), + "F", + id="rm_anova-F", + ), + ], +) +def test_cluster_result_plot_joint_units(stat_fun, unit): + """Test plot_joint labels the statistic and does not rescale it to µV.""" + info = create_info(["Fz", "Cz", "Pz", "Oz"], 100, "eeg") + result = ClusterResult( + stat_obs=np.zeros((4, 10)), # required keywords clusters=[], cluster_p_values=np.array([]), H0=np.array([]), stat_fun=stat_fun, n_permutations=1, - **kwargs, ) - - -@pytest.mark.parametrize( - ("stat_fun", "unit"), - [(ttest_1samp_no_p, "t"), (f_oneway, "F")], # TODO: how many tests do we support? -) -def test_plot_joint_unit_matches_stat_fun(stat_fun, unit): - """Units passed to MNE match the statistic, not V or µV.""" - TIMES = np.linspace(0, 0.09, 10) - PLOT = "mne.viz.evoked.plot_evoked_joint" - with mock.patch(PLOT) as plot: - _result(stat_fun).plot_joint(_info(), TIMES, times=[0.02], show=False) + # mock a figure + with mock.patch("mne.viz.evoked.plot_evoked_joint") as plot: + result.plot_joint(info, np.arange(10), times=[2.0]) for key in ("ts_args", "topomap_args"): assert plot.call_args.kwargs[key]["units"]["eeg"] == unit - - -@pytest.mark.parametrize("stat_fun", [ttest_1samp_no_p, f_oneway, lambda x: x]) -def test_plot_joint_disables_scaling(stat_fun): - """Test statistics must not be scaled from V to µV.""" - TIMES = np.linspace(0, 0.09, 10) - PLOT = "mne.viz.evoked.plot_evoked_joint" - with mock.patch(PLOT) as plot: - _result(stat_fun).plot_joint(_info(), TIMES, times=[0.02], show=False) - for key in ("ts_args", "topomap_args"): - scalings = plot.call_args.kwargs[key]["scalings"] - assert scalings and all(v == 1.0 for v in scalings.values()) + assert plot.call_args.kwargs[key]["scalings"] == dict(eeg=1.0) From be5e43887379b9b1176b1b721f8858a100be7fef Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 24 Oct 2026 16:47:56 +1000 Subject: [PATCH 12/14] plot_joint only supports 2D cluster for now --- mne/stats/cluster_level.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/mne/stats/cluster_level.py b/mne/stats/cluster_level.py index 50adada1287..e0e3a4cb16a 100644 --- a/mne/stats/cluster_level.py +++ b/mne/stats/cluster_level.py @@ -2933,13 +2933,17 @@ def plot_joint( topographic maps at selected time points, preserving spatial relationships between channels. + .. note:: + Only results of shape ``(n_channels, n_times)`` (e.g. from + :class:`~mne.Evoked` input) are supported. Time-frequency results + are not supported yet. + Parameters ---------- info : instance of Info The Info object from the evoked data, containing channel information. times_sec : array-like - The time vector (in seconds) corresponding to the stat_obs time axis. - Should match the length of ``stat_obs.shape[1]``. + Must have length stat_obs.shape[1] and spacing 1 / info["sfreq"]. times : float | array of float | "auto" | "peaks" The time point(s) at which to show topomaps. If ``"auto"``, 5 evenly spaced topographies will be shown. If ``"peaks"``, automatically finds @@ -2969,12 +2973,30 @@ def plot_joint( from mne import EvokedArray from mne.viz.evoked import plot_evoked_joint - # Create EvokedArray from test statistics - stat_evoked = EvokedArray( - self.stat_obs, - info, - tmin=times_sec[0], - ) + # for now only supports 2D clusters + if self.stat_obs.ndim != 2: + raise ValueError( + "plot_joint supports only (n_channels, n_times) statistics, got " + f"stat_obs with shape {self.stat_obs.shape}. Time-frequency " + "results are not supported yet." + ) + if len(info["ch_names"]) != self.stat_obs.shape[0]: + raise ValueError( + f"info has {len(info['ch_names'])} channels, but stat_obs has " + f"{self.stat_obs.shape[0]}." + ) + + times_sec = np.asarray(times_sec, float) + + stat_evoked = EvokedArray(self.stat_obs, info, tmin=times_sec[0]) + + if times_sec.shape != stat_evoked.times.shape or not np.allclose( + stat_evoked.times, times_sec + ): + raise ValueError( + "times_sec does not match the time axis of stat_obs given " + f"info['sfreq']={info['sfreq']}." + ) # Create default title if not provided if title is None: From 850567c1baf8e48282fba33cb6a6f0f26a431af0 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 24 Oct 2026 16:48:30 +1000 Subject: [PATCH 13/14] update tutorial --- .../stats-sensor-space/76_new_cluster_test_api.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tutorials/stats-sensor-space/76_new_cluster_test_api.py b/tutorials/stats-sensor-space/76_new_cluster_test_api.py index 656a680fb2e..8338403acff 100644 --- a/tutorials/stats-sensor-space/76_new_cluster_test_api.py +++ b/tutorials/stats-sensor-space/76_new_cluster_test_api.py @@ -170,13 +170,16 @@ # ------------------- # # The result object carries the observed cluster-level statistics. We plot the -# observed t-values as a heatmap with time on the x-axis and channel names on -# the y-axis. Because the contrast is target minus non-target, positive t-values -# mean a stronger response to targets, matching the difference plotted earlier. +# observed t-values as a joint plot: a butterfly plot of the t-value time course +# of every channel, with topographic maps at the time points of the largest +# peaks. Because the contrast is target minus non-target, positive t-values mean +# a stronger response to targets, matching the difference plotted earlier. +# Note that this shows the test statistic itself, not which channels and time +# points belong to a significant cluster. print(f"Number of permutations run: {cluster_result.n_permutations}") -# Plot cluster result +# Plot cluster result statistic (for now without the actual cluster highlighted) fig = cluster_result.plot_joint( info=df["evoked"][0].info, times_sec=df["evoked"][0].times, From b368507ab36a0a8d5a81de992e456b41d453d722 Mon Sep 17 00:00:00 2001 From: CarinaFo Date: Sat, 24 Oct 2026 16:52:06 +1000 Subject: [PATCH 14/14] make sure plot_joing throws an error for 3D input , wrong channel count and wrong time input --- mne/stats/tests/test_cluster_level_modern.py | 25 +++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/mne/stats/tests/test_cluster_level_modern.py b/mne/stats/tests/test_cluster_level_modern.py index 6e6806d429d..09d9b317acc 100644 --- a/mne/stats/tests/test_cluster_level_modern.py +++ b/mne/stats/tests/test_cluster_level_modern.py @@ -452,7 +452,30 @@ def test_cluster_result_plot_joint_units(stat_fun, unit): ) # mock a figure with mock.patch("mne.viz.evoked.plot_evoked_joint") as plot: - result.plot_joint(info, np.arange(10), times=[2.0]) + result.plot_joint(info, np.arange(10) / 100, times=[0.02]) for key in ("ts_args", "topomap_args"): assert plot.call_args.kwargs[key]["units"]["eeg"] == unit assert plot.call_args.kwargs[key]["scalings"] == dict(eeg=1.0) + + +def test_cluster_result_plot_joint_errors(): + """Test plot_joint rejects input it cannot plot.""" + info = create_info(["Fz", "Cz", "Pz", "Oz"], 100, "eeg") + kwargs = dict( + clusters=[], + cluster_p_values=np.array([]), + H0=np.array([]), + stat_fun=ttest_1samp_no_p, + n_permutations=1, + ) + times = np.arange(10) / 100.0 + tfr = ClusterResult(stat_obs=np.zeros((4, 3, 10)), **kwargs) + with pytest.raises(ValueError, match="Time-frequency results"): + tfr.plot_joint(info, times) + result = ClusterResult(stat_obs=np.zeros((4, 10)), **kwargs) + with pytest.raises(ValueError, match="info has 2 channels"): + result.plot_joint(create_info(["Fz", "Cz"], 100, "eeg"), times) + with pytest.raises(ValueError, match="times_sec does not match"): + result.plot_joint(info, times[:5]) + with pytest.raises(ValueError, match="times_sec does not match"): + result.plot_joint(info, np.arange(10) / 50.0) # wrong sfreq