{"spec_id":"spirometry-flow-volume","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\nspirometry-flow-volume: Spirometry Flow-Volume Loop\nLibrary: seaborn 0.13.2 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport seaborn as sns\n\n\n# Theme-adaptive chrome (Imprint palette)\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\"\n\n# Imprint categorical palette + semantic anchors\nBRAND = \"#009E73\"  # brand green — measured loop (first series)\nLOSS = \"#AE3030\"  # matte red — semantic anchor for flow deficit / shortfall\nPREDICTED = INK_MUTED  # muted neutral — reference / predicted-normal overlay\n\n# Data\nnp.random.seed(42)\n\nfvc_measured = 4.8\npef_measured = 9.5\nfev1_measured = 3.5\n\nn_points = 150\n\n# Expiratory limb (positive flow): sharp rise to PEF then linear decline\nvol_exp = np.linspace(0, fvc_measured, n_points)\nrise_phase = np.minimum(vol_exp / 0.3, 1.0)\ndecay_phase = 1.0 - (vol_exp / fvc_measured) ** 0.85\nflow_exp_raw = rise_phase * decay_phase\nflow_exp = pef_measured * flow_exp_raw / flow_exp_raw.max()\nflow_exp = np.maximum(flow_exp, 0)\n\n# Inspiratory limb (negative flow): symmetric U-shaped curve\nvol_insp = np.linspace(0, fvc_measured, n_points)\nflow_insp = -5.5 * np.sin(np.linspace(np.pi, 0, n_points))\n\n# Predicted normal values\nfvc_predicted = 5.2\npef_predicted = 10.8\nvol_pred_exp = np.linspace(0, fvc_predicted, n_points)\nrise_pred = np.minimum(vol_pred_exp / 0.28, 1.0)\ndecay_pred = 1.0 - (vol_pred_exp / fvc_predicted) ** 0.85\nflow_pred_exp_raw = rise_pred * decay_pred\nflow_pred_exp = pef_predicted * flow_pred_exp_raw / flow_pred_exp_raw.max()\nflow_pred_exp = np.maximum(flow_pred_exp, 0)\n\nvol_pred_insp = np.linspace(0, fvc_predicted, n_points)\nflow_pred_insp = -6.2 * np.sin(np.linspace(np.pi, 0, n_points))\n\n# Build DataFrame using a style column for idiomatic seaborn hue+style plotting\ndf = pd.DataFrame(\n    {\n        \"Volume (L)\": np.concatenate([vol_exp, vol_insp, vol_pred_exp, vol_pred_insp]),\n        \"Flow (L/s)\": np.concatenate([flow_exp, flow_insp, flow_pred_exp, flow_pred_insp]),\n        \"Curve\": (\n            [\"Measured\"] * n_points\n            + [\"Measured\"] * n_points\n            + [\"Predicted Normal\"] * n_points\n            + [\"Predicted Normal\"] * n_points\n        ),\n        \"Limb\": (\n            [\"Expiratory\"] * n_points\n            + [\"Inspiratory\"] * n_points\n            + [\"Expiratory\"] * n_points\n            + [\"Inspiratory\"] * n_points\n        ),\n    }\n)\n\n# seaborn theming — theme-adaptive chrome\nsns.set_theme(\n    style=\"ticks\",\n    rc={\n        \"figure.facecolor\": PAGE_BG,\n        \"axes.facecolor\": PAGE_BG,\n        \"axes.edgecolor\": INK_SOFT,\n        \"axes.labelcolor\": INK,\n        \"text.color\": INK,\n        \"xtick.color\": INK_SOFT,\n        \"ytick.color\": INK_SOFT,\n        \"grid.color\": INK,\n        \"grid.alpha\": 0.15,\n        \"legend.facecolor\": ELEVATED_BG,\n        \"legend.edgecolor\": INK_SOFT,\n    },\n)\n\n# Canvas — hard rule: 8 × 4.5 in @ 400 dpi = 3200 × 1800 px\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400)\n\n# Plot using sns.lineplot with hue AND style for idiomatic seaborn dash control.\n# style drives the dash pattern natively without post-hoc artist iteration.\nfor limb_name in [\"Expiratory\", \"Inspiratory\"]:\n    limb_df = df[df[\"Limb\"] == limb_name]\n    sns.lineplot(\n        data=limb_df,\n        x=\"Volume (L)\",\n        y=\"Flow (L/s)\",\n        hue=\"Curve\",\n        style=\"Curve\",\n        palette={\"Measured\": BRAND, \"Predicted Normal\": PREDICTED},\n        dashes={\"Measured\": \"\", \"Predicted Normal\": (5, 3)},\n        linewidth=2.5,\n        ax=ax,\n        legend=(limb_name == \"Expiratory\"),\n    )\n\n# Shade flow deficit between measured and predicted expiratory limbs\nvol_shade = np.linspace(0, min(fvc_measured, fvc_predicted), 200)\nflow_meas_interp = np.interp(vol_shade, vol_exp, flow_exp)\nflow_pred_interp = np.interp(vol_shade, vol_pred_exp, flow_pred_exp)\nax.fill_between(vol_shade, flow_meas_interp, flow_pred_interp, alpha=0.12, color=LOSS, label=\"Flow deficit\", zorder=1)\n\n# Mark PEF (peak expiratory flow) — the clinical highlight on the measured curve\npef_idx = np.argmax(flow_exp)\npef_actual = flow_exp[pef_idx]\npef_df = pd.DataFrame({\"Volume (L)\": [vol_exp[pef_idx]], \"Flow (L/s)\": [pef_actual]})\nsns.scatterplot(\n    data=pef_df,\n    x=\"Volume (L)\",\n    y=\"Flow (L/s)\",\n    color=LOSS,\n    s=120,\n    zorder=5,\n    edgecolor=PAGE_BG,\n    linewidth=1.2,\n    legend=False,\n    ax=ax,\n)\nax.annotate(\n    f\"PEF = {pef_actual:.1f} L/s\",\n    xy=(vol_exp[pef_idx], pef_actual),\n    xytext=(vol_exp[pef_idx] + 0.7, pef_actual + 0.3),\n    fontsize=9,\n    fontweight=\"bold\",\n    color=LOSS,\n    arrowprops={\"arrowstyle\": \"->\", \"color\": LOSS, \"lw\": 1.2},\n    zorder=5,\n)\n\n# Mark FEV1 point on the measured expiratory curve\nfev1_flow = np.interp(fev1_measured, vol_exp, flow_exp)\nfev1_df = pd.DataFrame({\"Volume (L)\": [fev1_measured], \"Flow (L/s)\": [fev1_flow]})\nsns.scatterplot(\n    data=fev1_df,\n    x=\"Volume (L)\",\n    y=\"Flow (L/s)\",\n    color=BRAND,\n    marker=\"D\",\n    s=90,\n    zorder=5,\n    edgecolor=PAGE_BG,\n    linewidth=1.0,\n    legend=False,\n    ax=ax,\n)\nax.annotate(\n    f\"FEV₁ = {fev1_measured:.1f} L\",\n    xy=(fev1_measured, fev1_flow),\n    xytext=(fev1_measured + 0.5, fev1_flow + 0.7),\n    fontsize=9,\n    fontweight=\"semibold\",\n    color=BRAND,\n    arrowprops={\"arrowstyle\": \"->\", \"color\": BRAND, \"lw\": 1.0},\n    zorder=5,\n)\n\n# Clinical values callout box\nfev1_fvc_ratio = fev1_measured / fvc_measured * 100\ntextstr = (\n    f\"FVC      = {fvc_measured:.1f} L\\n\"\n    f\"FEV₁     = {fev1_measured:.1f} L\\n\"\n    f\"FEV₁/FVC = {fev1_fvc_ratio:.0f}%\\n\"\n    f\"PEF      = {pef_actual:.1f} L/s\"\n)\nprops = {\"boxstyle\": \"round,pad=0.5\", \"facecolor\": ELEVATED_BG, \"edgecolor\": INK_SOFT, \"alpha\": 0.95, \"linewidth\": 0.8}\nax.text(\n    0.975,\n    0.04,\n    textstr,\n    transform=ax.transAxes,\n    fontsize=8,\n    color=INK,\n    verticalalignment=\"bottom\",\n    horizontalalignment=\"right\",\n    bbox=props,\n    family=\"monospace\",\n    zorder=6,\n)\n\n# Zero-flow reference line separating expiratory and inspiratory limbs\nax.axhline(y=0, color=INK_SOFT, linewidth=0.7, linestyle=\"-\", zorder=1)\n\n# Labels and title\nax.set_xlabel(\"Volume (L)\", fontsize=10)\nax.set_ylabel(\"Flow (L/s)\", fontsize=10)\nax.set_title(\n    \"spirometry-flow-volume · python · seaborn · anyplot.ai\", fontsize=12, fontweight=\"medium\", color=INK, pad=10\n)\nax.tick_params(axis=\"both\", labelsize=8)\nax.yaxis.grid(True, alpha=0.15, linewidth=0.6)\nax.xaxis.grid(False)\nsns.despine(ax=ax)\n\n# Single legend pass (measured, predicted, flow deficit) — no redundant override\nhandles, labels = ax.get_legend_handles_labels()\nlegend = ax.legend(handles=handles, labels=labels, fontsize=8, loc=\"upper right\", framealpha=0.95)\nlegend.get_frame().set_facecolor(ELEVATED_BG)\nlegend.get_frame().set_edgecolor(INK_SOFT)\nfor text in legend.get_texts():\n    text.set_color(INK)\n\nfig.subplots_adjust(left=0.07, right=0.97, top=0.91, bottom=0.11)\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}