Source code for eegproc.plotting.plots

"""Plotting for EEG feature tables."""

from __future__ import annotations

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from ..data.schema import parse_feature_column



[docs] def plot_eeg_features( input_data: pd.DataFrame, title: str = "EEG Features", xlabel: str = "Time (s)", seconds: float = 4.0, overlap: float = 0.0, start_row: int = 0, end_row: int | None = None, save_path: str | None = None, max_width: float = 12.0, max_height_per_channel: float = 0.6, max_subplots: int | None = 40, channels: list[str] | None = None, frequency_bands: list[str] | None = None, ) -> tuple[plt.Figure, list[plt.Axes]]: """Plot stacked EEG feature traces per channel and/or band. This function creates a vertically stacked line plot showing channel-level features (e.g., entropy or bandpower) across time windows. Each subplot corresponds to one feature column, with time on the x-axis (derived from the row index multiplied by the window duration ``seconds``). It supports filtering by subsets of channels and/or frequency bands, and automatically arranges figure size and subplot layout. Parameters ---------- input_data : pandas.DataFrame DataFrame containing per-window EEG features (e.g., from :func:`shannons_entropy`, :func:`wavelet_entropy`, etc.). title : str, default="Entropy Plot" Figure title. xlabel : str, default="Time" X-axis label (typically "Time"). seconds : float, default=4.0 Duration represented by each row, in seconds. Used to scale the time axis. start_row : int, default=0 Inclusive start row index to plot. end_row : int, default=1 Exclusive end row index (like ``df.iloc[start:end]``). If ``None``, plots until the end of the DataFrame. save_path : str or None, optional If provided, saves the figure to this path (e.g., ``"entropy_plot.png"``). Otherwise, displays it interactively via ``plt.show()``. max_width : int or None, optional Maximum width (in inches) of the entire figure. If ``None``, width is auto-scaled. max_height_per_channel : int or None, optional Maximum height (in inches) allocated per channel subplot. If ``None``, auto-scaled. channels : list[str] or None, optional Subset of channel names to plot (e.g., ``["AF3", "F7"]``). If ``None``, includes all. frequency_bands : list[str] or None, optional Subset of frequency bands to include when aggregating (e.g., ``["alpha", "theta"]``). If ``None``, includes all bands found in column names. Raises ------ ValueError If ``input_data`` is empty or the specified start/end rows yields an empty range. Notes ----- - Each row of ``input_data`` corresponds to one analysis window (e.g., 4 seconds). - Columns are expected to follow patterns like: ``AF3_wentropy``, ``F7_wentropy``, ``AF3_alpha_entropy`` etc. - The function automatically infers which columns to plot based on substring matches for the requested ``channels`` and ``frequency_bands``. Examples -------- Basic synthetic example: >>> import numpy as np, pandas as pd >>> from eegproc.plotting import plot_eeg_features >>> >>> # Per-window band powers for two channels, as psd_bandpowers emits them >>> t = np.arange(100) >>> df = pd.DataFrame({ ... "AF3_alpha": np.sin(0.1 * t), ... "AF3_betaL": np.cos(0.1 * t), ... "AF3_theta": np.cos(0.2 * t), ... "F7_alpha": np.sin(0.1 * t + 1.0), ... }) >>> >>> # Plot only AF3's alpha and betaL traces, for the first 50 windows >>> plot_eeg_features( ... df, ... title="AF3 Band Power (Synthetic Example)", ... seconds=4, ... start_row=0, ... end_row=50, ... channels=["AF3"], ... frequency_bands=["alpha", "betaL"], ... ) # doctest: +SKIP Note that band filtering only applies to features whose column names carry a band token. ``shannons_entropy``, ``wavelet_entropy`` and ``imf_entropy`` emit one column per channel (``{channel}_entropy``) with no band, so pass ``channels=`` alone for those. """ if input_data is None or len(input_data) == 0: raise ValueError("input_data is empty.") n_rows = len(input_data) start = max(0, int(start_row)) stop = n_rows if end_row is None else min(n_rows, int(end_row)) if start >= stop: raise ValueError( f"empty row range [{start}:{stop}) for a frame with {n_rows} rows." ) window = input_data.iloc[start:stop] parsed = {c: parse_feature_column(str(c)) for c in window.columns} # Match parsed structure, never a substring: `channels=["F3"]` must not also # select AF3, and the 10-20 montage contains both AF3/F3 and AF4/F4. selected = [] for column, info in parsed.items(): if channels is not None and info.channel not in channels: continue if frequency_bands is not None and info.band not in frequency_bands: continue if not pd.api.types.is_numeric_dtype(window[column]): continue selected.append(column) if not selected: _raise_empty_selection(window, parsed, channels, frequency_bands) if max_subplots is not None and len(selected) > max_subplots: raise ValueError( f"{len(selected)} columns matched, which would produce a " f"{max_height_per_channel * len(selected):.0f}-inch figure. Narrow the " "selection with channels=/frequency_bands=, or raise max_subplots." ) # Consecutive windows advance by the hop, not the full window length. if not 0.0 <= overlap < 1.0: raise ValueError(f"overlap must be in [0.0, 1.0), got {overlap}.") hop_seconds = seconds * (1.0 - overlap) x = start * hop_seconds + np.arange(stop - start) * hop_seconds fig, axes = plt.subplots( nrows=len(selected), ncols=1, figsize=(max_width, max_height_per_channel * (len(selected) + 1)), sharex=True, squeeze=False, ) axes = list(axes[:, 0]) try: for ax, column in zip(axes, selected): ax.plot(x, window[column].to_numpy(dtype=float)) ax.set_ylabel(column, rotation=0, ha="right", va="center", labelpad=20) ax.grid(True, linewidth=0.5, alpha=0.5) axes[-1].set_xlabel(xlabel) fig.suptitle(title, fontsize=14) fig.tight_layout(rect=(0, 0, 1, 0.95)) if save_path: fig.savefig(save_path, dpi=150) except Exception: plt.close(fig) raise return fig, axes
def _raise_empty_selection(window, parsed, channels, frequency_bands) -> None: """Explain precisely why nothing matched, instead of failing in matplotlib.""" available_channels = sorted({i.channel for i in parsed.values()}) available_bands = sorted({i.band for i in parsed.values() if i.band}) problems = [] if channels is not None: unknown = [c for c in channels if c not in available_channels] if unknown: problems.append( f"unknown channel(s) {unknown}; available: {available_channels}" ) if frequency_bands is not None: if not available_bands: problems.append( "none of these columns carry a band, so frequency_bands selects " "nothing. shannons_entropy, wavelet_entropy and imf_entropy emit " "one column per channel with no band — filter by channels only" ) else: unknown = [b for b in frequency_bands if b not in available_bands] if unknown: problems.append( f"unknown band(s) {unknown}; available: {available_bands}" ) if not problems: problems.append( "the requested channel and band combination matched no column, or " "every matching column is non-numeric" ) raise ValueError("no columns to plot: " + "; ".join(problems) + ".")