Make 10 ECGS and diagnosis

Asking for Preferences
Reading File
Finding Sources
Finding Sources
Running Command

ls /home/daytona/workspace/ 2>/dev/null || mkdir -p /home/daytona/workspace/ecg-…

Running Command

mkdir -p /home/daytona/workspace/ecg-cases && ls /home/daytona/workspace/ecg-cas…

Writing File

~/ecg-cases/generate_ecgs.py

#!/usr/bin/env python3
"""
Generate 10 ECG cases with text/grid representation and diagnoses.
Uses matplotlib to render ASCII-style ECG waveforms on a grid background.
"""

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
from matplotlib.gridspec import GridSpec
import os

OUT_DIR = "/home/daytona/workspace/ecg-cases/output"
os.makedirs(OUT_DIR, exist_ok=True)

# ── helpers ──────────────────────────────────────────────────────────────────

def draw_grid(ax, x_max, y_min=-2.5, y_max=2.5):
    """Draw standard ECG grid (large = 5 mm, small = 1 mm)."""
    ax.set_facecolor('#fff8f0')
    # minor 1 mm
    ax.set_xticks(np.arange(0, x_max, 0.04), minor=True)
    ax.set_yticks(np.arange(y_min, y_max, 0.1), minor=True)
    # major 5 mm
    ax.set_xticks(np.arange(0, x_max, 0.2))
    ax.set_yticks(np.arange(y_min, y_max, 0.5))
    ax.grid(which='major', color='#e09090', linewidth=0.8, linestyle='-')
    ax.grid(which='minor', color='#f0c0c0', linewidth=0.3, linestyle='-')
    ax.tick_params(labelbottom=False, labelleft=False, length=0)
    ax.set_xlim(0, x_max)
    ax.set_ylim(y_min, y_max)

def plot_ecg(ax, signal, fs=250):
    """Plot a single ECG lead."""
    t = np.arange(len(signal)) / fs
    ax.plot(t, signal, color='black', linewidth=1.0)

# ── waveform generators ───────────────────────────────────────────────────────

def gaussian(x, mu, sigma, amp):
    return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)

def normal_beat(t_offset=0, hr=72, baseline=0):
    """Return one normal sinus beat array at 250 Hz."""
    fs = 250
    rr = 60 / hr
    n = int(rr * fs)
    t = np.linspace(0, rr, n)
    sig = np.zeros(n)
    # P wave
    sig += gaussian(t, 0.10, 0.025, 0.15)
    # QRS
    sig += gaussian(t, 0.22, 0.008, -0.1)   # Q
    sig += gaussian(t, 0.24, 0.010, 1.5)    # R
    sig += gaussian(t, 0.26, 0.008, -0.15)  # S
    # T wave
    sig += gaussian(t, 0.38, 0.040, 0.35)
    return sig + baseline

def make_repeating(beat_fn, n_beats, **kwargs):
    return np.concatenate([beat_fn(**kwargs) for _ in range(n_beats)])

# ── 10 ECG cases ─────────────────────────────────────────────────────────────

CASES = []

# ─── Case 1: Normal Sinus Rhythm ─────────────────────────────────────────────
def case1():
    fs = 250
    beats = []
    for _ in range(6):
        beats.append(normal_beat(hr=72))
    return np.concatenate(beats)

CASES.append({
    "title": "Case 1 — 45-year-old male, routine checkup",
    "vitals": "HR 72 bpm | BP 120/80 | No symptoms",
    "signal": case1(),
    "lead": "Lead II",
    "diagnosis": "NORMAL SINUS RHYTHM",
    "findings": [
        "Rate: 72 bpm (regular)",
        "P waves: upright, precede every QRS, uniform morphology",
        "PR interval: 160 ms (normal 120–200 ms)",
        "QRS duration: 80 ms (narrow, normal <120 ms)",
        "QT interval: 380 ms (normal)",
        "ST segment: isoelectric",
        "T waves: upright, concordant",
    ],
    "teaching": (
        "Normal sinus rhythm requires: rate 60–100 bpm, P before every QRS, "
        "regular P-P and R-R intervals, PR 120–200 ms, QRS <120 ms. "
        "All criteria are met here."
    ),
})

