"""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",
)