Source code for ecgdatakit.plotting.static

"""Static ECG plots using matplotlib.

All public functions return ``matplotlib.figure.Figure``.
Functions that accept an *ax* parameter can render into an existing axes
for composability; when *ax* is ``None`` a new figure is created.

Requires: ``pip install ecgdatakit[plotting]``
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
from numpy.typing import NDArray

from ecgdatakit.models import ECGRecord, Lead, LeadLike
from ecgdatakit.plotting._core import (
    GRID_12LEAD,
    STANDARD_12LEAD,
    _find_lead,
    _grid_shape,
    _resolve_leads,
    ensure_lead,
    lead_color,
    require_matplotlib,
    time_axis,
)

if TYPE_CHECKING:
    from matplotlib.axes import Axes
    from matplotlib.figure import Figure



def _get_or_create_ax(figsize, ax):
    """Return (fig, ax).  Creates new ones when *ax* is ``None``."""
    mpl = require_matplotlib()
    import matplotlib.pyplot as plt

    if ax is None:
        fig, ax = plt.subplots(figsize=figsize)
    else:
        fig = ax.get_figure()
    return fig, ax


def _ecg_grid(ax, major_x=0.2, major_y=0.5, minor_x=0.04, minor_y=0.1):
    """Draw ECG paper-style grid on *ax*.

    If the data range on either axis would produce more than 500 ticks,
    the locator for that axis falls back to matplotlib's ``AutoLocator``
    to avoid excessive tick generation (e.g. when signals are in raw ADC
    units rather than millivolts).
    """
    ax.set_axisbelow(True)
    ax.grid(True, which="major", color="#ffcccc", linewidth=0.8)
    ax.grid(True, which="minor", color="#ffe6e6", linewidth=0.4)

    from matplotlib.ticker import AutoLocator, AutoMinorLocator, MultipleLocator

    _MAX_TICKS = 500

    x_lo, x_hi = ax.get_xlim()
    y_lo, y_hi = ax.get_ylim()

    if (x_hi - x_lo) / minor_x < _MAX_TICKS:
        ax.xaxis.set_major_locator(MultipleLocator(major_x))
        ax.xaxis.set_minor_locator(MultipleLocator(minor_x))
    else:
        ax.xaxis.set_major_locator(AutoLocator())
        ax.xaxis.set_minor_locator(AutoMinorLocator())

    if (y_hi - y_lo) / minor_y < _MAX_TICKS:
        ax.yaxis.set_major_locator(MultipleLocator(major_y))
        ax.yaxis.set_minor_locator(MultipleLocator(minor_y))
    else:
        ax.yaxis.set_major_locator(AutoLocator())
        ax.yaxis.set_minor_locator(AutoMinorLocator())


def _style_ax(ax):
    """Remove top/right spines and set integer x-ticks."""
    from matplotlib.ticker import AutoMinorLocator, MaxNLocator

    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
    ax.xaxis.set_minor_locator(AutoMinorLocator())


def _x_data(lead, x_axis):
    """Return ``(x_array, xlabel)`` based on *x_axis* mode."""
    if x_axis == "samples":
        return np.arange(1, len(lead.samples) + 1), "Sample"
    return time_axis(lead), "Time (s)"



[docs] def plot_lead( lead: LeadLike, peaks: NDArray[np.intp] | None = None, title: str | None = None, show_grid: bool = False, figsize: tuple[float, float] = (12, 3), ax: Axes | None = None, *, fs: int | None = None, show: bool = True, x_axis: str = "time", ) -> Figure: """Plot a single ECG lead waveform. Parameters ---------- lead : Lead | NDArray[np.float64] ECG lead or raw signal array to plot. peaks : NDArray | None Optional R-peak indices to mark. title : str | None Figure title. Defaults to the lead label. show_grid : bool Draw ECG paper-style grid (default ``True``). figsize : tuple Figure size in inches (default ``(12, 3)``). ax : Axes | None Existing axes to draw on. A new figure is created if ``None``. fs : int | None Sample rate in Hz. Required when *lead* is a numpy array. show : bool Display the plot immediately (default ``True``). Set to ``False`` to return the figure without displaying. x_axis : str ``"time"`` for seconds (default) or ``"samples"`` for sample indices (1, 2, ..., N). """ lead = ensure_lead(lead, fs=fs) own_fig = ax is None fig, ax = _get_or_create_ax(figsize, ax) x, xlabel = _x_data(lead, x_axis) ax.plot(x, lead.samples, color=lead_color(lead.label), linewidth=0.8) if peaks is not None and len(peaks) > 0: ax.plot( x[peaks], lead.samples[peaks], "rv", markersize=6, label="R-peaks", ) ax.set_xlabel(xlabel) ax.set_ylabel(f"Amplitude ({lead.units})" if lead.units else "Amplitude") ax.set_title(title or lead.label) if show_grid: _ecg_grid(ax) ax.set_xlim(x[0], x[-1]) _style_ax(ax) fig.tight_layout() if show and own_fig: import matplotlib.pyplot as plt plt.show() return fig
[docs] def plot_leads( leads: list[Lead] | ECGRecord | NDArray[np.float64] | list[NDArray[np.float64]], peaks_dict: dict[str, NDArray[np.intp]] | None = None, title: str | None = None, show_grid: bool = False, figsize: tuple[float, float | None] = (12, None), share_x: bool = True, *, fs: int | None = None, show: bool = True, x_axis: str = "time", rows: int | None = None, cols: int | None = None, ) -> Figure: """Plot multiple leads in a grid layout (vertical stack by default). Parameters ---------- leads : list[Lead] | ECGRecord | NDArray | list[NDArray] Leads to plot. Also accepts a 2-D numpy array (n_leads × n_samples) or a list of 1-D numpy arrays. peaks_dict : dict | None ``{label: peaks_array}`` for per-lead R-peak markers. title : str | None Overall figure title. show_grid : bool Draw ECG paper-style grid. figsize : tuple Width is fixed; height is auto-calculated (2 in per row) when ``None``. share_x : bool Share the x-axis across all subplots (default ``True``). fs : int | None Sample rate in Hz. Required when *leads* is a numpy array. show : bool Display the plot immediately (default ``True``). x_axis : str ``"time"`` for seconds (default) or ``"samples"`` for sample indices. rows : int | None Number of rows in the subplot grid. Derived from *cols* or defaults to one row per lead when neither is given. cols : int | None Number of columns in the subplot grid (default ``1``). """ require_matplotlib() import matplotlib.pyplot as plt lead_list, _ = _resolve_leads(leads, fs=fs) n = len(lead_list) if n == 0: fig, _ = plt.subplots(figsize=(figsize[0], 3)) return fig r, c = _grid_shape(n, rows, cols) h = figsize[1] if figsize[1] is not None else max(3, 2 * r) fig, axes = plt.subplots(r, c, figsize=(figsize[0], h), sharex=share_x, squeeze=False) for i, ld in enumerate(lead_list): ri, ci = divmod(i, c) ax = axes[ri][ci] x, _ = _x_data(ld, x_axis) ax.plot(x, ld.samples, color=lead_color(ld.label), linewidth=0.8) if peaks_dict and ld.label in peaks_dict: pk = peaks_dict[ld.label] ax.plot(x[pk], ld.samples[pk], "rv", markersize=5) ax.set_ylabel(ld.label, rotation=0, labelpad=30, fontsize=10) ax.yaxis.set_label_position("left") if show_grid: _ecg_grid(ax) ax.set_xlim(x[0], x[-1]) _style_ax(ax) # Hide empty subplots for j in range(n, r * c): ri, ci = divmod(j, c) axes[ri][ci].set_visible(False) # X-axis label on bottom row for ci in range(c): axes[-1][ci].set_xlabel("Sample" if x_axis == "samples" else "Time (s)") if title: fig.suptitle(title, fontsize=13) fig.tight_layout() if show: plt.show() return fig
[docs] def plot_12lead( leads: list[Lead] | ECGRecord | NDArray[np.float64] | list[NDArray[np.float64]], record: ECGRecord | None = None, title: str | None = None, show_grid: bool = False, figsize: tuple[float, float | None] = (12, None), share_x: bool = True, *, fs: int | None = None, show: bool = True, x_axis: str = "time", rows: int | None = None, cols: int | None = None, ) -> Figure: """Plot 12 leads with standard lead names (I, II, III, aVR, …, V6). Unlike :func:`plot_leads`, this function assigns the standard 12-lead names when the input contains unnamed leads (e.g. a raw numpy array). The full signal is plotted without cropping. Parameters ---------- leads : list[Lead] | ECGRecord | NDArray | list[NDArray] Leads (or full record) to plot. Also accepts a 2-D numpy array (n_leads × n_samples) or a list of 1-D numpy arrays. record : ECGRecord | None If provided, a header with patient/device/measurement info is shown. title : str | None Overall figure title. show_grid : bool Draw ECG paper-style grid (default ``True``). figsize : tuple Width is fixed; height is auto-calculated (2 in per row) when ``None``. share_x : bool Share the x-axis across all subplots (default ``True``). fs : int | None Sample rate in Hz. Required when *leads* is a numpy array. show : bool Display the plot immediately (default ``True``). x_axis : str ``"time"`` for seconds (default) or ``"samples"`` for sample indices. rows : int | None Number of rows in the subplot grid. Derived from *cols* or defaults to one row per lead when neither is given. cols : int | None Number of columns in the subplot grid (default ``1``). """ require_matplotlib() import matplotlib.pyplot as plt lead_list, rec = _resolve_leads(leads, fs=fs) if record is not None: rec = record n = len(lead_list) if n == 0: fig, _ = plt.subplots(figsize=(figsize[0], 3)) return fig # Assign standard 12-lead names when leads are unnamed for i, ld in enumerate(lead_list): if i < len(STANDARD_12LEAD) and ld.label.startswith("Lead "): ld.label = STANDARD_12LEAD[i] r, c = _grid_shape(n, rows, cols) h = figsize[1] if figsize[1] is not None else max(3, 2 * r) has_header = rec is not None if has_header: fig, all_axes = plt.subplots( r + 1, c, figsize=(figsize[0], h + 1.5), sharex=False, squeeze=False, gridspec_kw={"height_ratios": [0.4] + [1] * r}, ) # Merge top row into one header axes for ci in range(c): all_axes[0][ci].set_visible(False) ax_hdr = fig.add_subplot(r + 1, 1, 1) ax_hdr.axis("off") _draw_header(ax_hdr, rec) axes = all_axes[1:] else: fig, axes = plt.subplots(r, c, figsize=(figsize[0], h), sharex=share_x, squeeze=False) for i, ld in enumerate(lead_list): ri, ci = divmod(i, c) ax = axes[ri][ci] x, _ = _x_data(ld, x_axis) ax.plot(x, ld.samples, color=lead_color(ld.label), linewidth=0.8) ax.set_title(ld.label, fontsize=9, loc="left", pad=2) if show_grid: _ecg_grid(ax) ax.set_xlim(x[0], x[-1]) _style_ax(ax) # Hide empty subplots for j in range(n, r * c): ri, ci = divmod(j, c) axes[ri][ci].set_visible(False) # X-axis label on bottom row for ci in range(c): axes[-1][ci].set_xlabel("Sample" if x_axis == "samples" else "Time (s)") if title: fig.suptitle(title, fontsize=13) fig.tight_layout() if show: plt.show() return fig
def _draw_header(ax, record: ECGRecord) -> None: """Draw patient/device/measurement info in the header axes.""" lines = [] p = record.patient name = f"{p.first_name} {p.last_name}".strip() if name: lines.append(f"Name: {name}") if p.patient_id: lines.append(f"ID: {p.patient_id}") parts = [] if p.age is not None: parts.append(f"Age: {p.age}") if p.sex: parts.append(f"Sex: {p.sex}") if parts: lines.append(" ".join(parts)) r = record.recording if r.date: lines.append(f"Date: {r.date.strftime('%Y-%m-%d %H:%M')}") if r.acquisition.signal.sampling_rate: lines.append(f"Sample rate: {r.acquisition.signal.sampling_rate} Hz") d = r.device dev_parts = [] if d.manufacturer: dev_parts.append(d.manufacturer) if d.model: dev_parts.append(d.model) if dev_parts: lines.append(f"Device: {' '.join(dev_parts)}") m = record.measurements meas = [] if m.heart_rate is not None: meas.append(f"HR: {m.heart_rate} bpm") if m.pr_interval is not None: meas.append(f"PR: {m.pr_interval} ms") if m.qrs_duration is not None: meas.append(f"QRS: {m.qrs_duration} ms") if m.qt_interval is not None: meas.append(f"QT: {m.qt_interval} ms") if m.qtc_bazett is not None: meas.append(f"QTc: {m.qtc_bazett} ms") if m.qrs_axis is not None: meas.append(f"Axis: {m.qrs_axis}\u00b0") if meas: lines.append(" | ".join(meas)) interp = record.interpretation if interp.statements: stmts = [f"{l} {r}".strip() if r else l for l, r in interp.statements[:3]] lines.append("Interpretation: " + "; ".join(stmts)) text = "\n".join(lines) if lines else "ECG Report" ax.text( 0.02, 0.5, text, transform=ax.transAxes, fontsize=9, verticalalignment="center", fontfamily="monospace", )
[docs] def plot_peaks( lead: LeadLike, peaks: NDArray[np.intp] | None = None, title: str | None = None, figsize: tuple[float, float] = (12, 3), ax: Axes | None = None, *, fs: int | None = None, show: bool = True, x_axis: str = "time", ) -> Figure: """Plot lead with R-peak markers and RR interval annotations. Parameters ---------- lead : Lead | NDArray[np.float64] ECG lead or raw signal array to plot. peaks : NDArray | None R-peak indices. Auto-detected if ``None``. fs : int | None Sample rate in Hz. Required when *lead* is a numpy array. show : bool Display the plot immediately (default ``True``). x_axis : str ``"time"`` for seconds (default) or ``"samples"`` for sample indices. """ from ecgdatakit.processing.peaks import detect_r_peaks lead = ensure_lead(lead, fs=fs) if peaks is None: peaks = detect_r_peaks(lead) own_fig = ax is None fig, ax = _get_or_create_ax(figsize, ax) x, xlabel = _x_data(lead, x_axis) ax.plot(x, lead.samples, color=lead_color(lead.label), linewidth=0.8) if len(peaks) > 0: ax.plot(x[peaks], lead.samples[peaks], "rv", markersize=7, label="R-peaks") for i in range(1, min(len(peaks), 20)): rr_ms = (peaks[i] - peaks[i - 1]) / lead.sampling_rate * 1000 mid_x = (x[peaks[i]] + x[peaks[i - 1]]) / 2 y_pos = max(lead.samples[peaks[i]], lead.samples[peaks[i - 1]]) ax.annotate( f"{rr_ms:.0f}ms", xy=(mid_x, y_pos), fontsize=7, ha="center", va="bottom", color="#666666", ) rr_all = np.diff(peaks).astype(np.float64) / lead.sampling_rate * 1000 if len(rr_all) > 0: hr = 60_000.0 / rr_all.mean() ax.text( 0.98, 0.95, f"HR: {hr:.0f} bpm", transform=ax.transAxes, fontsize=9, ha="right", va="top", bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8), ) ax.set_xlabel(xlabel) ax.set_ylabel(f"Amplitude ({lead.units})" if lead.units else "Amplitude") ax.set_title(title or f"{lead.label} \u2014 R-peaks") _ecg_grid(ax) ax.set_xlim(x[0], x[-1]) _style_ax(ax) fig.tight_layout() if show and own_fig: import matplotlib.pyplot as plt plt.show() return fig
[docs] def plot_beats( lead: LeadLike, beats: list[Lead] | None = None, peaks: NDArray[np.intp] | None = None, overlay: bool = True, figsize: tuple[float, float] = (8, 5), ax: Axes | None = None, *, fs: int | None = None, show: bool = True, ) -> Figure: """Plot segmented heartbeats. Parameters ---------- lead : Lead | NDArray[np.float64] Source ECG lead or raw signal array. beats : list[Lead] | None Pre-segmented beats. Segmented automatically if ``None``. peaks : NDArray | None R-peak indices for segmentation. overlay : bool ``True``: overlay all beats; ``False``: waterfall display. fs : int | None Sample rate in Hz. Required when *lead* is a numpy array. show : bool Display the plot immediately (default ``True``). """ from ecgdatakit.processing.transforms import average_beat, segment_beats lead = ensure_lead(lead, fs=fs) if beats is None: beats = segment_beats(lead, peaks) own_fig = ax is None fig, ax = _get_or_create_ax(figsize, ax) if not beats: ax.text(0.5, 0.5, "No beats detected", transform=ax.transAxes, ha="center") return fig n_samples = len(beats[0].samples) t_ms = np.arange(n_samples, dtype=np.float64) / lead.sampling_rate * 1000 if overlay: for i, beat in enumerate(beats): ax.plot(t_ms, beat.samples, color=lead_color(lead.label), alpha=0.25, linewidth=0.6) avg = average_beat(lead, peaks) ax.plot(t_ms[:len(avg.samples)], avg.samples, color="black", linewidth=2.0, label="Average") ax.legend(fontsize=8) else: offset = 0.0 spacing = np.ptp(beats[0].samples) * 1.3 if len(beats[0].samples) > 0 else 1.0 for i, beat in enumerate(beats): ax.plot(t_ms, beat.samples + offset, color=lead_color(lead.label), linewidth=0.7) offset -= spacing ax.set_xlabel("Time relative to R-peak (ms)") ax.set_ylabel("Amplitude") ax.set_title(f"{lead.label} \u2014 Segmented beats ({len(beats)})") _style_ax(ax) fig.tight_layout() if show and own_fig: import matplotlib.pyplot as plt plt.show() return fig
[docs] def plot_average_beat( lead: LeadLike, peaks: NDArray[np.intp] | None = None, before: float = 0.2, after: float = 0.4, figsize: tuple[float, float] = (6, 4), ax: Axes | None = None, *, fs: int | None = None, show: bool = True, ) -> Figure: """Plot ensemble-averaged beat with \u00b11 SD shading. Parameters ---------- lead : Lead | NDArray[np.float64] Source ECG lead or raw signal array. peaks : NDArray | None R-peak indices. before : float Seconds before R-peak. after : float Seconds after R-peak. fs : int | None Sample rate in Hz. Required when *lead* is a numpy array. show : bool Display the plot immediately (default ``True``). """ from ecgdatakit.processing.transforms import segment_beats lead = ensure_lead(lead, fs=fs) beats = segment_beats(lead, peaks, before, after) own_fig = ax is None fig, ax = _get_or_create_ax(figsize, ax) if not beats: ax.text(0.5, 0.5, "No beats detected", transform=ax.transAxes, ha="center") return fig stacked = np.stack([b.samples for b in beats], axis=0) avg = stacked.mean(axis=0) std = stacked.std(axis=0) n_samples = len(avg) t_ms = np.linspace(-before * 1000, after * 1000, n_samples) ax.fill_between(t_ms, avg - std, avg + std, alpha=0.25, color=lead_color(lead.label)) ax.plot(t_ms, avg, color=lead_color(lead.label), linewidth=2.0) ax.axvline(0, color="red", linestyle="--", linewidth=0.8, alpha=0.6, label="R-peak") ax.set_xlabel("Time (ms)") ax.set_ylabel("Amplitude") ax.set_title(f"{lead.label} \u2014 Average beat (n={len(beats)})") ax.legend(fontsize=8) _style_ax(ax) fig.tight_layout() if show and own_fig: import matplotlib.pyplot as plt plt.show() return fig
[docs] def plot_spectrum( lead: LeadLike, method: str = "welch", figsize: tuple[float, float] = (10, 4), ax: Axes | None = None, *, fs: int | None = None, show: bool = True, ) -> Figure: """Plot power spectral density or FFT magnitude spectrum. Parameters ---------- lead : Lead | NDArray[np.float64] ECG lead or raw signal array to analyse. method : str ``"welch"`` for PSD or ``"fft"`` for magnitude spectrum. fs : int | None Sample rate in Hz. Required when *lead* is a numpy array. show : bool Display the plot immediately (default ``True``). """ lead = ensure_lead(lead, fs=fs) from ecgdatakit.processing.transforms import fft as ecg_fft from ecgdatakit.processing.transforms import power_spectrum own_fig = ax is None fig, ax = _get_or_create_ax(figsize, ax) if method == "welch": freqs, power = power_spectrum(lead) power_db = 10 * np.log10(np.maximum(power, 1e-20)) ax.plot(freqs, power_db, color=lead_color(lead.label), linewidth=0.8) ax.set_ylabel("Power (dB/Hz)") else: freqs, mags = ecg_fft(lead) ax.plot(freqs, mags, color=lead_color(lead.label), linewidth=0.8) ax.set_ylabel("Magnitude") ax.axvspan(0.05, 150, alpha=0.05, color="green", label="ECG band (0.05\u2013150 Hz)") ax.axvspan(0, 0.05, alpha=0.05, color="red", label="Baseline drift") ax.set_xlabel("Frequency (Hz)") ax.set_title(f"{lead.label} \u2014 {'PSD (Welch)' if method == 'welch' else 'FFT Magnitude'}") ax.set_xlim(0, min(lead.sampling_rate / 2, 250)) ax.legend(fontsize=7, loc="upper right") _style_ax(ax) fig.tight_layout() if show and own_fig: import matplotlib.pyplot as plt plt.show() return fig
[docs] def plot_spectrogram( lead: LeadLike, nperseg: int = 256, figsize: tuple[float, float] = (12, 4), ax: Axes | None = None, *, fs: int | None = None, show: bool = True, ) -> Figure: """Plot time-frequency spectrogram (STFT). Parameters ---------- lead : Lead | NDArray[np.float64] ECG lead or raw signal array. nperseg : int Segment length for STFT. fs : int | None Sample rate in Hz. Required when *lead* is a numpy array. show : bool Display the plot immediately (default ``True``). """ from ecgdatakit.processing._core import require_scipy lead = ensure_lead(lead, fs=fs) sig = require_scipy("signal") own_fig = ax is None fig, ax = _get_or_create_ax(figsize, ax) nperseg = min(nperseg, len(lead.samples)) f, t_spec, Sxx = sig.spectrogram( lead.samples, fs=lead.sampling_rate, nperseg=nperseg ) Sxx_db = 10 * np.log10(np.maximum(Sxx, 1e-20)) ax.pcolormesh(t_spec, f, Sxx_db, shading="gouraud", cmap="viridis") ax.set_ylabel("Frequency (Hz)") ax.set_xlabel("Time (s)") ax.set_title(f"{lead.label} \u2014 Spectrogram") ax.set_ylim(0, min(lead.sampling_rate / 2, 150)) _style_ax(ax) fig.tight_layout() if show and own_fig: import matplotlib.pyplot as plt plt.show() return fig
[docs] def plot_rr_tachogram( rr_ms: NDArray[np.float64], figsize: tuple[float, float] = (10, 3), ax: Axes | None = None, *, show: bool = True, ) -> Figure: """Plot RR interval tachogram. Parameters ---------- rr_ms : NDArray RR intervals in milliseconds. show : bool Display the plot immediately (default ``True``). """ own_fig = ax is None fig, ax = _get_or_create_ax(figsize, ax) beats = np.arange(len(rr_ms)) ax.plot(beats, rr_ms, color="#1f77b4", linewidth=0.8, marker=".", markersize=3) mean_rr = rr_ms.mean() std_rr = rr_ms.std() ax.axhline(mean_rr, color="red", linestyle="--", linewidth=0.8, label=f"Mean: {mean_rr:.0f} ms") ax.axhline(mean_rr + std_rr, color="orange", linestyle=":", linewidth=0.6, label=f"\u00b1SD: {std_rr:.0f} ms") ax.axhline(mean_rr - std_rr, color="orange", linestyle=":", linewidth=0.6) ax.set_xlabel("Beat number") ax.set_ylabel("RR interval (ms)") ax.set_title("RR Tachogram") ax.legend(fontsize=8) _style_ax(ax) fig.tight_layout() if show and own_fig: import matplotlib.pyplot as plt plt.show() return fig
[docs] def plot_poincare( rr_ms: NDArray[np.float64], figsize: tuple[float, float] = (6, 6), ax: Axes | None = None, *, show: bool = True, ) -> Figure: """Poincar\u00e9 plot: RR(n) vs RR(n+1) with SD1/SD2 ellipse. Parameters ---------- rr_ms : NDArray RR intervals in milliseconds. show : bool Display the plot immediately (default ``True``). """ from matplotlib.patches import Ellipse own_fig = ax is None fig, ax = _get_or_create_ax(figsize, ax) if len(rr_ms) < 2: ax.text(0.5, 0.5, "Need \u22652 RR intervals", transform=ax.transAxes, ha="center") return fig x = rr_ms[:-1] y = rr_ms[1:] ax.scatter(x, y, s=10, alpha=0.5, color="#1f77b4", edgecolors="none") lo, hi = min(x.min(), y.min()), max(x.max(), y.max()) margin = (hi - lo) * 0.1 ax.plot([lo - margin, hi + margin], [lo - margin, hi + margin], "k--", linewidth=0.5, alpha=0.4) sd1 = float(np.std(y - x, ddof=1) / np.sqrt(2)) sd2 = float(np.std(y + x, ddof=1) / np.sqrt(2)) cx, cy = float(x.mean()), float(y.mean()) ellipse = Ellipse( (cx, cy), width=2 * sd2, height=2 * sd1, angle=45, edgecolor="red", facecolor="none", linewidth=1.5, linestyle="--", label=f"SD1={sd1:.1f}, SD2={sd2:.1f}", ) ax.add_patch(ellipse) ax.set_xlabel("RR(n) (ms)") ax.set_ylabel("RR(n+1) (ms)") ax.set_title("Poincar\u00e9 Plot") ax.set_aspect("equal", adjustable="datalim") ax.legend(fontsize=8) _style_ax(ax) fig.tight_layout() if show and own_fig: import matplotlib.pyplot as plt plt.show() return fig
[docs] def plot_hrv_summary( rr_ms: NDArray[np.float64], figsize: tuple[float, float] = (14, 8), *, show: bool = True, ) -> Figure: """Combined HRV dashboard: tachogram, Poincar\u00e9, frequency bands, metrics. Parameters ---------- rr_ms : NDArray RR intervals in milliseconds. show : bool Display the plot immediately (default ``True``). """ require_matplotlib() import matplotlib.pyplot as plt fig, axes = plt.subplots(2, 2, figsize=figsize) plot_rr_tachogram(rr_ms, ax=axes[0, 0], show=False) plot_poincare(rr_ms, ax=axes[0, 1], show=False) _plot_hrv_frequency(rr_ms, ax=axes[1, 0]) _plot_hrv_table(rr_ms, ax=axes[1, 1]) # Style the HRV frequency axis (sub-functions styled their own axes) axes[1, 0].spines["top"].set_visible(False) axes[1, 0].spines["right"].set_visible(False) fig.suptitle("HRV Summary", fontsize=14, y=1.01) fig.tight_layout() if show: plt.show() return fig
def _plot_hrv_frequency(rr_ms: NDArray[np.float64], ax) -> None: """Plot HRV frequency-domain PSD with shaded VLF/LF/HF bands.""" from ecgdatakit.processing._core import require_scipy if len(rr_ms) < 4: ax.text(0.5, 0.5, "Need \u22654 RR intervals", transform=ax.transAxes, ha="center") return sig = require_scipy("signal") interpolate = require_scipy("interpolate") rr_s = rr_ms / 1000.0 t_rr = np.cumsum(rr_s) - rr_s[0] interp_fs = 4.0 t_uniform = np.arange(0, t_rr[-1], 1.0 / interp_fs) f_interp = interpolate.interp1d(t_rr, rr_ms, kind="cubic", fill_value="extrapolate") rr_uniform = f_interp(t_uniform) rr_uniform = rr_uniform - rr_uniform.mean() nperseg = min(256, len(rr_uniform)) freqs, psd = sig.welch(rr_uniform, fs=interp_fs, nperseg=nperseg) ax.plot(freqs, psd, color="black", linewidth=0.8) ax.fill_between(freqs, psd, where=(freqs < 0.04), alpha=0.3, color="#9467bd", label="VLF") ax.fill_between(freqs, psd, where=(freqs >= 0.04) & (freqs < 0.15), alpha=0.3, color="#2ca02c", label="LF") ax.fill_between(freqs, psd, where=(freqs >= 0.15) & (freqs < 0.40), alpha=0.3, color="#1f77b4", label="HF") ax.set_xlabel("Frequency (Hz)") ax.set_ylabel("PSD (ms\u00b2/Hz)") ax.set_title("HRV Frequency Domain") ax.set_xlim(0, 0.5) ax.legend(fontsize=8) def _plot_hrv_table(rr_ms: NDArray[np.float64], ax) -> None: """Draw a table of time-domain HRV metrics.""" from ecgdatakit.processing.hrv import time_domain metrics = time_domain(rr_ms) rows = [ ("Mean RR", f"{metrics['mean_rr']:.1f} ms"), ("SDNN", f"{metrics['sdnn']:.1f} ms"), ("RMSSD", f"{metrics['rmssd']:.1f} ms"), ("pNN50", f"{metrics['pnn50']:.1f} %"), ("pNN20", f"{metrics['pnn20']:.1f} %"), ("Mean HR", f"{metrics['hr_mean']:.1f} bpm"), ("HR Std", f"{metrics['hr_std']:.1f} bpm"), ] ax.axis("off") table = ax.table( cellText=[[r[0], r[1]] for r in rows], colLabels=["Metric", "Value"], loc="center", cellLoc="center", ) table.auto_set_font_size(False) table.set_fontsize(10) table.scale(1, 1.5) ax.set_title("Time-Domain Metrics", fontsize=11, pad=10)
[docs] def plot_quality( leads: list[Lead] | ECGRecord | NDArray[np.float64] | list[NDArray[np.float64]], figsize: tuple[float, float] = (10, 5), *, fs: int | None = None, show: bool = True, ) -> Figure: """Signal quality dashboard: SQI bar chart per lead. Parameters ---------- leads : list[Lead] | ECGRecord | NDArray | list[NDArray] Leads to assess. Also accepts a 2-D numpy array (n_leads × n_samples) or a list of 1-D numpy arrays. fs : int | None Sample rate in Hz. Required when *leads* is a numpy array. show : bool Display the plot immediately (default ``True``). """ require_matplotlib() import matplotlib.pyplot as plt from ecgdatakit.processing.quality import signal_quality_index, snr_estimate lead_list, _ = _resolve_leads(leads, fs=fs) if not lead_list: fig, _ = plt.subplots(figsize=figsize) return fig labels = [ld.label for ld in lead_list] sqis = [signal_quality_index(ld) for ld in lead_list] snrs = [snr_estimate(ld) for ld in lead_list] colors = [] for s in sqis: if s > 0.8: colors.append("#2ca02c") elif s >= 0.5: colors.append("#ff7f0e") else: colors.append("#d62728") fig, ax = plt.subplots(figsize=figsize) x = np.arange(len(labels)) bars = ax.bar(x, sqis, color=colors, edgecolor="white", linewidth=0.5) for i, (bar, snr) in enumerate(zip(bars, snrs)): ax.text( bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.02, f"SNR: {snr:.0f} dB", ha="center", va="bottom", fontsize=8, color="#555555", ) ax.set_xticks(x) ax.set_xticklabels(labels, fontsize=9) ax.set_ylabel("Signal Quality Index") ax.set_ylim(0, 1.15) ax.set_title("Signal Quality per Lead") from matplotlib.patches import Patch legend_elements = [ Patch(facecolor="#2ca02c", label="Excellent (>0.8)"), Patch(facecolor="#ff7f0e", label="Acceptable (0.5\u20130.8)"), Patch(facecolor="#d62728", label="Unacceptable (<0.5)"), ] ax.legend(handles=legend_elements, fontsize=8, loc="upper right") ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) fig.tight_layout() if show: plt.show() return fig
[docs] def plot_report( record: ECGRecord, figsize: tuple[float, float] = (16, 20), *, show: bool = True, ) -> Figure: """Comprehensive ECG report page. Includes header with patient/device info, 12-lead grid, rhythm strip, and quality indicators. Parameters ---------- record : ECGRecord Full ECG record. show : bool Display the plot immediately (default ``True``). """ require_matplotlib() import matplotlib.pyplot as plt import matplotlib.gridspec as gridspec fig = plt.figure(figsize=figsize) gs = gridspec.GridSpec(6, 4, figure=fig, height_ratios=[0.6, 1, 1, 1, 0.7, 0.8], hspace=0.35, wspace=0.15) ax_hdr = fig.add_subplot(gs[0, :]) ax_hdr.axis("off") _draw_header(ax_hdr, record) leads = record.leads for row_idx, row_labels in enumerate(GRID_12LEAD): for col_idx, lbl in enumerate(row_labels): ax = fig.add_subplot(gs[1 + row_idx, col_idx]) ld = _find_lead(leads, lbl) if ld is not None: t = time_axis(ld) max_s = int(10.0 * ld.sampling_rate) sl = slice(0, min(max_s, len(ld.samples))) ax.plot(t[sl], ld.samples[sl], color=lead_color(lbl), linewidth=0.7) ax.set_xlim(0, 10.0) ax.set_title(lbl, fontsize=9, loc="left", pad=2) _ecg_grid(ax) ax.tick_params(labelsize=6) if row_idx < 2: ax.set_xticklabels([]) _style_ax(ax) ax_rhythm = fig.add_subplot(gs[4, :]) rl = _find_lead(leads, "II") if rl is not None: t = time_axis(rl) ax_rhythm.plot(t, rl.samples, color=lead_color("II"), linewidth=0.7) ax_rhythm.set_xlim(t[0], t[-1]) ax_rhythm.set_title("II rhythm strip", fontsize=9, loc="left", pad=2) _ecg_grid(ax_rhythm) ax_rhythm.set_xlabel("Time (s)", fontsize=8) ax_rhythm.tick_params(labelsize=6) _style_ax(ax_rhythm) ax_qi = fig.add_subplot(gs[5, :2]) ax_qi.axis("off") _draw_quality_summary(ax_qi, leads) ax_interp = fig.add_subplot(gs[5, 2:]) ax_interp.axis("off") _draw_interpretation(ax_interp, record) try: fig.tight_layout() except Exception: pass if show: plt.show() return fig
def _draw_quality_summary(ax, leads: list[Lead]) -> None: """Draw compact quality summary text.""" from ecgdatakit.processing.quality import classify_quality, signal_quality_index lines = ["Signal Quality:"] for ld in leads[:12]: sqi = signal_quality_index(ld) cat = classify_quality(ld) lines.append(f" {ld.label:>5}: {sqi:.2f} ({cat})") ax.text( 0.02, 0.95, "\n".join(lines), transform=ax.transAxes, fontsize=8, verticalalignment="top", fontfamily="monospace", ) def _draw_interpretation(ax, record: ECGRecord) -> None: """Draw interpretation statements.""" interp = record.interpretation lines = ["Interpretation:"] if interp.severity: lines.append(f" Severity: {interp.severity}") if interp.source: lines.append(f" Source: {interp.source}") for left, right in interp.statements: text = f"{left} {right}".strip() if right else left lines.append(f" - {text}") if not interp.statements and not interp.severity: lines.append(" No interpretation available") ax.text( 0.02, 0.95, "\n".join(lines), transform=ax.transAxes, fontsize=8, verticalalignment="top", fontfamily="monospace", )