# ─── Case 2: Atrial Fibrillation ─────────────────────────────────────────────
def case2():
    fs = 250
    rng = np.random.default_rng(42)
    sig = []
    t = 0
    while t < 2500:
        rr_var = int(rng.integers(150, 350))   # irregularly irregular
        beat_t = np.linspace(0, rr_var / fs, rr_var)
        beat = np.zeros(rr_var)
        # fibrillatory baseline
        for f in [4, 6, 8, 10, 12]:
            beat += rng.uniform(0.02, 0.06) * np.sin(
                2 * np.pi * f * beat_t + rng.uniform(0, 2 * np.pi))
        # QRS (no P wave)
        mid = rr_var // 2
        beat += gaussian(beat_t, beat_t[mid - 8], 0.010, -0.08)
        beat += gaussian(beat_t, beat_t[mid],     0.010, 1.4)
        beat += gaussian(beat_t, beat_t[mid + 8], 0.010, -0.12)
        # T wave
        beat += gaussian(beat_t, beat_t[mid] + 0.14, 0.040, 0.28)
        sig.append(beat)
        t += rr_var
    return np.concatenate(sig)[:2500]

CASES.append({
    "title": "Case 2 — 68-year-old male, palpitations × 3 hours",
    "vitals": "HR ~110 bpm (irregular) | BP 135/88 | SpO₂ 97%",
    "signal": case2(),
    "lead": "Lead II",
    "diagnosis": "ATRIAL FIBRILLATION (uncontrolled ventricular rate)",
    "findings": [
        "Rate: 100–130 bpm (irregularly irregular)",
        "No discernible P waves — replaced by fibrillatory (f) waves",
        "R-R intervals: variable (hallmark of AF)",
        "QRS: narrow (<120 ms) — normal ventricular conduction",
        "ST segment: no significant deviation",
    ],
    "teaching": (
        "AF key criteria: absent P waves, irregularly irregular rhythm, "
        "narrow QRS (unless aberrant conduction/WPW). "
        "Rate control target <110 bpm at rest. Always assess CHA₂DS₂-VASc for anticoagulation."
    ),
})

# ─── Case 3: ST-Elevation MI (Inferior) ──────────────────────────────────────
def case3():
    fs = 250
    beats = []
    for _ in range(5):
        n = int(250 * (60 / 88))
        t = np.linspace(0, 60/88, n)
        sig = np.zeros(n)
        sig += gaussian(t, 0.10, 0.025, 0.14)   # P
        sig += gaussian(t, 0.22, 0.008, -0.08)  # Q (deep in inferior)
        sig += gaussian(t, 0.24, 0.010, 1.3)    # R
        sig += gaussian(t, 0.26, 0.008, -0.1)   # S
        # ST elevation 2 mm + hyperacute T
        sig += 0.2 * (t > 0.27) * (t < 0.50) * np.exp(-8*(t - 0.37))
        sig += gaussian(t, 0.44, 0.045, 0.65)   # hyperacute T
        beats.append(sig)
    return np.concatenate(beats)

CASES.append({
    "title": "Case 3 — 58-year-old male, crushing chest pain × 45 min",
    "vitals": "HR 88 bpm | BP 100/65 | Diaphoretic",
    "signal": case3(),
    "lead": "Lead II (inferior leads II, III, aVF affected)",
    "diagnosis": "INFERIOR STEMI — Right Coronary Artery (RCA) occlusion",
    "findings": [
        "ST elevation ≥2 mm in II, III, aVF",
        "Reciprocal ST depression in I, aVL",
        "Hyperacute (tall, peaked) T waves",
        "Q waves beginning to form (early infarct)",
        "HR 88 bpm, sinus rhythm",
    ],
    "teaching": (
        "Inferior STEMI: ST↑ in II, III, aVF with reciprocal changes in I/aVL. "
        "RCA supplies inferior wall in 85% of patients. "
        "Always check right-sided leads (V3R, V4R) for RV infarct. "
        "Door-to-balloon time <90 min. Avoid nitrates if RV involvement."
    ),
})

