Chris Parmer — home

Cardiology ECG strip

MIT-BIH record 100 via PhysioNet LightWAVE API

Example from the compendium of canonical charts

Cardiology ECG strip — MIT-BIH record 100 via PhysioNet LightWAVE API

Python Code

"""Cardiology ECG strip — MIT-BIH record 100 via PhysioNet LightWAVE API."""


import numpy as np
import plotly.graph_objects as go


LIGHTWAVE_URL = (
    "https://physionet.org/lightwave/server"
    "?action=fetch&db=mitdb/1.0.0&record=100&signal=0&t0=s0&tf=s360"
)

FS = 360          # sampling rate Hz
MV_OFFSET = 1024  # zero baseline
MV_SCALE  = 200   # ADU per mV
DURATION  = 2.5   # seconds to display
MAJOR_T   = 0.20  # 0.2 s major grid
MINOR_T   = 0.04  # 0.04 s minor grid
MAJOR_MV  = 0.5   # 0.5 mV major grid
MINOR_MV  = 0.1   # 0.1 mV minor grid


def ecg_colors():
    return dict(
        bg="white",
        major="#ffc8c8",
        minor="#ffe0e0",
        signal="#1a1a1a",
    )


def synthetic_ecg(n_samples: int, fs: int = 360) -> np.ndarray:
    """Generate a realistic synthetic Lead-II ECG signal."""
    t = np.arange(n_samples) / fs
    hr_bpm = 72
    rr = 60 / hr_bpm  # R-R interval in seconds

    signal = np.zeros(n_samples)

    def gaussian(t_arr, center, sigma, amp):
        return amp * np.exp(-((t_arr - center) ** 2) / (2 * sigma ** 2))

    # Place beats
    beat_times = np.arange(0.2, t[-1], rr)
    for bt in beat_times:
        # P wave: 80ms wide, 0.15 mV
        signal += gaussian(t, bt - 0.16, 0.025, 0.15)
        # Q wave: small negative
        signal += gaussian(t, bt - 0.02, 0.008, -0.15)
        # R wave: tall, narrow
        signal += gaussian(t, bt, 0.009, 1.2)
        # S wave: small negative
        signal += gaussian(t, bt + 0.025, 0.010, -0.25)
        # ST segment + T wave: 120ms wide, 0.2 mV
        signal += gaussian(t, bt + 0.20, 0.045, 0.25)

    # Add baseline wander and noise
    rng = np.random.default_rng(40)
    signal += 0.04 * np.sin(2 * np.pi * 0.3 * t)          # respiratory sway
    signal += rng.normal(0, 0.015, n_samples)               # measurement noise
    return signal


def generate():
    print("fetching MIT-BIH record 100 via PhysioNet LightWAVE …")

    mV = None
    try:
        data = fetch_json(LIGHTWAVE_URL)
        # LightWAVE returns {"fetch": {"signal": [{"samp": [...], ...}]}}
        sig_list = data.get("fetch", data).get("signal", data.get("samples", []))
        sig0 = sig_list[0]
        raw_delta = sig0.get("samp", sig0.get("data", []))
        raw = np.cumsum(raw_delta)
        mV = (raw - MV_OFFSET) / MV_SCALE
        t  = np.arange(len(mV)) / FS
        print(f"  fetched {len(mV)} samples at {FS} Hz")
    except Exception as e:
        print(f"  fetch failed ({e}), using synthetic ECG")

    n_show = int(DURATION * FS)

    if mV is not None:
        t_show  = t[:n_show]
        mv_show = mV[:n_show]
    else:
        mv_show = synthetic_ecg(n_show, FS)
        t_show  = np.arange(n_show) / FS

    c = ecg_colors()

    fig = go.Figure()

    # ECG grid shapes
    shapes = []
    t_max  = DURATION
    mv_min = float(mv_show.min()) - 0.2
    mv_max = float(mv_show.max()) + 0.2

    # Minor grid lines (time)
    t_minor = np.arange(0, t_max + MINOR_T, MINOR_T)
    for tx in t_minor:
        shapes.append(dict(
            type="line", x0=tx, x1=tx, y0=mv_min, y1=mv_max,
            line=dict(color=c["minor"], width=0.5), layer="below",
        ))
    # Major grid lines (time)
    t_major = np.arange(0, t_max + MAJOR_T, MAJOR_T)
    for tx in t_major:
        shapes.append(dict(
            type="line", x0=tx, x1=tx, y0=mv_min, y1=mv_max,
            line=dict(color=c["major"], width=1.0), layer="below",
        ))
    # Minor grid lines (mV)
    mv_ticks_minor = np.arange(np.floor(mv_min / MINOR_MV) * MINOR_MV,
                               np.ceil(mv_max / MINOR_MV) * MINOR_MV + MINOR_MV,
                               MINOR_MV)
    for my in mv_ticks_minor:
        shapes.append(dict(
            type="line", x0=0, x1=t_max, y0=my, y1=my,
            line=dict(color=c["minor"], width=0.5), layer="below",
        ))
    # Major grid lines (mV)
    mv_ticks_major = np.arange(np.floor(mv_min / MAJOR_MV) * MAJOR_MV,
                               np.ceil(mv_max / MAJOR_MV) * MAJOR_MV + MAJOR_MV,
                               MAJOR_MV)
    for my in mv_ticks_major:
        shapes.append(dict(
            type="line", x0=0, x1=t_max, y0=my, y1=my,
            line=dict(color=c["major"], width=1.0), layer="below",
        ))

    fig.update_layout(shapes=shapes)

    # ECG signal trace
    fig.add_trace(go.Scatter(
        x=t_show,
        y=mv_show,
        mode="lines",
        name="Lead II",
        line=dict(color=c["signal"], width=1.5),
        hovertemplate="t=%{x:.3f}s, %{y:.3f} mV<extra></extra>",
    ))

    fig.update_layout(
        paper_bgcolor=c["bg"],
        plot_bgcolor=c["bg"],
        xaxis=dict(
            title="Time (s)",
            range=[0, DURATION],
            dtick=MAJOR_T,
            tickformat=".1f",
            zeroline=False,
            showgrid=False,
        ),
        yaxis=dict(
            title="Amplitude (mV)",
            zeroline=True,
            zerolinecolor=c["major"],
            showgrid=False,
        ),
        legend=dict(orientation="h", y=1.08),
        margin=dict(t=50, b=50, l=60, r=40),
        height=400,
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly