{"spec_id":"spirometry-flow-volume","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nspirometry-flow-volume: Spirometry Flow-Volume Loop\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 94/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\n\n\nLetsPlot.setup_html()\n\n# Theme tokens (see prompts/default-style-guide.md \"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 = \"#D9D4C7\" if THEME == \"light\" else \"#33322D\"\n\n# Imprint palette — data colors stay constant across themes\nBRAND = \"#009E73\"  # measured patient loop — ALWAYS first series\nPRED = INK_MUTED  # predicted normal reference (theme-adaptive muted)\nPEF_COLOR = \"#BD8233\"  # ochre accent for Peak Expiratory Flow\nFEV1_COLOR = \"#C475FD\"  # lavender accent for FEV1 marker\n\n# Data: simulated spirometry flow-volume loop (mild obstructive pattern)\nnp.random.seed(42)\n\n# Measured patient values\nfvc = 4.2  # Forced Vital Capacity (L)\npef = 8.5  # Peak Expiratory Flow (L/s)\nfev1 = 3.1  # Forced Expiratory Volume in 1 s (L)\n\n# Expiratory limb: sharp rise to PEF then roughly linear decline\nn_exp = 160\nvolume_exp = np.linspace(0, fvc, n_exp)\npeak_idx = int(0.08 * n_exp)\nflow_rise = np.linspace(0, pef, peak_idx + 1)\nvol_rem = volume_exp[peak_idx:]\nflow_decline = pef * (1 - ((vol_rem - vol_rem[0]) / (fvc - vol_rem[0])) ** 0.85)\nflow_exp = np.concatenate([flow_rise, flow_decline[1:]])\n\n# Inspiratory limb: symmetric U-shaped curve below the zero-flow line\nn_insp = 160\nvolume_insp = np.linspace(fvc, 0, n_insp)\npif = -6.0  # Peak Inspiratory Flow (L/s)\nflow_insp = pif * np.sin(np.linspace(0, np.pi, n_insp))\n\n# Predicted normal values (healthy reference loop)\nfvc_pred, pef_pred, pif_pred = 4.8, 10.2, -7.0\nvolume_pred_exp = np.linspace(0, fvc_pred, n_exp)\npeak_idx_pred = int(0.08 * n_exp)\nflow_pred_rise = np.linspace(0, pef_pred, peak_idx_pred + 1)\nvol_rem_pred = volume_pred_exp[peak_idx_pred:]\nflow_pred_decline = pef_pred * (1 - ((vol_rem_pred - vol_rem_pred[0]) / (fvc_pred - vol_rem_pred[0])) ** 0.95)\nflow_pred_exp = np.concatenate([flow_pred_rise, flow_pred_decline[1:]])\nvolume_pred_insp = np.linspace(fvc_pred, 0, n_insp)\nflow_pred_insp = pif_pred * np.sin(np.linspace(0, np.pi, n_insp))\n\n# Curve frames (one row per limb point) with hover labels for tooltips\ndf_meas_exp = pd.DataFrame(\n    {\n        \"volume\": volume_exp,\n        \"flow\": flow_exp,\n        \"curve\": \"Measured\",\n        \"vol_label\": [f\"{v:.2f} L\" for v in volume_exp],\n        \"flow_label\": [f\"{f:.1f} L/s\" for f in flow_exp],\n    }\n)\ndf_meas_insp = pd.DataFrame(\n    {\n        \"volume\": volume_insp,\n        \"flow\": flow_insp,\n        \"curve\": \"Measured\",\n        \"vol_label\": [f\"{v:.2f} L\" for v in volume_insp],\n        \"flow_label\": [f\"{f:.1f} L/s\" for f in flow_insp],\n    }\n)\ndf_pred_exp = pd.DataFrame({\"volume\": volume_pred_exp, \"flow\": flow_pred_exp, \"curve\": \"Predicted normal\"})\ndf_pred_insp = pd.DataFrame({\"volume\": volume_pred_insp, \"flow\": flow_pred_insp, \"curve\": \"Predicted normal\"})\n\n# Ribbon fills (loop interiors) — measured uses inspiratory limb as lower bound\ndf_ribbon_meas = pd.DataFrame(\n    {\"volume\": volume_exp, \"ymin\": np.interp(volume_exp, volume_insp[::-1], flow_insp[::-1]), \"ymax\": flow_exp}\n)\ndf_ribbon_pred = pd.DataFrame(\n    {\n        \"volume\": volume_pred_exp,\n        \"ymin\": np.interp(volume_pred_exp, volume_pred_insp[::-1], flow_pred_insp[::-1]),\n        \"ymax\": flow_pred_exp,\n    }\n)\n\n# Clinical markers\npef_volume = volume_exp[np.argmax(flow_exp)]\ndf_pef = pd.DataFrame({\"volume\": [pef_volume], \"flow\": [pef]})\nfev1_flow = np.interp(fev1, volume_exp, flow_exp)\ndf_fev1 = pd.DataFrame({\"volume\": [fev1], \"flow\": [fev1_flow]})\n\n# Clinical summary text box (spec requires FEV1 / FVC / PEF annotations)\nratio = fev1 / fvc\nclinical_text = f\"FEV₁ {fev1:.1f} L   ·   FVC {fvc:.1f} L   ·   PEF {pef:.1f} L/s   ·   FEV₁/FVC {ratio:.0%}\"\ndf_clinical = pd.DataFrame({\"volume\": [fvc_pred * 0.55], \"flow\": [10.3], \"label\": [clinical_text]})\n\ntitle = \"spirometry-flow-volume · python · letsplot · anyplot.ai\"\n\n# Plot\nplot = (\n    ggplot()\n    # Filled loop interiors\n    + geom_ribbon(aes(x=\"volume\", ymin=\"ymin\", ymax=\"ymax\"), data=df_ribbon_pred, fill=PRED, alpha=0.12)\n    + geom_ribbon(aes(x=\"volume\", ymin=\"ymin\", ymax=\"ymax\"), data=df_ribbon_meas, fill=BRAND, alpha=0.14)\n    # Zero-flow reference line dividing expiratory (upper) from inspiratory (lower)\n    + geom_hline(yintercept=0, color=INK_SOFT, size=0.5)\n    # Predicted normal loop (dashed) — legend driven by color + linetype aes\n    + geom_line(\n        aes(x=\"volume\", y=\"flow\", color=\"curve\", linetype=\"curve\"), data=df_pred_exp, size=1.1, show_legend=True\n    )\n    + geom_line(\n        aes(x=\"volume\", y=\"flow\", color=\"curve\", linetype=\"curve\"), data=df_pred_insp, size=1.1, show_legend=False\n    )\n    # Measured loop (solid) with interactive tooltips\n    + geom_line(\n        aes(x=\"volume\", y=\"flow\", color=\"curve\", linetype=\"curve\"),\n        data=df_meas_exp,\n        size=2.0,\n        show_legend=True,\n        tooltips=layer_tooltips().title(\"Expiratory limb\").line(\"Volume|@vol_label\").line(\"Flow|@flow_label\"),\n    )\n    + geom_line(\n        aes(x=\"volume\", y=\"flow\", color=\"curve\", linetype=\"curve\"),\n        data=df_meas_insp,\n        size=2.0,\n        show_legend=False,\n        tooltips=layer_tooltips().title(\"Inspiratory limb\").line(\"Volume|@vol_label\").line(\"Flow|@flow_label\"),\n    )\n    # FEV1 marker\n    + geom_point(aes(x=\"volume\", y=\"flow\"), data=df_fev1, color=FEV1_COLOR, size=6, shape=18)\n    + geom_label(\n        aes(x=\"volume\", y=\"flow\", label=\"label\"),\n        data=pd.DataFrame({\"volume\": [fev1 + 0.15], \"flow\": [fev1_flow + 1.1], \"label\": [f\"FEV₁ at {fev1:.1f} L\"]}),\n        size=7,\n        color=FEV1_COLOR,\n        fill=ELEVATED_BG,\n        label_padding=0.25,\n        hjust=0,\n    )\n    # PEF marker\n    + geom_point(\n        aes(x=\"volume\", y=\"flow\"),\n        data=df_pef,\n        color=PEF_COLOR,\n        size=7,\n        shape=16,\n        tooltips=layer_tooltips()\n        .title(\"Peak Expiratory Flow\")\n        .line(f\"PEF|{pef} L/s\")\n        .line(f\"Volume|{pef_volume:.2f} L\"),\n    )\n    + geom_label(\n        aes(x=\"volume\", y=\"flow\", label=\"label\"),\n        data=pd.DataFrame({\"volume\": [pef_volume + 0.18], \"flow\": [pef + 0.1], \"label\": [f\"PEF {pef} L/s\"]}),\n        size=7,\n        color=PEF_COLOR,\n        fill=ELEVATED_BG,\n        label_padding=0.25,\n        hjust=0,\n    )\n    # Clinical summary\n    + geom_label(\n        aes(x=\"volume\", y=\"flow\", label=\"label\"),\n        data=df_clinical,\n        size=7,\n        color=INK,\n        fill=ELEVATED_BG,\n        label_padding=0.4,\n        hjust=0.5,\n    )\n    # Scales — combined color + linetype legend (shared empty name merges them)\n    + scale_color_manual(values={\"Measured\": BRAND, \"Predicted normal\": PRED}, name=\"\")\n    + scale_linetype_manual(values={\"Measured\": \"solid\", \"Predicted normal\": \"dashed\"}, name=\"\")\n    + guides(color=guide_legend(override_aes={\"size\": 1.6}))\n    + labs(x=\"Volume (L)\", y=\"Flow (L/s)\", title=title)\n    + coord_cartesian(xlim=[-0.15, 5.4], ylim=[-8, 11])\n    + scale_y_continuous(breaks=list(range(-8, 12, 2)))\n    # Sizing & theme-adaptive chrome\n    + ggsize(800, 450)\n    + theme_minimal()\n    + theme(\n        plot_title=element_text(size=16, face=\"bold\", color=INK),\n        axis_title=element_text(size=12, color=INK),\n        axis_text=element_text(size=10, color=INK_SOFT),\n        panel_grid_major_y=element_line(color=GRID, size=0.4),\n        panel_grid_major_x=element_blank(),\n        panel_grid_minor=element_blank(),\n        panel_border=element_blank(),\n        axis_line=element_blank(),\n        axis_ticks=element_blank(),\n        legend_position=[0.86, 0.93],\n        legend_justification=[0.5, 1.0],\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0.4),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_margin=[24, 24, 16, 16],\n    )\n)\n\n# Save\nggsave(plot, f\"plot-{THEME}.png\", scale=4, path=\".\")\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}