# ─── Case 4: Complete Heart Block (3rd Degree AV Block) ──────────────────────
def case4():
    fs = 250
    duration = 2500
    sig = np.zeros(duration)
    t = np.arange(duration) / fs
    # P waves at 75 bpm (atrial rate) — marching through
    p_interval = int(fs * 60 / 75)
    for p in range(0, duration, p_interval):
        for i, pt in enumerate(t):
            idx = int(p + i)
            if idx < duration:
                sig[idx] += gaussian(np.array([t[min(idx, duration-1)]]),
                                     t[p] + 0.10, 0.025, 0.14)[0]
    # Escape QRS at 38 bpm (junctional escape) — wide
    qrs_interval = int(fs * 60 / 38)
    for q in range(int(qrs_interval * 0.3), duration, qrs_interval):
        bt = np.linspace(0, 0.6, int(0.6 * fs))
        beat = np.zeros(int(0.6 * fs))
        beat += gaussian(bt, 0.20, 0.025, -0.15)
        beat += gaussian(bt, 0.25, 0.018, 1.2)
        beat += gaussian(bt, 0.30, 0.018, -0.25)
        beat += gaussian(bt, 0.45, 0.060, 0.4)  # wide T
        end = min(q + len(beat), duration)
        sig[q:end] += beat[:end - q]
    return sig

CASES.append({
    "title": "Case 4 — 72-year-old female, syncope, near-fall",
    "vitals": "HR 38 bpm | BP 88/55 | Pale",
    "signal": case4(),
    "lead": "Lead II",
    "diagnosis": "COMPLETE (3rd DEGREE) AV BLOCK with junctional escape",
    "findings": [
        "Atrial rate: 75 bpm (regular P waves)",
        "Ventricular rate: 38 bpm (regular but independent)",
        "AV dissociation: P waves and QRS march independently",
        "QRS: wide (~140 ms) — ventricular escape rhythm",
        "No relationship between P waves and QRS (PR interval varies)",
    ],
    "teaching": (
        "3rd degree AV block: complete failure of AV conduction. "
        "Atria and ventricles beat independently. "
        "Escape rhythm source determines QRS width: junctional (narrow) vs ventricular (wide). "
        "Immediate transcutaneous pacing, then permanent pacemaker. "
        "Causes: inferior MI (RCA), Lyme disease, medication toxicity (digoxin, beta-blockers)."
    ),
})

# ─── Case 5: Ventricular Tachycardia ─────────────────────────────────────────
def case5():
    fs = 250
    duration = 2500
    t = np.arange(duration) / fs
    sig = np.zeros(duration)
    vt_rate = 160
    vt_interval = fs * 60 // vt_rate
    for q in range(0, duration, vt_interval):
        bt = np.linspace(0, 0.4, int(0.4 * fs))
        beat = np.zeros(int(0.4 * fs))
        beat += gaussian(bt, 0.08, 0.030, -0.3)   # broad initial deflection
        beat += gaussian(bt, 0.16, 0.030, 1.6)    # tall R
        beat += gaussian(bt, 0.24, 0.030, -0.5)   # broad S
        beat += gaussian(bt, 0.33, 0.040, -0.4)   # discordant T
        end = min(q + len(beat), duration)
        sig[q:end] += beat[:end - q]
    return sig

