Skip to content

Commit

Permalink
MRG: Revamp HTML reprs of Raw, Epochs, Evoked, and Info (mne-…
Browse files Browse the repository at this point in the history
…tools#12583)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel McCloy <[email protected]>
  • Loading branch information
4 people authored May 21, 2024
1 parent 07aecf8 commit c55c44a
Show file tree
Hide file tree
Showing 26 changed files with 683 additions and 310 deletions.
2 changes: 2 additions & 0 deletions doc/changes/devel/12583.apichange.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
``mne.Info.ch_names`` will now return an empty list instead of raising a ``KeyError`` if no channels
are present, by `Richard Höchenberger`_.
4 changes: 4 additions & 0 deletions doc/changes/devel/12583.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
The HTML representations of :class:`~mne.io.Raw`, :class:`~mne.Epochs`,
and :class:`~mne.Evoked` (which you will see e.g. when working with Jupyter Notebooks or
:class:`~mne.Report`) have been updated to be more consistent and contain
slightly more information, by `Richard Höchenberger`_.
82 changes: 8 additions & 74 deletions mne/_fiff/meas_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import datetime
import operator
import string
from collections import Counter, OrderedDict, defaultdict
from collections import Counter, OrderedDict
from collections.abc import Mapping
from copy import deepcopy
from io import BytesIO
Expand Down Expand Up @@ -1838,84 +1838,18 @@ def _update_redundant(self):

@property
def ch_names(self):
return self["ch_names"]

def _get_chs_for_repr(self):
titles = _handle_default("titles")

# good channels
good_names = defaultdict(lambda: list())
for ci, ch_name in enumerate(self["ch_names"]):
if ch_name in self["bads"]:
continue
ch_type = channel_type(self, ci)
good_names[ch_type].append(ch_name)
good_channels = ", ".join(
[f"{len(v)} {titles.get(k, k.upper())}" for k, v in good_names.items()]
)
for key in ("ecg", "eog"): # ensure these are present
if key not in good_names:
good_names[key] = list()
for key, val in good_names.items():
good_names[key] = ", ".join(val) or "Not available"

# bad channels
bad_channels = ", ".join(self["bads"]) or "None"
try:
ch_names = self["ch_names"]
except KeyError:
ch_names = []

return good_channels, bad_channels, good_names["ecg"], good_names["eog"]
return ch_names

@repr_html
def _repr_html_(self, caption=None, duration=None, filenames=None):
def _repr_html_(self):
"""Summarize info for HTML representation."""
if isinstance(caption, str):
html = f"<h4>{caption}</h4>"
else:
html = ""

good_channels, bad_channels, ecg, eog = self._get_chs_for_repr()

# TODO
# Most of the following checks are to ensure that we get a proper repr
# for Forward['info'] (and probably others like
# InverseOperator['info']??), which doesn't seem to follow our standard
# Info structure used elsewhere.
# Proposed solution for a future refactoring:
# Forward['info'] should get its own Info subclass (with respective
# repr).

# meas date
meas_date = self.get("meas_date")
if meas_date is not None:
meas_date = meas_date.strftime("%B %d, %Y %H:%M:%S") + " GMT"

projs = self.get("projs")
if projs:
projs = [
f'{p["desc"]} : {"on" if p["active"] else "off"}' for p in self["projs"]
]
else:
projs = None

info_template = _get_html_template("repr", "info.html.jinja")
sections = ("General", "Channels", "Data")
return html + info_template.render(
sections=sections,
caption=caption,
meas_date=meas_date,
projs=projs,
ecg=ecg,
eog=eog,
good_channels=good_channels,
bad_channels=bad_channels,
dig=self.get("dig"),
subject_info=self.get("subject_info"),
lowpass=self.get("lowpass"),
highpass=self.get("highpass"),
sfreq=self.get("sfreq"),
experimenter=self.get("experimenter"),
duration=duration,
filenames=filenames,
)
return info_template.render(info=self)

def save(self, fname):
"""Write measurement info in fif file.
Expand Down
15 changes: 7 additions & 8 deletions mne/_fiff/tests/test_meas_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -896,18 +896,17 @@ def test_repr_html():
info["projs"] = []
assert "Projections" not in info._repr_html_()
info["bads"] = []
assert "None" in info._repr_html_()
assert "bad" not in info._repr_html_()
info["bads"] = ["MEG 2443", "EEG 053"]
assert "MEG 2443" in info._repr_html_()
assert "EEG 053" in info._repr_html_()
assert "1 bad" in info._repr_html_() # 1 for each channel type

html = info._repr_html_()
for ch in [ # good channel counts
"203 Gradiometers",
"102 Magnetometers",
"9 Stimulus",
"59 EEG",
"1 EOG",
"203", # grad
"102", # mag
"9", # stim
"59", # eeg
"1", # eog
]:
assert ch in html

Expand Down
17 changes: 10 additions & 7 deletions mne/epochs.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from copy import deepcopy
from functools import partial
from inspect import getfullargspec
from pathlib import Path

import numpy as np
from scipy.interpolate import interp1d
Expand Down Expand Up @@ -2062,12 +2063,6 @@ def __repr__(self):

@repr_html
def _repr_html_(self):
if self.baseline is None:
baseline = "off"
else:
baseline = tuple([f"{b:.3f}" for b in self.baseline])
baseline = f"{baseline[0]}{baseline[1]} s"

if isinstance(self.event_id, dict):
event_strings = []
for k, v in sorted(self.event_id.items()):
Expand All @@ -2085,7 +2080,15 @@ def _repr_html_(self):
event_strings = None

t = _get_html_template("repr", "epochs.html.jinja")
t = t.render(epochs=self, baseline=baseline, events=event_strings)
t = t.render(
inst=self,
filenames=(
[Path(self.filename).name]
if getattr(self, "filename", None) is not None
else None
),
event_counts=event_strings,
)
return t

@verbose
Expand Down
16 changes: 9 additions & 7 deletions mne/evoked.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from copy import deepcopy
from inspect import getfullargspec
from pathlib import Path
from typing import Union

import numpy as np
Expand Down Expand Up @@ -467,14 +468,15 @@ def __repr__(self): # noqa: D105

@repr_html
def _repr_html_(self):
if self.baseline is None:
baseline = "off"
else:
baseline = tuple([f"{b:.3f}" for b in self.baseline])
baseline = f"{baseline[0]}{baseline[1]} s"

t = _get_html_template("repr", "evoked.html.jinja")
t = t.render(evoked=self, baseline=baseline)
t = t.render(
inst=self,
filenames=(
[Path(self.filename).name]
if getattr(self, "filename", None) is not None
else None
),
)
return t

@property
Expand Down
10 changes: 2 additions & 8 deletions mne/forward/forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,17 +225,11 @@ def __repr__(self):

@repr_html
def _repr_html_(self):
(
good_chs,
bad_chs,
_,
_,
) = self["info"]._get_chs_for_repr()
src_descr, src_ori = self._get_src_type_and_ori_for_repr()

t = _get_html_template("repr", "forward.html.jinja")
html = t.render(
good_channels=good_chs,
bad_channels=bad_chs,
info=self["info"],
source_space_descr=src_descr,
source_orientation=src_ori,
)
Expand Down
118 changes: 118 additions & 0 deletions mne/html_templates/_templates.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,118 @@
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
import datetime
import functools
import uuid
from dataclasses import dataclass
from typing import Any, Literal, Union

from .._fiff.pick import channel_type
from ..defaults import _handle_default

_COLLAPSED = False # will override in doc build


def _format_number(value: Union[int, float]) -> str:
"""Insert thousand separators."""
return f"{value:,}"


def _append_uuid(string: str, sep: str = "-") -> str:
"""Append a UUID to a string."""
return f"{string}{sep}{uuid.uuid4()}"


def _data_type(obj) -> str:
"""Return the qualified name of a class."""
return obj.__class__.__qualname__


def _dt_to_str(dt: datetime.datetime) -> str:
"""Convert a datetime object to a human-readable string representation."""
return dt.strftime("%Y-%m-%d at %H:%M:%S %Z")


def _format_baseline(inst) -> str:
"""Format the baseline time period."""
if inst.baseline is None:
baseline = "off"
else:
baseline = (
f"{round(inst.baseline[0], 3):.3f}{round(inst.baseline[1], 3):.3f} s"
)

return baseline


def _format_time_range(inst) -> str:
"""Format evoked and epochs time range."""
tr = f"{round(inst.tmin, 3):.3f}{round(inst.tmax, 3):.3f} s"
return tr


def _format_projs(info) -> list[str]:
"""Format projectors."""
projs = [f'{p["desc"]} ({"on" if p["active"] else "off"})' for p in info["projs"]]
return projs


@dataclass
class _Channel:
"""A channel in a recording."""

index: int
name: str
name_html: str
type: str
type_pretty: str
status: Literal["good", "bad"]


def _format_channels(info) -> dict[str, dict[Literal["good", "bad"], list[str]]]:
"""Format channel names."""
ch_types_pretty: dict[str, str] = _handle_default("titles")
channels = []

if info.ch_names:
for ch_index, ch_name in enumerate(info.ch_names):
ch_type = channel_type(info, ch_index)
ch_type_pretty = ch_types_pretty.get(ch_type, ch_type.upper())
ch_status = "bad" if ch_name in info["bads"] else "good"
channel = _Channel(
index=ch_index,
name=ch_name,
name_html=ch_name.replace(" ", "&nbsp;"),
type=ch_type,
type_pretty=ch_type_pretty,
status=ch_status,
)
channels.append(channel)

# Extract unique channel types and put them in the desired order.
ch_types = list(set([c.type_pretty for c in channels]))
ch_types = [c for c in ch_types_pretty.values() if c in ch_types]

channels_formatted = {}
for ch_type in ch_types:
goods = [c for c in channels if c.type_pretty == ch_type and c.status == "good"]
bads = [c for c in channels if c.type_pretty == ch_type and c.status == "bad"]
if ch_type not in channels_formatted:
channels_formatted[ch_type] = {"good": [], "bad": []}
channels_formatted[ch_type]["good"] = goods
channels_formatted[ch_type]["bad"] = bads

return channels_formatted


def _has_attr(obj: Any, attr: str) -> bool:
"""Check if an object has an attribute `obj.attr`.
This is needed because on dict-like objects, Jinja2's `obj.attr is defined` would
check for `obj["attr"]`, which may not be what we want.
"""
return hasattr(obj, attr)


@functools.lru_cache(maxsize=2)
def _get_html_templates_env(kind):
# For _html_repr_() and mne.Report
Expand All @@ -19,6 +127,16 @@ def _get_html_templates_env(kind):
)
if kind == "report":
templates_env.filters["zip"] = zip

templates_env.filters["format_number"] = _format_number
templates_env.filters["append_uuid"] = _append_uuid
templates_env.filters["data_type"] = _data_type
templates_env.filters["dt_to_str"] = _dt_to_str
templates_env.filters["format_baseline"] = _format_baseline
templates_env.filters["format_time_range"] = _format_time_range
templates_env.filters["format_projs"] = _format_projs
templates_env.filters["format_channels"] = _format_channels
templates_env.filters["has_attr"] = _has_attr
return templates_env


Expand Down
Loading

0 comments on commit c55c44a

Please sign in to comment.