{"spec_id":"spirometry-flow-volume","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nspirometry-flow-volume: Spirometry Flow-Volume Loop\nLibrary: plotly 6.8.0 | Python 3.13.13\nQuality: 94/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme-adaptive chrome\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nGRID = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\n# Imprint palette\nBRAND = \"#009E73\"  # measured loop (always first series)\nBLUE = \"#4467A3\"  # PEF landmark\nMUTED = INK_MUTED  # predicted normal reference\n# Flow deficit fill uses Imprint matte red #AE3030 (semantic: loss / obstruction) at low alpha\n\n# Data - Spirometry flow-volume loop for a patient with mild obstruction\nnp.random.seed(42)\n\n# Measured values\nfvc = 4.2  # Forced Vital Capacity (L)\npef = 8.5  # Peak Expiratory Flow (L/s)\nfev1 = 3.1  # FEV1 (L)\n\n# Predicted normal values\nfvc_pred = 4.8\npef_pred = 10.2\n\nn_points = 150\n\n# Expiratory limb: sharp rise to PEF then roughly linear decline.\n# Normalised so the curve peak lands exactly on the stated PEF value.\nvolume_exp = np.linspace(0, fvc, n_points)\nt_exp = volume_exp / fvc\nflow_exp = (1 - t_exp) ** 0.35 * (1 - np.exp(-30 * t_exp))\nflow_exp = np.maximum(flow_exp, 0)\nflow_exp = flow_exp / flow_exp.max() * pef\n\n# Inspiratory limb: symmetric U-shape below zero line\nvolume_insp = np.linspace(fvc, 0, n_points)\nt_insp = np.linspace(0, 1, n_points)\npif = -5.5  # Peak Inspiratory Flow\nflow_insp = pif * np.sin(np.pi * t_insp)\n\n# Predicted normal expiratory limb (peak pinned to predicted PEF)\nvolume_pred_exp = np.linspace(0, fvc_pred, n_points)\nt_pred_exp = volume_pred_exp / fvc_pred\nflow_pred_exp = (1 - t_pred_exp) ** 0.3 * (1 - np.exp(-35 * t_pred_exp))\nflow_pred_exp = np.maximum(flow_pred_exp, 0)\nflow_pred_exp = flow_pred_exp / flow_pred_exp.max() * pef_pred\n\n# Predicted normal inspiratory limb\nvolume_pred_insp = np.linspace(fvc_pred, 0, n_points)\nt_pred_insp = np.linspace(0, 1, n_points)\npif_pred = -6.5\nflow_pred_insp = pif_pred * np.sin(np.pi * t_pred_insp)\n\n# Combine into closed loops\nvolume_measured = np.concatenate([volume_exp, volume_insp])\nflow_measured = np.concatenate([flow_exp, flow_insp])\n\nvolume_predicted = np.concatenate([volume_pred_exp, volume_pred_insp])\nflow_predicted = np.concatenate([flow_pred_exp, flow_pred_insp])\n\n# PEF point now coincides exactly with the curve peak\npef_idx = np.argmax(flow_exp)\npef_volume = volume_exp[pef_idx]\n\n# FEV1 volume marker\nfev1_volume = fev1\n\n# Plot\nfig = go.Figure()\n\n# Shaded deficit between predicted and measured expiratory curves (obstruction)\nvol_common = np.linspace(0, min(fvc, fvc_pred), 120)\nflow_pred_interp = np.interp(vol_common, volume_pred_exp, flow_pred_exp)\nflow_meas_interp = np.interp(vol_common, volume_exp, flow_exp)\n\nfig.add_trace(\n    go.Scatter(\n        x=np.concatenate([vol_common, vol_common[::-1]]),\n        y=np.concatenate([flow_pred_interp, flow_meas_interp[::-1]]),\n        fill=\"toself\",\n        fillcolor=\"rgba(174, 48, 48, 0.12)\",\n        line={\"width\": 0},\n        name=\"Flow Deficit\",\n        showlegend=True,\n        hoverinfo=\"skip\",\n        legendrank=3,\n    )\n)\n\n# Predicted normal loop (dashed reference, behind measured)\nfig.add_trace(\n    go.Scatter(\n        x=volume_predicted,\n        y=flow_predicted,\n        mode=\"lines\",\n        line={\"color\": MUTED, \"width\": 2.5, \"dash\": \"dash\"},\n        name=\"Predicted Normal\",\n        hovertemplate=\"<b>Predicted</b><br>Volume: %{x:.2f} L<br>Flow: %{y:.2f} L/s<extra></extra>\",\n        legendrank=2,\n    )\n)\n\n# Measured loop (solid brand green)\nfig.add_trace(\n    go.Scatter(\n        x=volume_measured,\n        y=flow_measured,\n        mode=\"lines\",\n        line={\"color\": BRAND, \"width\": 4, \"shape\": \"spline\"},\n        name=\"Measured\",\n        hovertemplate=\"<b>Measured</b><br>Volume: %{x:.2f} L<br>Flow: %{y:.2f} L/s<extra></extra>\",\n        legendrank=1,\n    )\n)\n\n# PEF marker sitting exactly on the curve peak\nfig.add_trace(\n    go.Scatter(\n        x=[pef_volume],\n        y=[pef],\n        mode=\"markers\",\n        marker={\"size\": 16, \"color\": BLUE, \"symbol\": \"diamond\", \"line\": {\"width\": 2.5, \"color\": PAGE_BG}},\n        name=\"PEF\",\n        showlegend=False,\n        hovertemplate=\"<b>Peak Expiratory Flow</b><br>%{y:.1f} L/s at %{x:.2f} L<extra></extra>\",\n    )\n)\n\n# PEF annotation with arrow\nfig.add_annotation(\n    x=pef_volume,\n    y=pef,\n    text=f\"<b>PEF = {pef:.1f} L/s</b>\",\n    showarrow=True,\n    arrowhead=0,\n    arrowwidth=2,\n    arrowcolor=BLUE,\n    ax=55,\n    ay=-32,\n    font={\"size\": 13, \"color\": BLUE},\n    bgcolor=ELEVATED_BG,\n    bordercolor=BLUE,\n    borderwidth=1.5,\n    borderpad=5,\n)\n\n# FEV1 vertical reference line\nfig.add_shape(\n    type=\"line\",\n    x0=fev1_volume,\n    x1=fev1_volume,\n    y0=-1,\n    y1=np.interp(fev1_volume, volume_exp, flow_exp),\n    line={\"color\": INK_SOFT, \"width\": 1.5, \"dash\": \"dashdot\"},\n)\n\nfig.add_annotation(\n    x=fev1_volume,\n    y=-1.3,\n    text=f\"FEV₁ = {fev1:.1f} L\",\n    showarrow=False,\n    font={\"size\": 12, \"color\": INK_SOFT},\n    bgcolor=ELEVATED_BG,\n    borderpad=4,\n)\n\n# Clinical values annotation box\nclinical_text = (\n    f\"<b>Spirometry Results</b><br>\"\n    f\"FEV₁: <b>{fev1:.1f} L</b><br>\"\n    f\"FVC: <b>{fvc:.1f} L</b><br>\"\n    f\"FEV₁/FVC: <b>{fev1 / fvc:.0%}</b><br>\"\n    f\"PEF: <b>{pef:.1f} L/s</b>\"\n)\nfig.add_annotation(\n    x=0.98,\n    y=0.95,\n    xref=\"paper\",\n    yref=\"paper\",\n    text=clinical_text,\n    showarrow=False,\n    font={\"size\": 13, \"color\": INK},\n    align=\"left\",\n    bordercolor=INK_SOFT,\n    borderwidth=1.5,\n    borderpad=12,\n    bgcolor=ELEVATED_BG,\n    xanchor=\"right\",\n    yanchor=\"top\",\n)\n\n# Zero flow reference line\nfig.add_hline(y=0, line={\"color\": GRID, \"width\": 1.5})\n\n# Layout\nfig.update_layout(\n    autosize=False,\n    title={\n        \"text\": \"spirometry-flow-volume · python · plotly · anyplot.ai\",\n        \"font\": {\"size\": 16, \"color\": INK},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n    },\n    xaxis={\n        \"title\": {\"text\": \"Volume (L)\", \"font\": {\"size\": 12, \"color\": INK}, \"standoff\": 12},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"showgrid\": False,\n        \"zeroline\": False,\n        \"range\": [-0.3, max(fvc, fvc_pred) + 0.6],\n        \"linecolor\": INK_SOFT,\n        \"linewidth\": 1.5,\n        \"ticks\": \"outside\",\n        \"ticklen\": 6,\n        \"tickcolor\": INK_SOFT,\n        \"dtick\": 1,\n    },\n    yaxis={\n        \"title\": {\"text\": \"Flow (L/s)\", \"font\": {\"size\": 12, \"color\": INK}, \"standoff\": 12},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"showgrid\": True,\n        \"gridcolor\": GRID,\n        \"gridwidth\": 1,\n        \"zeroline\": False,\n        \"linecolor\": INK_SOFT,\n        \"linewidth\": 1.5,\n        \"ticks\": \"outside\",\n        \"ticklen\": 6,\n        \"tickcolor\": INK_SOFT,\n        \"dtick\": 2,\n    },\n    legend={\n        \"font\": {\"size\": 10, \"color\": INK_SOFT},\n        \"x\": 0.02,\n        \"y\": 0.02,\n        \"xanchor\": \"left\",\n        \"yanchor\": \"bottom\",\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n        \"itemsizing\": \"constant\",\n        \"tracegroupgap\": 4,\n    },\n    margin={\"l\": 80, \"r\": 40, \"t\": 80, \"b\": 60},\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    hoverlabel={\"bgcolor\": ELEVATED_BG, \"bordercolor\": BRAND, \"font\": {\"size\": 12, \"color\": INK}},\n    hovermode=\"closest\",\n)\n\n# Save\nfig.write_image(f\"plot-{THEME}.png\", width=800, height=450, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}