CASES.append({
    "title": "Case 5 — 55-year-old male, sudden palpitations, presyncope",
    "vitals": "HR 160 bpm (regular) | BP 85/50 | Diaphoretic",
    "signal": case5(),
    "lead": "Lead II",
    "diagnosis": "VENTRICULAR TACHYCARDIA (monomorphic)",
    "findings": [
        "Rate: 160 bpm, regular",
        "QRS: wide (>140 ms), bizarre morphology",
        "No visible P waves (buried or absent)",
        "AV dissociation (if P waves visible, march independently)",
        "Concordance across precordial leads (positive)",
        "Northwest axis deviation",
    ],
    "teaching": (
        "VT criteria (Brugada algorithm): absence of RS in precordial leads, "
        "RS interval >100 ms, AV dissociation, morphology criteria (LBBB/RBBB pattern). "
        "Wide complex tachycardia = VT until proven otherwise. "
        "Hemodynamically unstable: immediate synchronized cardioversion. "
        "Stable: amiodarone 150 mg IV. Check K⁺, Mg²⁺, ischemia."
    ),
})

# ─── Case 6: Wolff-Parkinson-White (WPW) ─────────────────────────────────────
def case6():
    fs = 250
    beats = []
    for _ in range(5):
        n = int(fs * 60 / 78)
        t = np.linspace(0, 60/78, n)
        sig = np.zeros(n)
        sig += gaussian(t, 0.10, 0.022, 0.14)   # P wave
        # Delta wave (slurred upstroke) + short PR
        delta_start = 0.17
        sig += 0.3 * np.clip((t - delta_start) / 0.025, 0, 1) * \
               np.exp(-4 * np.maximum(t - delta_start - 0.030, 0))
        sig += gaussian(t, 0.215, 0.010, 1.3)   # R
        sig += gaussian(t, 0.240, 0.010, -0.25) # S
        sig += gaussian(t, 0.37,  0.040, -0.30) # discordant T (negative)
        beats.append(sig)
    return np.concatenate(beats)

CASES.append({
    "title": "Case 6 — 28-year-old female, episodic palpitations since teens",
    "vitals": "HR 78 bpm | BP 118/76 | Currently asymptomatic",
    "signal": case6(),
    "lead": "Lead II",
    "diagnosis": "WOLFF-PARKINSON-WHITE SYNDROME (WPW)",
    "findings": [
        "Short PR interval: <120 ms",
        "Delta wave: slurred upstroke of QRS (pre-excitation)",
        "Wide QRS: >120 ms (from delta wave contribution)",
        "ST-T changes: secondary, discordant to delta/QRS",
        "Tachyarrhythmia history: AVRT (SVT via accessory pathway)",
    ],
    "teaching": (
        "WPW triad: short PR + delta wave + wide QRS. "
        "Accessory pathway (Bundle of Kent) bypasses AV node → ventricular pre-excitation. "
        "Risk: AF with rapid ventricular response (can degenerate to VF — AVOID AV nodal blockers). "
        "Definitive treatment: radiofrequency catheter ablation."
    ),
})

# ─── Case 7: Hyperkalemia ────────────────────────────────────────────────────
def case7():
    fs = 250
    beats = []
    for _ in range(5):
        n = int(fs * 60 / 60)
        t = np.linspace(0, 1.0, n)
        sig = np.zeros(n)
        # Flattened P wave
        sig += gaussian(t, 0.10, 0.040, 0.06)
        # QRS widened
        sig += gaussian(t, 0.22, 0.012, -0.08)
        sig += gaussian(t, 0.25, 0.018, 1.2)
        sig += gaussian(t, 0.28, 0.012, -0.15)
        # TALL peaked T wave (tent-shaped)
        sig += gaussian(t, 0.42, 0.025, 0.90)
        beats.append(sig)
    return np.concatenate(beats)

