{"spec_id":"ecg-twelve-lead","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\necg-twelve-lead: ECG/EKG 12-Lead Waveform Display\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 93/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_line,\n    geom_rect,\n    geom_segment,\n    geom_text,\n    ggplot,\n    ggsize,\n    labs,\n    layer_tooltips,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n)\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\n\n# Theme-adaptive chrome (Imprint palette + theme tokens)\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# ECG-paper grid — classic red ruling, kept as theme-adaptive chrome so the\n# iconic medical look survives on both the warm-cream and warm-black surfaces.\nPAPER_TINT = \"#FBF0EB\" if THEME == \"light\" else \"#241C19\"\nGRID_MINOR = \"#EBD2CB\" if THEME == \"light\" else \"#3A2A26\"\nGRID_MAJOR = \"#D49B8E\" if THEME == \"light\" else \"#5E3E35\"\nGRID_COL = \"#BC7563\" if THEME == \"light\" else \"#7C4E43\"\n\n# ECG trace = Imprint brand green (position 1) — identical in both themes, only\n# chrome flips. Green tracings are a real patient-monitor convention.\nTRACE = \"#009E73\"\n\n# Data - Synthetic ECG (Normal Sinus Rhythm)\nnp.random.seed(42)\n\nfs = 500\nstrip_duration = 2.5\nn_strip = int(fs * strip_duration)\nt_strip = np.linspace(0, strip_duration, n_strip, endpoint=False)\n\nhr = 72\nbeat_period = 60.0 / hr\nn_beat = int(fs * beat_period)\nt_beat = np.linspace(0, beat_period, n_beat, endpoint=False)\n\n# ECG waveform components (Gaussian model for P-QRS-T complex)\np_comp = np.exp(-((t_beat - 0.10) ** 2) / (2 * 0.018**2))\nq_comp = np.exp(-((t_beat - 0.175) ** 2) / (2 * 0.004**2))\nr_comp = np.exp(-((t_beat - 0.19) ** 2) / (2 * 0.008**2))\ns_comp = np.exp(-((t_beat - 0.205) ** 2) / (2 * 0.005**2))\ntw_comp = np.exp(-((t_beat - 0.34) ** 2) / (2 * 0.028**2))\n\n# Per-lead amplitudes [P, Q, R, S, T] in mV\nlead_weights = {\n    \"I\": [0.15, -0.07, 0.95, -0.10, 0.22],\n    \"II\": [0.20, -0.10, 1.30, -0.15, 0.30],\n    \"III\": [0.07, -0.05, 0.45, -0.08, 0.12],\n    \"aVR\": [-0.12, 0.06, -0.55, 0.10, -0.18],\n    \"aVL\": [0.09, -0.03, 0.28, -0.05, 0.10],\n    \"aVF\": [0.14, -0.07, 0.85, -0.12, 0.20],\n    \"V1\": [0.07, 0.00, 0.22, -0.85, -0.06],\n    \"V2\": [0.09, -0.02, 0.45, -0.55, 0.12],\n    \"V3\": [0.10, -0.06, 0.75, -0.35, 0.22],\n    \"V4\": [0.12, -0.10, 1.35, -0.18, 0.32],\n    \"V5\": [0.12, -0.08, 1.05, -0.08, 0.28],\n    \"V6\": [0.10, -0.05, 0.75, -0.04, 0.22],\n}\n\n# Clinical 3x4 grid layout: (row, col)\ngrid_positions = {\n    \"I\": (0, 0),\n    \"aVR\": (0, 1),\n    \"V1\": (0, 2),\n    \"V4\": (0, 3),\n    \"II\": (1, 0),\n    \"aVL\": (1, 1),\n    \"V2\": (1, 2),\n    \"V5\": (1, 3),\n    \"III\": (2, 0),\n    \"aVF\": (2, 1),\n    \"V3\": (2, 2),\n    \"V6\": (2, 3),\n}\n\nrow_spacing = 3.5\nn_rows = 3\ntotal_time = 4 * strip_duration\n\n# Generate ECG traces with grid offsets\nall_traces = []\nlabel_records = []\n\nfor lead_name, w in lead_weights.items():\n    one_beat = w[0] * p_comp + w[1] * q_comp + w[2] * r_comp + w[3] * s_comp + w[4] * tw_comp\n    signal = np.tile(one_beat, int(np.ceil(n_strip / n_beat)) + 1)[:n_strip]\n    signal += np.random.normal(0, 0.015, n_strip)\n\n    row, col = grid_positions[lead_name]\n    x_vals = t_strip + col * strip_duration\n    y_baseline = (n_rows - 1 - row) * row_spacing + row_spacing\n\n    all_traces.append(pd.DataFrame({\"time\": x_vals, \"voltage\": signal + y_baseline, \"lead\": lead_name}))\n    label_records.append({\"time\": x_vals[0] + 0.05, \"voltage\": y_baseline + 1.35, \"label\": lead_name})\n\n# Lead II rhythm strip across the bottom (full 10 seconds)\nn_full = int(fs * total_time)\nt_full = np.linspace(0, total_time, n_full, endpoint=False)\nw_ii = lead_weights[\"II\"]\none_beat_ii = w_ii[0] * p_comp + w_ii[1] * q_comp + w_ii[2] * r_comp + w_ii[3] * s_comp + w_ii[4] * tw_comp\nsignal_ii = np.tile(one_beat_ii, int(np.ceil(n_full / n_beat)) + 1)[:n_full]\nsignal_ii += np.random.normal(0, 0.015, n_full)\n\nrhythm_baseline = 0.0\nall_traces.append(pd.DataFrame({\"time\": t_full, \"voltage\": signal_ii + rhythm_baseline, \"lead\": \"II_rhythm\"}))\nlabel_records.append({\"time\": 0.05, \"voltage\": rhythm_baseline + 1.35, \"label\": \"II\"})\n\ndf = pd.concat(all_traces, ignore_index=True)\nlabels_df = pd.DataFrame(label_records)\n\n# 1mV calibration pulses at left margin of each row + rhythm strip\ncal_records = []\nfor row_idx in range(n_rows):\n    y_base = (n_rows - 1 - row_idx) * row_spacing + row_spacing\n    cal_x = -0.15\n    cal_records.extend(\n        [\n            {\"x\": cal_x, \"y\": y_base, \"xend\": cal_x, \"yend\": y_base + 1.0},\n            {\"x\": cal_x - 0.05, \"y\": y_base, \"xend\": cal_x + 0.05, \"yend\": y_base},\n            {\"x\": cal_x - 0.05, \"y\": y_base + 1.0, \"xend\": cal_x + 0.05, \"yend\": y_base + 1.0},\n        ]\n    )\n# Rhythm strip calibration\ncal_records.extend(\n    [\n        {\"x\": -0.15, \"y\": rhythm_baseline, \"xend\": -0.15, \"yend\": rhythm_baseline + 1.0},\n        {\"x\": -0.20, \"y\": rhythm_baseline, \"xend\": -0.10, \"yend\": rhythm_baseline},\n        {\"x\": -0.20, \"y\": rhythm_baseline + 1.0, \"xend\": -0.10, \"yend\": rhythm_baseline + 1.0},\n    ]\n)\ncal_df = pd.DataFrame(cal_records)\n\n# Scale annotation text (bumped size — prev review flagged it as slightly small)\nscale_df = pd.DataFrame({\"x\": [total_time - 0.05], \"y\": [rhythm_baseline - 1.4], \"label\": [\"25 mm/s   |   10 mm/mV\"]})\n\n# ECG paper grid extents\ny_min = rhythm_baseline - 1.9\ny_max = (n_rows - 1) * row_spacing + row_spacing + 2.0\n\n# Paper-region backgrounds using geom_rect (lets-plot distinctive feature)\nrow_rects = []\nfor row_idx in range(n_rows):\n    y_base = (n_rows - 1 - row_idx) * row_spacing + row_spacing\n    row_rects.append(\n        {\"xmin\": 0, \"xmax\": total_time, \"ymin\": y_base - 1.7, \"ymax\": y_base + 1.8, \"region\": f\"Row {row_idx + 1}\"}\n    )\nrow_rects.append(\n    {\n        \"xmin\": 0,\n        \"xmax\": total_time,\n        \"ymin\": rhythm_baseline - 1.7,\n        \"ymax\": rhythm_baseline + 1.8,\n        \"region\": \"Rhythm Strip\",\n    }\n)\nrow_rects_df = pd.DataFrame(row_rects)\n\n# Minor grid (1mm equivalent: 0.04s horizontal, 0.1mV vertical)\nminor_x_vals = np.arange(0, total_time + 0.01, 0.04)\nminor_y_vals = np.arange(np.floor(y_min), y_max + 0.01, 0.1)\nminor_v = pd.DataFrame({\"x\": minor_x_vals, \"xend\": minor_x_vals, \"y\": y_min, \"yend\": y_max})\nminor_h = pd.DataFrame({\"y\": minor_y_vals, \"yend\": minor_y_vals, \"x\": 0.0, \"xend\": total_time})\n\n# Major grid (5mm equivalent: 0.2s horizontal, 0.5mV vertical)\nmajor_x_vals = np.arange(0, total_time + 0.01, 0.2)\nmajor_y_vals = np.arange(np.floor(y_min), y_max + 0.01, 0.5)\nmajor_v = pd.DataFrame({\"x\": major_x_vals, \"xend\": major_x_vals, \"y\": y_min, \"yend\": y_max})\nmajor_h = pd.DataFrame({\"y\": major_y_vals, \"yend\": major_y_vals, \"x\": 0.0, \"xend\": total_time})\n\n# Column separator lines (thicker at 2.5s boundaries)\ncol_boundaries = [strip_duration * i for i in range(5)]\ncol_sep = pd.DataFrame({\"x\": col_boundaries, \"xend\": col_boundaries, \"y\": y_min, \"yend\": y_max})\n\n# Plot\nplot = (\n    ggplot()\n    # Paper-region backgrounds (lets-plot geom_rect)\n    + geom_rect(\n        aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\"),\n        data=row_rects_df,\n        fill=PAPER_TINT,\n        alpha=0.5,\n        color=\"rgba(0,0,0,0)\",\n        tooltips=layer_tooltips().line(\"@region\"),\n        inherit_aes=False,\n    )\n    # Minor grid\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), data=minor_v, color=GRID_MINOR, size=0.1, inherit_aes=False\n    )\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), data=minor_h, color=GRID_MINOR, size=0.1, inherit_aes=False\n    )\n    # Major grid\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), data=major_v, color=GRID_MAJOR, size=0.3, inherit_aes=False\n    )\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), data=major_h, color=GRID_MAJOR, size=0.3, inherit_aes=False\n    )\n    # Column separators\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), data=col_sep, color=GRID_COL, size=0.6, inherit_aes=False\n    )\n    # 1mV calibration pulses\n    + geom_segment(aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), data=cal_df, color=INK, size=0.7, inherit_aes=False)\n    # ECG traces with interactive tooltips (lets-plot layer_tooltips)\n    + geom_line(\n        aes(x=\"time\", y=\"voltage\", group=\"lead\"),\n        data=df,\n        color=TRACE,\n        size=0.7,\n        tooltips=layer_tooltips().line(\"Lead: @lead\").format(\"time\", \".2f\").line(\"Time: @time s\"),\n    )\n    # Lead labels\n    + geom_text(\n        aes(x=\"time\", y=\"voltage\", label=\"label\"),\n        data=labels_df,\n        color=INK,\n        size=7,\n        fontface=\"bold\",\n        hjust=0,\n        inherit_aes=False,\n    )\n    # Scale annotation\n    + geom_text(aes(x=\"x\", y=\"y\", label=\"label\"), data=scale_df, color=INK_MUTED, size=6, hjust=1, inherit_aes=False)\n    # Scales\n    + scale_x_continuous(limits=[-0.3, total_time], expand=[0, 0])\n    + scale_y_continuous(limits=[y_min, y_max], expand=[0, 0])\n    + labs(\n        title=\"ecg-twelve-lead · python · letsplot · anyplot.ai\",\n        subtitle=\"Normal sinus rhythm · 72 bpm · standard 12-lead with continuous Lead II rhythm strip\",\n    )\n    # Theme - ECG paper style\n    + theme(\n        plot_title=element_text(size=17, face=\"bold\", color=INK, margin=[0, 0, 4, 0]),\n        plot_subtitle=element_text(size=11, color=INK_MUTED, margin=[0, 0, 10, 0]),\n        axis_title=element_blank(),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        axis_line=element_blank(),\n        panel_background=element_rect(fill=PAGE_BG, color=\"rgba(0,0,0,0)\"),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_grid=element_blank(),\n        plot_margin=[28, 22, 16, 22],\n        legend_position=\"none\",\n    )\n    + ggsize(800, 450)\n)\n\n# Save (scale 4x -> 3200 x 1800 px)\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}