From d1fd089de46df5e5456cffb5b2e1ef514753b2e9 Mon Sep 17 00:00:00 2001 From: Master-Zero1 Date: Sat, 6 Jun 2026 03:29:00 +0530 Subject: [PATCH 1/8] WIP: ENH add Epochs support and add_rate to add_projs --- mne/report/report.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/mne/report/report.py b/mne/report/report.py index 94e6d7b984f..26da725748c 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -1854,6 +1854,7 @@ def add_projs( title, projs=None, topomap_kwargs=None, + add_rate="auto", tags=("ssp",), joint=False, picks_trace=None, @@ -1903,6 +1904,7 @@ def add_projs( section=section, tags=tags, topomap_kwargs=topomap_kwargs, + add_rate=add_rate, replace=replace, joint=joint, ) @@ -3611,6 +3613,24 @@ def _add_raw( replace=replace, ) + + @staticmethod + def _event_estimate(epochs, projs): + rate_caption = None + n_events = len(epochs.drop_log) + event_times = [ev[0] / epochs.info['sfreq'] for ev in epochs.events] + if len(event_times) > 1: + duration_sec = event_times[-1] - event_times[0] + duration_min = duration_sec / 60.0 + else: + duration_min = 0.0 + return None + if duration_min > 0: + rate = n_events / duration_min + unit = "BPM" if any(any(kw in p["desc"].lower() for kw in ("ecg", "heart")) for p in projs) else "events/min" + rate_caption = f"Estimated rate: {rate:.1f} {unit}" + return rate_caption + @_use_agg def _add_projs( self, @@ -3622,16 +3642,20 @@ def _add_projs( tags, section, topomap_kwargs, + add_rate, replace, picks_trace=None, joint=False, ): evoked = None + epochs = None if isinstance(info, Info): # no-op pass elif isinstance(getattr(info, "info", None), Info): # try to get the file name if isinstance(info, Evoked): evoked = info + if isinstance(info, BaseEpochs): + epochs = info info = info.info else: # read from a file info = read_info(info, verbose=False) @@ -3645,6 +3669,18 @@ def _add_projs( elif not isinstance(projs, list): projs = read_proj(projs) + rate_caption = None + + if add_rate is False or epochs is None: + pass + elif add_rate is True: + rate_caption = self._event_estimate(epochs, projs) + elif add_rate == "auto": + if any(any(kw in p["desc"].lower() for kw in ("ecg", "eog", "blink")) for p in projs): + rate_caption = self._event_estimate(epochs, projs) + else: + rate_caption = None + if not projs: raise ValueError("No SSP projectors found") @@ -3682,7 +3718,7 @@ def _add_projs( self._add_figure( fig=fig, title=title, - caption=None, + caption=rate_caption, image_format=image_format, tags=tags, section=section, From 35e52bdd47a6abb7dfc3aa82e43ebe03f93b609b Mon Sep 17 00:00:00 2001 From: Master-Zero1 Date: Sat, 6 Jun 2026 11:16:01 +0530 Subject: [PATCH 2/8] ENH: add Epochs support and add_rate parameter to Report.add_projs --- doc/changes/dev/13161.newfeature.rst | 1 + mne/report/report.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 doc/changes/dev/13161.newfeature.rst diff --git a/doc/changes/dev/13161.newfeature.rst b/doc/changes/dev/13161.newfeature.rst new file mode 100644 index 00000000000..f5cb38ce554 --- /dev/null +++ b/doc/changes/dev/13161.newfeature.rst @@ -0,0 +1 @@ +:meth:`mne.Report.add_projs` now accepts :class:`~mne.Epochs` as ``info`` and adds an ``add_rate`` parameter to show estimated event rates (e.g. heart rate or blink rate) in the report (:gh:`13161` by `Master-Zero1`_). \ No newline at end of file diff --git a/mne/report/report.py b/mne/report/report.py index 26da725748c..d7621919801 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -1865,7 +1865,7 @@ def add_projs( Parameters ---------- - info : instance of Info | instance of Evoked | path-like + info : instance of Info | instance of Evoked | instance of Epochs | path-like An `~mne.Info` structure or the path of a file containing one. title : str The title corresponding to the :class:`~mne.Projection` object. @@ -1873,6 +1873,15 @@ def add_projs( The projection vectors to add to the report. Can be the path to a file that will be loaded via `mne.read_proj`. If ``None``, the projectors are taken from ``info['projs']``. + add_rate : bool | "auto" + Whether to add an estimated event rate to the figure caption when + ``info`` is an instance of :class:`~mne.Epochs`. If ``"auto"`` + (default), the rate is shown only when the projector descriptions + suggest ECG or EOG content (i.e. contain ``"ecg"``, ``"eog"``, or + ``"blink"``). If ``True``, always show the rate. If ``False``, never + show it. + + .. versionadded:: 1.10 %(topomap_kwargs)s %(tags_report)s joint : bool From 0b07eaa95c6fe21cd3092874f4569fe075c3325a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 05:57:46 +0000 Subject: [PATCH 3/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/report/report.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/mne/report/report.py b/mne/report/report.py index d7621919801..62c33fdf149 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -3622,12 +3622,11 @@ def _add_raw( replace=replace, ) - @staticmethod def _event_estimate(epochs, projs): rate_caption = None n_events = len(epochs.drop_log) - event_times = [ev[0] / epochs.info['sfreq'] for ev in epochs.events] + event_times = [ev[0] / epochs.info["sfreq"] for ev in epochs.events] if len(event_times) > 1: duration_sec = event_times[-1] - event_times[0] duration_min = duration_sec / 60.0 @@ -3636,7 +3635,14 @@ def _event_estimate(epochs, projs): return None if duration_min > 0: rate = n_events / duration_min - unit = "BPM" if any(any(kw in p["desc"].lower() for kw in ("ecg", "heart")) for p in projs) else "events/min" + unit = ( + "BPM" + if any( + any(kw in p["desc"].lower() for kw in ("ecg", "heart")) + for p in projs + ) + else "events/min" + ) rate_caption = f"Estimated rate: {rate:.1f} {unit}" return rate_caption @@ -3685,7 +3691,10 @@ def _add_projs( elif add_rate is True: rate_caption = self._event_estimate(epochs, projs) elif add_rate == "auto": - if any(any(kw in p["desc"].lower() for kw in ("ecg", "eog", "blink")) for p in projs): + if any( + any(kw in p["desc"].lower() for kw in ("ecg", "eog", "blink")) + for p in projs + ): rate_caption = self._event_estimate(epochs, projs) else: rate_caption = None From 0fd4e3d018a58716e6185e04c075b363086ca996 Mon Sep 17 00:00:00 2001 From: Master-Zero1 Date: Sun, 7 Jun 2026 01:03:19 +0530 Subject: [PATCH 4/8] ENH: add test and tutorial example for Epochs support in add_projs --- mne/report/report.py | 2 -- mne/report/tests/test_report.py | 21 ++++++++++++++++++++- tutorials/intro/70_report.py | 10 ++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/mne/report/report.py b/mne/report/report.py index 62c33fdf149..ec8aa5b869f 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -3696,8 +3696,6 @@ def _add_projs( for p in projs ): rate_caption = self._event_estimate(epochs, projs) - else: - rate_caption = None if not projs: raise ValueError("No SSP projectors found") diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py index d2182ddbe66..a066dc6a0ab 100644 --- a/mne/report/tests/test_report.py +++ b/mne/report/tests/test_report.py @@ -17,6 +17,7 @@ from mne import ( Epochs, + compute_proj_epochs, create_info, pick_channels_cov, read_cov, @@ -28,7 +29,7 @@ from mne.epochs import make_metadata from mne.fixes import _reshape_view from mne.io import RawArray, read_info, read_raw_fif -from mne.preprocessing import ICA +from mne.preprocessing import ICA, create_ecg_epochs from mne.report import Report, _ReportScraper, open_report, report from mne.report import report as report_mod from mne.report.report import ( @@ -1019,6 +1020,24 @@ def test_manual_report_2d(tmp_path, invisible_fig): r.add_projs( info=raw_fname, projs=ecg_proj_fname, title="my proj", tags=("ssp", "ecg") ) + raw_tmp = read_raw_fif(raw_fname).crop(0, 60).load_data() + ecg_epochs = create_ecg_epochs(raw_tmp) + ecg_projs_tmp = compute_proj_epochs(ecg_epochs, n_grad=1, n_mag=1, n_eeg=0) + r.add_projs( + info=ecg_epochs, + projs=ecg_projs_tmp, + title="ECG projs from epochs", + add_rate="auto", + ) + assert "Estimated rate" not in r.html[-1] # auto but no ecg in desc + r.add_projs( + info=ecg_epochs, + projs=ecg_projs_tmp, + title="ECG projs add_rate=True", + add_rate=True, + ) + assert "Estimated rate" in r.html[-1] + del raw_tmp, ecg_epochs, ecg_projs_tmp r.add_ica(ica=ica, title="my ica", inst=None) with pytest.raises(RuntimeError, match="not preloaded"): r.add_ica(ica=ica, title="ica", inst=raw_non_preloaded) diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 76d15fc0251..b31e4d1ce48 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -182,6 +182,16 @@ ecg_proj_path = sample_dir / "sample_audvis_ecg-proj.fif" report = mne.Report(title="Projectors example") report.add_projs(info=raw_path, title="Projs from info") +raw_tmp = mne.io.read_raw(raw_path).crop(0, 60).load_data() +ecg_epochs = mne.preprocessing.create_ecg_epochs(raw_tmp) +ecg_projs_rate = mne.compute_proj_epochs(ecg_epochs, n_grad=1, n_mag=1, n_eeg=0) +report.add_projs( + info=ecg_epochs, + projs=ecg_projs_rate, + title="ECG projs with estimated heart rate", + add_rate="auto", +) +del raw_tmp, ecg_epochs, ecg_projs_rate # Now a joint plot events = mne.read_events(sample_dir / "sample_audvis_ecg-eve.fif") From 8c41939b5a79b94e221820963e8de41b5dc4e558 Mon Sep 17 00:00:00 2001 From: Master-Zero1 Date: Mon, 8 Jun 2026 22:35:53 +0530 Subject: [PATCH 5/8] Update tutorials/intro/70_report.py Co-authored-by: Eric Larson --- tutorials/intro/70_report.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index b31e4d1ce48..64a5983096e 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -179,15 +179,13 @@ # is read from the `~mne.Info`, but projectors potentially included will be # ignored; instead, only the explicitly passed projectors will be plotted. -ecg_proj_path = sample_dir / "sample_audvis_ecg-proj.fif" report = mne.Report(title="Projectors example") -report.add_projs(info=raw_path, title="Projs from info") raw_tmp = mne.io.read_raw(raw_path).crop(0, 60).load_data() ecg_epochs = mne.preprocessing.create_ecg_epochs(raw_tmp) -ecg_projs_rate = mne.compute_proj_epochs(ecg_epochs, n_grad=1, n_mag=1, n_eeg=0) +ecg_projs = mne.compute_proj_epochs(ecg_epochs, n_grad=1, n_mag=1, n_eeg=0) report.add_projs( info=ecg_epochs, - projs=ecg_projs_rate, + projs=ecg_projs, title="ECG projs with estimated heart rate", add_rate="auto", ) From dbffe7c2a3287322c6932570eff96e0b302c9c2e Mon Sep 17 00:00:00 2001 From: Master-Zero1 Date: Mon, 8 Jun 2026 22:58:53 +0530 Subject: [PATCH 6/8] Address review feedback for add_projs add_rate feature --- doc/changes/dev/13161.newfeature.rst | 2 +- mne/report/report.py | 12 +++++++++--- tutorials/intro/70_report.py | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/doc/changes/dev/13161.newfeature.rst b/doc/changes/dev/13161.newfeature.rst index f5cb38ce554..41db6a0929e 100644 --- a/doc/changes/dev/13161.newfeature.rst +++ b/doc/changes/dev/13161.newfeature.rst @@ -1 +1 @@ -:meth:`mne.Report.add_projs` now accepts :class:`~mne.Epochs` as ``info`` and adds an ``add_rate`` parameter to show estimated event rates (e.g. heart rate or blink rate) in the report (:gh:`13161` by `Master-Zero1`_). \ No newline at end of file +:meth:`mne.Report.add_projs` now accepts :class:`~mne.Epochs` as ``info`` and adds an ``add_rate`` parameter to show estimated event rates (e.g. heart rate or blink rate) in the report (:gh:`13161` by `Prithvi Chauhan`_). \ No newline at end of file diff --git a/mne/report/report.py b/mne/report/report.py index ec8aa5b869f..c81b0d73969 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -88,6 +88,7 @@ from ..viz.utils import _ndarray_to_fig _BEM_VIEWS = ("axial", "sagittal", "coronal") +_RATE_PROJ_KEYWORDS = ("ecg", "eog", "blink", "heart") # For raw files, we want to support different suffixes + extensions for all @@ -3638,7 +3639,7 @@ def _event_estimate(epochs, projs): unit = ( "BPM" if any( - any(kw in p["desc"].lower() for kw in ("ecg", "heart")) + any(kw in p["desc"].lower() for kw in _RATE_PROJ_KEYWORDS) for p in projs ) else "events/min" @@ -3686,13 +3687,18 @@ def _add_projs( rate_caption = None - if add_rate is False or epochs is None: + if add_rate is False: pass + elif epochs is None: + if add_rate is True: + raise ValueError( + "add_rate=True requires an Epochs instance to be passed as info" + ) elif add_rate is True: rate_caption = self._event_estimate(epochs, projs) elif add_rate == "auto": if any( - any(kw in p["desc"].lower() for kw in ("ecg", "eog", "blink")) + any(kw in p["desc"].lower() for kw in _RATE_PROJ_KEYWORDS) for p in projs ): rate_caption = self._event_estimate(epochs, projs) diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 64a5983096e..98abbf5c79c 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -189,7 +189,7 @@ title="ECG projs with estimated heart rate", add_rate="auto", ) -del raw_tmp, ecg_epochs, ecg_projs_rate +del raw_tmp, ecg_epochs, ecg_projs # Now a joint plot events = mne.read_events(sample_dir / "sample_audvis_ecg-eve.fif") From ee696b18bcc08fe41d28028950a97dfbe92917d3 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 8 Jun 2026 13:42:29 -0400 Subject: [PATCH 7/8] Apply suggestions from code review Co-authored-by: Eric Larson --- mne/report/report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/report/report.py b/mne/report/report.py index c81b0d73969..070a4315562 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -1882,7 +1882,7 @@ def add_projs( ``"blink"``). If ``True``, always show the rate. If ``False``, never show it. - .. versionadded:: 1.10 + .. versionadded:: 1.13 %(topomap_kwargs)s %(tags_report)s joint : bool From 8bbabf00b5b3ecda64a8446a6660ee58fd05493d Mon Sep 17 00:00:00 2001 From: Master-Zero1 Date: Mon, 8 Jun 2026 23:24:59 +0530 Subject: [PATCH 8/8] Restore ecg_proj_path in tutorial example --- tutorials/intro/70_report.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index 98abbf5c79c..9c0ee64c6a2 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -179,6 +179,7 @@ # is read from the `~mne.Info`, but projectors potentially included will be # ignored; instead, only the explicitly passed projectors will be plotted. +ecg_proj_path = sample_dir / "sample_audvis_ecg-proj.fif" report = mne.Report(title="Projectors example") raw_tmp = mne.io.read_raw(raw_path).crop(0, 60).load_data() ecg_epochs = mne.preprocessing.create_ecg_epochs(raw_tmp)