CASES.append({
    "title": "Case 7 — 65-year-old male, CKD stage 4, weakness",
    "vitals": "HR 60 bpm | BP 155/95 | K⁺ = 7.1 mEq/L",
    "signal": case7(),
    "lead": "Lead II / V5",
    "diagnosis": "HYPERKALEMIA (severe, K⁺ ≈ 7.1 mEq/L)",
    "findings": [
        "Tall, narrow, peaked (tent-shaped) T waves — earliest sign",
        "Flattened/absent P waves (severe hyperkalemia)",
        "PR interval prolongation",
        "QRS widening (>100 ms) — sine-wave pattern if worsens",
        "Sinus rate 60 bpm",
    ],
    "teaching": (
        "Hyperkalemia ECG progression: peaked T → flat P → wide QRS → sine wave → VF. "
        "Immediate management: "
        "(1) Ca²⁺ gluconate 1 g IV (membrane stabilization within 1–3 min), "
        "(2) Insulin 10 U + dextrose (shift K⁺ into cells), "
        "(3) Sodium bicarbonate (if acidosis), "
        "(4) Kayexalate/patiromer/SZC (excretion), "
        "(5) Dialysis if refractory."
    ),
})

# ─── Case 8: Left Bundle Branch Block (LBBB) ─────────────────────────────────
def case8():
    fs = 250
    beats = []
    for _ in range(5):
        n = int(fs * 60 / 70)
        t = np.linspace(0, 60/70, n)
        sig = np.zeros(n)
        sig += gaussian(t, 0.10, 0.025, 0.14)    # P
        # Wide notched R in V5/V6 pattern (LBBB)
        sig += gaussian(t, 0.21, 0.010, -0.05)   # no Q (absent septal Q)
        sig += gaussian(t, 0.26, 0.022, 0.9)     # broad R peak 1
        sig += gaussian(t, 0.31, 0.022, 1.1)     # broad R peak 2 (notch)
        sig += gaussian(t, 0.36, 0.015, -0.3)    # S descent
        # Discordant T wave (opposite to QRS)
        sig += gaussian(t, 0.52, 0.045, -0.45)
        beats.append(sig)
    return np.concatenate(beats)

CASES.append({
    "title": "Case 8 — 62-year-old female, dyspnea on exertion",
    "vitals": "HR 70 bpm | BP 142/88 | History of HTN, no chest pain",
    "signal": case8(),
    "lead": "Lead V5 (lateral)",
    "diagnosis": "LEFT BUNDLE BRANCH BLOCK (LBBB) — New onset",
    "findings": [
        "QRS duration: ≥120 ms (broad)",
        "Broad, notched (M-shaped) R wave in lateral leads (I, aVL, V5–V6)",
        "Absent septal Q waves in I, V5, V6",
        "ST and T wave changes: discordant (opposite to QRS direction)",
        "Left axis deviation",
    ],
    "teaching": (
        "LBBB criteria (Sgarbossa if STEMI suspected): QRS ≥120 ms, broad notched R in V5/V6, "
        "no septal Q in I/V5/V6, discordant ST-T. "
        "NEW LBBB = STEMI equivalent until proven otherwise (activates cath lab). "
        "Old LBBB: look for concordant ST elevation ≥1 mm or "
        "ST elevation ≥5 mm discordant (Sgarbossa criteria)."
    ),
})

# ─── Case 9: Pulmonary Embolism ───────────────────────────────────────────────
def case9():
    """S1Q3T3 pattern + sinus tachycardia + RBBB features."""
    fs = 250
    beats = []
    for _ in range(6):
        n = int(fs * 60 / 105)
        t = np.linspace(0, 60/105, n)
        sig = np.zeros(n)
        sig += gaussian(t, 0.08, 0.020, 0.13)   # P
        # S1: deep S in lead I (shown here)
        sig += gaussian(t, 0.19, 0.009, -0.06)  # Q
        sig += gaussian(t, 0.21, 0.010, 1.1)    # R
        sig += gaussian(t, 0.24, 0.012, -0.45)  # deep S (S1 pattern)
        # RBBB pattern: rSR' (terminal R')
        sig += gaussian(t, 0.29, 0.012, 0.35)   # R' (terminal conduction delay)
        # T wave inversion (Q3T3 — lead III equivalent)
        sig += gaussian(t, 0.40, 0.035, -0.28)  # T inversion
        beats.append(sig)
    return np.concatenate(beats)

