{"spec_id":"spirometry-flow-volume","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nspirometry-flow-volume: Spirometry Flow-Volume Loop\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 93/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove script directory from sys.path to avoid importing local altair.py\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nif _script_dir in sys.path:\n    sys.path.remove(_script_dir)\n# Also strip the '' / '.' empty-string entry added when running from this dir\nsys.path[:] = [p for p in sys.path if os.path.abspath(p or \".\") != _script_dir]\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\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\"\n\n# Imprint palette — data colors stay identical across themes\nBRAND = \"#009E73\"  # measured loop — ALWAYS first series\nPREDICTED = INK_MUTED  # predicted normal overlay — theme-adaptive muted\nPEF_RED = \"#AE3030\"  # Imprint matte red — focal Peak Expiratory Flow marker\nFEV1_BLUE = \"#4467A3\"  # Imprint blue — FEV1 reference\n\n# Data\nnp.random.seed(42)\n\nfvc_measured = 4.8\nfev1_measured = 3.2\npef_measured = 9.5\nfvc_predicted = 5.2\npef_predicted = 10.5\nn_points = 150\n\n# Measured expiratory limb: sharp rise to PEF then linear decline\nvol_exp = np.linspace(0, fvc_measured, n_points)\npef_idx = int(n_points * 0.12)\nflow_rise = np.linspace(0, pef_measured, pef_idx + 1)\nvol_remaining = vol_exp[pef_idx:] - vol_exp[pef_idx]\nvol_range = fvc_measured - vol_exp[pef_idx]\nflow_decline = pef_measured * (1 - (vol_remaining / vol_range) ** 0.85)\nflow_exp = np.concatenate([flow_rise, flow_decline[1:]])\nflow_exp += np.random.normal(0, 0.015, len(flow_exp))  # subtle physiological noise\nflow_exp = np.clip(flow_exp, 0, None)\nflow_exp[0] = 0\nflow_exp[-1] = 0\n\n# Measured inspiratory limb: symmetric U-shape below the zero-flow line\nvol_insp = np.linspace(fvc_measured, 0, n_points)\nt_insp = np.linspace(0, np.pi, n_points)\nflow_insp = -5.5 * np.sin(t_insp)\nflow_insp += np.random.normal(0, 0.015, len(flow_insp))\nflow_insp[0] = 0\nflow_insp[-1] = 0\n\n# Predicted expiratory limb\nvol_pred_exp = np.linspace(0, fvc_predicted, n_points)\npred_pef_idx = int(n_points * 0.12)\npred_flow_rise = np.linspace(0, pef_predicted, pred_pef_idx + 1)\npred_vol_remaining = vol_pred_exp[pred_pef_idx:] - vol_pred_exp[pred_pef_idx]\npred_vol_range = fvc_predicted - vol_pred_exp[pred_pef_idx]\npred_flow_decline = pef_predicted * (1 - (pred_vol_remaining / pred_vol_range) ** 0.85)\nflow_pred_exp = np.concatenate([pred_flow_rise, pred_flow_decline[1:]])\nflow_pred_exp = np.clip(flow_pred_exp, 0, None)\nflow_pred_exp[0] = 0\nflow_pred_exp[-1] = 0\n\n# Predicted inspiratory limb\nvol_pred_insp = np.linspace(fvc_predicted, 0, n_points)\nt_pred_insp = np.linspace(0, np.pi, n_points)\nflow_pred_insp = -6.2 * np.sin(t_pred_insp)\nflow_pred_insp[0] = 0\nflow_pred_insp[-1] = 0\n\n# Combine into dataframes\ndf_measured = pd.concat(\n    [pd.DataFrame({\"volume\": vol_exp, \"flow\": flow_exp}), pd.DataFrame({\"volume\": vol_insp, \"flow\": flow_insp})],\n    ignore_index=True,\n)\ndf_measured[\"curve\"] = \"Measured\"\ndf_measured[\"order\"] = range(len(df_measured))\n\ndf_predicted = pd.concat(\n    [\n        pd.DataFrame({\"volume\": vol_pred_exp, \"flow\": flow_pred_exp}),\n        pd.DataFrame({\"volume\": vol_pred_insp, \"flow\": flow_pred_insp}),\n    ],\n    ignore_index=True,\n)\ndf_predicted[\"curve\"] = \"Predicted\"\ndf_predicted[\"order\"] = range(len(df_predicted))\n\ndf_all = pd.concat([df_measured, df_predicted], ignore_index=True)\n\n# PEF annotation point\npef_vol = vol_exp[np.argmax(flow_exp)]\npef_flow = float(flow_exp.max())\ndf_pef = pd.DataFrame({\"volume\": [pef_vol], \"flow\": [pef_flow], \"label\": [f\"PEF = {pef_flow:.1f} L/s\"]})\n\n# Clinical values annotation\ndf_clinical = pd.DataFrame(\n    {\n        \"volume\": [4.0],\n        \"flow\": [9.0],\n        \"label\": [f\"FVC = {fvc_measured:.1f} L\\nFEV₁ = {fev1_measured:.1f} L\\nPEF = {pef_flow:.1f} L/s\"],\n    }\n)\n\n# FEV1 marker: vertical reference at the 1-second volume\ndf_fev1_line = pd.DataFrame({\"volume\": [fev1_measured, fev1_measured], \"flow\": [-1.5, 7.8]})\ndf_fev1_label = pd.DataFrame({\"volume\": [fev1_measured], \"flow\": [8.4], \"label\": [\"FEV₁\"]})\n\n# Color and dash scales for the legend\ncolor_scale = alt.Scale(domain=[\"Measured\", \"Predicted\"], range=[BRAND, PREDICTED])\ndash_scale = alt.Scale(domain=[\"Measured\", \"Predicted\"], range=[[1, 0], [8, 6]])\n\n# Nearest selection for interactive hover tooltip (Altair-distinctive)\nnearest = alt.selection_point(nearest=True, on=\"pointerover\", fields=[\"order\"], empty=False)\n\nx_axis = alt.X(\"volume:Q\", title=\"Volume (L)\", scale=alt.Scale(domain=[-0.3, 5.6]))\ny_axis = alt.Y(\"flow:Q\", title=\"Flow (L/s)\", scale=alt.Scale(domain=[-8, 12]))\n\n# Zero-flow reference line\ndf_zero = pd.DataFrame({\"volume\": [-0.3, 5.6], \"flow\": [0, 0]})\nzero_line = (\n    alt.Chart(df_zero)\n    .mark_line(strokeWidth=1.2, strokeDash=[4, 4], color=INK_MUTED, opacity=0.6)\n    .encode(x=x_axis, y=y_axis)\n)\n\n# Main loops with legend\ncurves = (\n    alt.Chart(df_all)\n    .mark_line()\n    .encode(\n        x=x_axis,\n        y=y_axis,\n        order=\"order:Q\",\n        color=alt.Color(\n            \"curve:N\",\n            title=None,\n            scale=color_scale,\n            legend=alt.Legend(labelFontSize=12, symbolStrokeWidth=3, orient=\"top-right\", offset=-6),\n        ),\n        strokeDash=alt.StrokeDash(\"curve:N\", title=None, scale=dash_scale, legend=None),\n        strokeWidth=alt.condition(alt.datum.curve == \"Measured\", alt.value(3.5), alt.value(2)),\n    )\n)\n\n# Interactive hover layer (Altair-distinctive: reveals flow/volume on the measured loop)\ntooltip_points = (\n    alt.Chart(df_measured)\n    .mark_point(opacity=0, size=80)\n    .encode(\n        x=\"volume:Q\",\n        y=\"flow:Q\",\n        tooltip=[\n            alt.Tooltip(\"volume:Q\", title=\"Volume (L)\", format=\".2f\"),\n            alt.Tooltip(\"flow:Q\", title=\"Flow (L/s)\", format=\".2f\"),\n        ],\n    )\n    .add_params(nearest)\n)\n\nhover_rule = (\n    alt.Chart(df_measured)\n    .mark_rule(color=INK_SOFT, strokeWidth=1, strokeDash=[3, 3])\n    .encode(x=\"volume:Q\")\n    .transform_filter(nearest)\n)\n\nhover_point = (\n    alt.Chart(df_measured)\n    .mark_point(size=120, filled=True, color=BRAND)\n    .encode(x=\"volume:Q\", y=\"flow:Q\")\n    .transform_filter(nearest)\n)\n\n# PEF focal marker and label\npef_point = alt.Chart(df_pef).mark_point(size=260, filled=True, color=PEF_RED).encode(x=\"volume:Q\", y=\"flow:Q\")\npef_label = (\n    alt.Chart(df_pef)\n    .mark_text(align=\"left\", dx=14, dy=-12, fontSize=13, fontWeight=\"bold\", color=PEF_RED)\n    .encode(x=\"volume:Q\", y=\"flow:Q\", text=\"label:N\")\n)\n\n# FEV1 vertical reference line with label\nfev1_line = (\n    alt.Chart(df_fev1_line)\n    .mark_line(strokeWidth=1.5, strokeDash=[6, 4], color=FEV1_BLUE, opacity=0.8)\n    .encode(x=\"volume:Q\", y=\"flow:Q\")\n)\nfev1_label = (\n    alt.Chart(df_fev1_label)\n    .mark_text(fontSize=12, fontWeight=\"bold\", color=FEV1_BLUE, dy=-6)\n    .encode(x=\"volume:Q\", y=\"flow:Q\", text=\"label:N\")\n)\n\n# Clinical values text block\nclinical_text = (\n    alt.Chart(df_clinical)\n    .mark_text(align=\"left\", fontSize=12, lineBreak=\"\\n\", lineHeight=16, color=INK, fontWeight=\"bold\")\n    .encode(x=\"volume:Q\", y=\"flow:Q\", text=\"label:N\")\n)\n\n# Expiratory / Inspiratory region labels (bumped up from the previous 14pt-equivalent)\ndf_region_exp = pd.DataFrame({\"volume\": [0.2], \"flow\": [11.0], \"label\": [\"Expiration\"]})\ndf_region_insp = pd.DataFrame({\"volume\": [0.2], \"flow\": [-7.0], \"label\": [\"Inspiration\"]})\nregion_exp_label = (\n    alt.Chart(df_region_exp)\n    .mark_text(fontSize=13, color=INK_MUTED, fontStyle=\"italic\", align=\"left\")\n    .encode(x=\"volume:Q\", y=\"flow:Q\", text=\"label:N\")\n)\nregion_insp_label = (\n    alt.Chart(df_region_insp)\n    .mark_text(fontSize=13, color=INK_MUTED, fontStyle=\"italic\", align=\"left\")\n    .encode(x=\"volume:Q\", y=\"flow:Q\", text=\"label:N\")\n)\n\n# Title — scale fontsize off the 67-char baseline (this title is shorter, so stays at 16)\ntitle_text = \"spirometry-flow-volume · python · altair · anyplot.ai\"\ntitle_fontsize = round(16 * min(1.0, 67 / len(title_text)))\n\nchart = (\n    (\n        zero_line\n        + curves\n        + tooltip_points\n        + hover_rule\n        + hover_point\n        + fev1_line\n        + fev1_label\n        + pef_point\n        + pef_label\n        + clinical_text\n        + region_exp_label\n        + region_insp_label\n    )\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(title_text, fontSize=title_fontsize, anchor=\"start\", color=INK),\n    )\n    .configure_view(fill=PAGE_BG, stroke=None)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        domain=False,\n        gridColor=INK,\n        gridOpacity=0.12,\n        tickColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n    )\n    .configure_legend(\n        labelFontSize=12, fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, padding=8, cornerRadius=4\n    )\n)\n\n# Save — altair view dims are not the saved PNG; pad up to the canonical 3200×1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}