172 lines
5.2 KiB
Python
172 lines
5.2 KiB
Python
# Authors: The MNE-Python contributors.
|
||
# License: BSD-3-Clause
|
||
# Copyright the MNE-Python contributors.
|
||
|
||
from __future__ import annotations # only needed for Python ≤ 3.9
|
||
|
||
import datetime
|
||
import functools
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from typing import Any, Literal
|
||
|
||
from .._fiff.pick import channel_type
|
||
from ..defaults import _handle_default
|
||
|
||
_COLLAPSED = False # will override in doc build
|
||
|
||
|
||
def _format_number(value: 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_metadata(inst) -> str:
|
||
"""Format metadata representation."""
|
||
if inst.metadata is None:
|
||
metadata = "No metadata set"
|
||
else:
|
||
metadata = f"{inst.metadata.shape[0]} rows × {inst.metadata.shape[1]} columns"
|
||
|
||
return metadata
|
||
|
||
|
||
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_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_html=ch_name.replace(" ", " "),
|
||
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
|
||
assert kind in ("repr", "report"), kind
|
||
import jinja2
|
||
|
||
templates_env = jinja2.Environment(
|
||
loader=jinja2.PackageLoader(
|
||
package_name="mne.html_templates", package_path=kind
|
||
),
|
||
autoescape=jinja2.select_autoescape(default=True, default_for_string=True),
|
||
)
|
||
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_metadata"] = _format_metadata
|
||
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
|
||
|
||
|
||
def _get_html_template(kind, name):
|
||
return _RenderWrap(
|
||
_get_html_templates_env(kind).get_template(name),
|
||
collapsed=_COLLAPSED,
|
||
)
|
||
|
||
|
||
class _RenderWrap:
|
||
"""Class that allows functools.partial-like wrapping of jinja2 Template.render()."""
|
||
|
||
def __init__(self, template, **kwargs):
|
||
self._template = template
|
||
self._kwargs = kwargs
|
||
|
||
def render(self, *args, **kwargs):
|
||
return self._template.render(*args, **kwargs, **self._kwargs)
|