CASES.append({
    "title": "Case 9 — 38-year-old female, acute dyspnea, pleuritic chest pain",
    "vitals": "HR 105 bpm | BP 105/70 | SpO₂ 91% | Recent long-haul flight",
    "signal": case9(),
    "lead": "Lead I / III composite (S₁Q₃T₃ pattern shown)",
    "diagnosis": "PULMONARY EMBOLISM (massive/submassive)",
    "findings": [
        "Sinus tachycardia: 105 bpm",
        "S1Q3T3 pattern: deep S in I, Q wave + T inversion in III",
        "Incomplete RBBB (rSR' in V1, wide S in V6)",
        "T wave inversions V1–V4 (RV strain)",
        "Right axis deviation",
    ],
    "teaching": (
        "PE ECG findings (none are specific or sensitive): sinus tachycardia (most common), "
        "S1Q3T3, RBBB, T inversions V1–V4, right axis deviation. "
        "ECG normal in ~40% of PE. Diagnose with CT-PA (preferred) or V/Q scan. "
        "Massive PE (hemodynamic instability): systemic thrombolysis or embolectomy. "
        "Submassive: anticoagulation ± catheter-directed thrombolysis."
    ),
})

# ─── Case 10: Torsades de Pointes (TdP) ──────────────────────────────────────
def case10():
    fs = 250
    rng = np.random.default_rng(7)
    duration = 2500
    sig = np.zeros(duration)
    # Preceding long QT beat
    for q_start in [0, 280]:
        bt = np.linspace(0, 1.1, int(1.1 * fs))
        beat = np.zeros(int(1.1 * fs))
        beat += gaussian(bt, 0.10, 0.022, 0.13)
        beat += gaussian(bt, 0.22, 0.008, -0.07)
        beat += gaussian(bt, 0.24, 0.010, 1.2)
        beat += gaussian(bt, 0.26, 0.008, -0.12)
        beat += gaussian(bt, 0.65, 0.070, 0.40)  # very late T = long QT
        end = min(q_start + len(beat), duration)
        sig[q_start:end] += beat[:end - q_start]
    # TdP burst: twisting QRS around baseline
    burst_start = 650
    burst_rate = 220
    burst_interval = int(fs * 60 / burst_rate)
    amplitude_env = np.sin(np.linspace(0, 3 * np.pi, (duration - burst_start)))
    t_burst = np.arange(duration - burst_start) / fs
    for i, q in enumerate(range(burst_start, duration, burst_interval)):
        amp = 0.8 * abs(np.sin(i * 0.35))
        sign = 1 if np.sin(i * 0.35) >= 0 else -1
        bt = np.linspace(0, burst_interval / fs, burst_interval)
        beat = sign * amp * (
            gaussian(bt, bt[-1]*0.3, 0.010, -0.15) +
            gaussian(bt, bt[-1]*0.5, 0.015, 1.0) +
            gaussian(bt, bt[-1]*0.7, 0.010, -0.20)
        )
        end = min(q + burst_interval, duration)
        sig[q:end] += beat[:end - q]
    return sig

