diff --git a/doc/changes/dev/13161.newfeature.rst b/doc/changes/dev/13161.newfeature.rst new file mode 100644 index 00000000000..41db6a0929e --- /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 `Prithvi Chauhan`_). \ No newline at end of file diff --git a/mne/report/report.py b/mne/report/report.py index 94e6d7b984f..070a4315562 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 @@ -1854,6 +1855,7 @@ def add_projs( title, projs=None, topomap_kwargs=None, + add_rate="auto", tags=("ssp",), joint=False, picks_trace=None, @@ -1864,7 +1866,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. @@ -1872,6 +1874,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.13 %(topomap_kwargs)s %(tags_report)s joint : bool @@ -1903,6 +1914,7 @@ def add_projs( section=section, tags=tags, topomap_kwargs=topomap_kwargs, + add_rate=add_rate, replace=replace, joint=joint, ) @@ -3611,6 +3623,30 @@ 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 _RATE_PROJ_KEYWORDS) + 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 +3658,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 +3685,24 @@ def _add_projs( elif not isinstance(projs, list): projs = read_proj(projs) + rate_caption = 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 _RATE_PROJ_KEYWORDS) + for p in projs + ): + rate_caption = self._event_estimate(epochs, projs) + if not projs: raise ValueError("No SSP projectors found") @@ -3682,7 +3740,7 @@ def _add_projs( self._add_figure( fig=fig, title=title, - caption=None, + caption=rate_caption, image_format=image_format, tags=tags, section=section, 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..9c0ee64c6a2 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -181,7 +181,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 = mne.compute_proj_epochs(ecg_epochs, n_grad=1, n_mag=1, n_eeg=0) +report.add_projs( + info=ecg_epochs, + projs=ecg_projs, + title="ECG projs with estimated heart rate", + add_rate="auto", +) +del raw_tmp, ecg_epochs, ecg_projs # Now a joint plot events = mne.read_events(sample_dir / "sample_audvis_ecg-eve.fif")