CASES.append({
    "title": "Case 10 — 54-year-old female, recurrent syncope on amiodarone",
    "vitals": "HR variable | BP unobtainable during episode | QTc 560 ms baseline",
    "signal": case10(),
    "lead": "Lead II — rhythm strip",
    "diagnosis": "TORSADES DE POINTES (TdP) — Drug-induced Long QT",
    "findings": [
        "Preceding prolonged QT interval (QTc ≥500 ms)",
        "Classic twisting of QRS axis around isoelectric baseline",
        "Rate 200–250 bpm during TdP",
        "Polymorphic VT morphology — QRS amplitude waxes and wanes",
        "Initiated by short-long-short R-R sequence (pause-dependent)",
    ],
    "teaching": (
        "TdP = polymorphic VT in setting of prolonged QT. "
        "Causes: QT-prolonging drugs (amiodarone, sotalol, haloperidol, macrolides, fluoroquinolones), "
        "hypokalemia, hypomagnesemia, congenital LQTS. "
        "Treatment: Mg²⁺ sulfate 2 g IV bolus (drug of choice), "
        "correct electrolytes, overdrive pacing, isoproterenol (increase HR to shorten QT). "
        "Stop offending drug. AVOID amiodarone and other QT-prolongers."
    ),
})

# ── render ────────────────────────────────────────────────────────────────────

def render_case(case, idx, out_dir):
    fig = plt.figure(figsize=(16, 9), facecolor='white')
    fig.patch.set_facecolor('white')

    # Layout
    gs = GridSpec(3, 1, figure=fig,
                  height_ratios=[0.28, 1.5, 0.55],
                  hspace=0.35)

    # ── Header ──
    ax_head = fig.add_subplot(gs[0])
    ax_head.axis('off')
    ax_head.text(0.01, 0.85, case["title"],
                 fontsize=14, fontweight='bold', color='#1a1a2e', va='top')
    ax_head.text(0.01, 0.45, f"Vitals: {case['vitals']}",
                 fontsize=10, color='#333333', va='top', style='italic')
    ax_head.text(0.01, 0.10, f"Lead shown: {case['lead']}",
                 fontsize=9, color='#555555', va='top')
    # Red diagnosis box
    ax_head.text(0.99, 0.50, f"⚡ {case['diagnosis']}",
                 fontsize=13, fontweight='bold', color='white',
                 va='center', ha='right',
                 bbox=dict(facecolor='#c0392b', edgecolor='none',
                           boxstyle='round,pad=0.4', alpha=0.92))

    # ── ECG Trace ──
    ax_ecg = fig.add_subplot(gs[1])
    fs = 250
    sig = case["signal"]
    draw_grid(ax_ecg, len(sig) / fs)
    plot_ecg(ax_ecg, sig, fs)
    ax_ecg.set_ylabel("Amplitude (mV)", fontsize=8, color='#666666')
    # 1 mV calibration marker
    ax_ecg.annotate('', xy=(0.08, 1.0), xytext=(0.08, 0.0),
                    arrowprops=dict(arrowstyle='-', color='blue', lw=1.5))
    ax_ecg.text(0.06, 0.5, '1 mV', fontsize=7, color='blue', ha='right', va='center')
    ax_ecg.set_title("ECG Rhythm Strip  |  25 mm/s  |  10 mm/mV",
                     fontsize=9, color='#444444', loc='right', pad=3)

    # ── Findings + Teaching ──
    ax_info = fig.add_subplot(gs[2])
    ax_info.axis('off')

    # Findings column
    findings_text = "KEY FINDINGS:\n" + "\n".join(f"  • {f}" for f in case["findings"])
    ax_info.text(0.01, 0.98, findings_text,
                 fontsize=8.5, va='top', ha='left', color='#1a1a2e',
                 fontfamily='monospace',
                 bbox=dict(facecolor='#eaf4fb', edgecolor='#aed6f1',
                           boxstyle='round,pad=0.5', alpha=0.9))

    # Teaching point
    ax_info.text(0.62, 0.98,
                 "TEACHING POINT:\n" + case["teaching"],
                 fontsize=8, va='top', ha='left', color='#1a1a2e',
                 wrap=True,
                 bbox=dict(facecolor='#fef9e7', edgecolor='#f9e79f',
                           boxstyle='round,pad=0.5', alpha=0.9))

    # Page number
    ax_info.text(0.99, 0.02, f"Case {idx}/10", fontsize=8,
                 ha='right', va='bottom', color='#999999')

    fname = os.path.join(out_dir, f"ecg_case_{idx:02d}.png")
    fig.savefig(fname, dpi=150, bbox_inches='tight', facecolor='white')
    plt.close(fig)
    print(f"  Saved: {fname}")
    return fname

# ── main ──────────────────────────────────────────────────────────────────────

print("Generating 10 ECG cases...")
file_list = []
for i, case in enumerate(CASES, start=1):
    f = render_case(case, i, OUT_DIR)
    file_list.append(f)

print(f"\nDone! {len(file_list)} ECG images saved to {OUT_DIR}")

# ── Combine into a single PDF ─────────────────────────────────────────────────
try:
    from reportlab.lib.pagesizes import A4, landscape
    from reportlab.platypus import SimpleDocTemplate, Image as RLImage, Spacer
    from reportlab.lib.units import inch

    pdf_path = "/home/daytona/workspace/ecg-cases/output/10_ECG_Cases.pdf"
    doc = SimpleDocTemplate(pdf_path, pagesize=landscape(A4),
                            leftMargin=0.3*inch, rightMargin=0.3*inch,
                            topMargin=0.3*inch, bottomMargin=0.3*inch)
    story = []
    for f in file_list:
        img = RLImage(f, width=10.5*inch, height=5.9*inch)
        story.append(img)
    doc.build(story)
    print(f"PDF saved: {pdf_path}")
except Exception as e:
    print(f"PDF generation note: {e}")
Running Command

cd /home/daytona/workspace/ecg-cases && python3 generate_ecgs.py 2>&1

Reading File
All 10 ECGs are ready. Here's a summary of every case:

10 ECG Cases — Resident/Intern Level

Each case includes: clinical vignette, ECG rhythm strip on standard grid (25 mm/s, 10 mm/mV), key findings, diagnosis, and teaching points.
#DiagnosisKey Feature
1Normal Sinus RhythmRate 72, upright P→QRS, PR 160 ms, QRS 80 ms
2Atrial FibrillationNo P waves, irregularly irregular RR, narrow QRS
3Inferior STEMIST↑ in II/III/aVF, hyperacute T waves, reciprocal depression I/aVL
4Complete (3rd Degree) AV BlockAV dissociation, P-rate 75 independent of escape rate 38, wide QRS
5Monomorphic Ventricular TachycardiaRate 160, wide bizarre QRS >140 ms, AV dissociation
6WPW SyndromeShort PR <120 ms, delta wave, wide QRS, discordant T waves
7Severe Hyperkalemia (K⁺ 7.1)Tall peaked tent-shaped T waves, flat P waves, QRS widening
8Left Bundle Branch Block (New)QRS ≥120 ms, notched M-shaped R in V5/V6, absent septal Q, discordant ST-T
9Pulmonary EmbolismSinus tachycardia, S₁Q₃T₃, incomplete RBBB, T inversions V1–V4
10Torsades de PointesLong QTc 560 ms preceding, twisting polymorphic VT 200–250 bpm

Downloads

All 10 cases in one PDF:

10 ECG Cases

PDF Document · PDF

Individual PNG files:

ecg case 01

Image · PNG

ecg case 02

Image · PNG

ecg case 03

Image · PNG

ecg case 04

Image · PNG

ecg case 05

Image · PNG

ecg case 06

Image · PNG

ecg case 07

Image · PNG

ecg case 08

Image · PNG

ecg case 09

Image · PNG

ecg case 10

Image · PNG

Each image has the standard pink ECG grid, the rhythm strip, key findings (blue box), and teaching point (yellow box). The waveforms are programmatically synthesized to model each pathology's characteristic morphology.
This is a shared conversation. Sign in to Orris to start your own chat.