{"spec_id":"spirometry-flow-volume","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nspirometry-flow-volume: Spirometry Flow-Volume Loop\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 94/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\nimport xml.etree.ElementTree as ET\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\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\"\nGRID = \"#E3DFD4\" if THEME == \"light\" else \"#33322D\"\n\n# Imprint palette: measured = brand green, predicted = muted (reference overlay),\n# PEF = matte red (semantic anchor for the highlighted clinical peak)\nBRAND = \"#009E73\"  # Imprint position 1 — ALWAYS first series\nPREDICTED = INK_MUTED  # theme-adaptive muted — the normal reference loop\nPEF_RED = \"#AE3030\"  # Imprint position 5 — emphasis on the key clinical point\n\n# Data - Spirometry flow-volume loop for a healthy adult male\nnp.random.seed(42)\n\nfvc = 4.8  # Forced Vital Capacity (L)\npef = 9.5  # Peak Expiratory Flow (L/s)\nfev1 = 3.8  # Forced Expiratory Volume in 1 second (L)\n\n# Measured expiratory limb: sharp rise to PEF then roughly linear decline\nn_exp = 150\nvolume_exp = np.linspace(0, fvc, n_exp)\npef_volume = 0.15 * fvc\nrise = volume_exp <= pef_volume\nflow_exp = np.where(\n    rise,\n    pef * np.divide(volume_exp, pef_volume, where=rise, out=np.zeros_like(volume_exp)) ** 0.6,\n    pef * np.clip(1 - (volume_exp - pef_volume) / (fvc - pef_volume), 0, None) ** 1.3,\n)\nflow_exp += np.random.normal(0, 0.05, n_exp)\nflow_exp = np.clip(flow_exp, 0, None)\nflow_exp[0] = 0\nflow_exp[-1] = 0\n\n# Measured inspiratory limb: symmetric U-shaped curve (negative flow)\nn_insp = 150\nvolume_insp = np.linspace(fvc, 0, n_insp)\nflow_insp = -6.0 * np.sin(np.pi * np.linspace(0, 1, n_insp)) ** 0.8\nflow_insp += np.random.normal(0, 0.04, n_insp)\nflow_insp[0] = 0\nflow_insp[-1] = 0\n\n# Predicted normal loop (slightly higher capacity), drawn dashed for comparison\nfvc_pred = 5.2\npef_pred = 10.5\nvolume_pred_exp = np.linspace(0, fvc_pred, 100)\npef_vol_pred = 0.15 * fvc_pred\nrise_pred = volume_pred_exp <= pef_vol_pred\nflow_pred_exp = np.where(\n    rise_pred,\n    pef_pred * np.divide(volume_pred_exp, pef_vol_pred, where=rise_pred, out=np.zeros_like(volume_pred_exp)) ** 0.6,\n    pef_pred * np.clip(1 - (volume_pred_exp - pef_vol_pred) / (fvc_pred - pef_vol_pred), 0, None) ** 1.3,\n)\nflow_pred_exp[0] = 0\nflow_pred_exp[-1] = 0\n\nvolume_pred_insp = np.linspace(fvc_pred, 0, 100)\nflow_pred_insp = -6.5 * np.sin(np.pi * np.linspace(0, 1, 100)) ** 0.8\nflow_pred_insp[0] = 0\nflow_pred_insp[-1] = 0\n\n# Style - theme-adaptive Imprint chrome on the warm-cream / warm-black surface\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=(BRAND, BRAND, PREDICTED, PREDICTED, PEF_RED),\n    font_family=\"DejaVu Sans, Helvetica, Arial, sans-serif\",\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    tooltip_font_size=36,\n    opacity=1.0,\n    opacity_hover=1.0,\n    stroke_opacity=1.0,\n    stroke_opacity_hover=1.0,\n    guide_stroke_color=GRID,\n    major_guide_stroke_color=GRID,\n)\n\n# Chart\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    title=\"spirometry-flow-volume · python · pygal · anyplot.ai\",\n    x_title=\"Volume (L)\",\n    y_title=\"Flow (L/s)\",\n    style=custom_style,\n    show_dots=False,\n    show_y_guides=True,\n    show_x_guides=False,\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_box_size=28,\n    value_formatter=lambda y: f\"{y:.1f} L/s\",\n    x_value_formatter=lambda x: f\"{x:.1f} L\",\n    margin_top=70,\n    margin_bottom=40,\n    margin_left=70,\n    margin_right=60,\n    xrange=(-0.2, 5.6),\n    range=(-7.5, 10.5),\n    x_labels=[0, 1, 2, 3, 4, 5],\n    x_labels_major=[0, 1, 2, 3, 4, 5],\n    y_labels=[-5, 0, 5, 10],\n    show_x_labels=True,\n    show_y_labels=True,\n    show_minor_x_labels=False,\n    show_minor_y_labels=False,\n    truncate_legend=-1,\n    explicit_size=True,\n)\n\n# Measured loop (solid, thick); predicted loop (dashed via SVG post-processing)\nchart.add(\n    \"Measured (Expiratory)\",\n    [(float(v), float(f)) for v, f in zip(volume_exp, flow_exp, strict=True)],\n    stroke_style={\"width\": 5},\n)\nchart.add(\n    \"Measured (Inspiratory)\",\n    [(float(v), float(f)) for v, f in zip(volume_insp, flow_insp, strict=True)],\n    stroke_style={\"width\": 5},\n)\nchart.add(\n    \"Predicted Normal (Expiratory)\",\n    [(float(v), float(f)) for v, f in zip(volume_pred_exp, flow_pred_exp, strict=True)],\n    stroke_style={\"width\": 4},\n)\nchart.add(\n    \"Predicted Normal (Inspiratory)\",\n    [(float(v), float(f)) for v, f in zip(volume_pred_insp, flow_pred_insp, strict=True)],\n    stroke_style={\"width\": 4},\n)\n\n# PEF marker\npef_idx = int(np.argmax(flow_exp))\nchart.add(\"PEF\", [(float(volume_exp[pef_idx]), float(flow_exp[pef_idx]))], stroke=False, show_dots=True, dots_size=16)\n\n# Render to interactive SVG, then post-process for dashed lines + annotations\nsvg_bytes = chart.render()\nSVG_NS = \"http://www.w3.org/2000/svg\"\nET.register_namespace(\"\", SVG_NS)\nET.register_namespace(\"xlink\", \"http://www.w3.org/1999/xlink\")\nroot = ET.fromstring(svg_bytes)\nns = f\"{{{SVG_NS}}}\"\n\n# Make the predicted (reference) loop dashed for distinct styling\nfor g in root.iter(f\"{ns}g\"):\n    cls = g.get(\"class\", \"\")\n    if \"serie-2\" in cls or \"serie-3\" in cls:\n        for path in g.iter(f\"{ns}path\"):\n            path.set(\"stroke-dasharray\", \"20,12\")\n\n# Highlight the PEF point and add its label\nparent_of = {child: parent for parent in root.iter() for child in parent}\nfor g in root.iter(f\"{ns}g\"):\n    if \"serie-4\" not in g.get(\"class\", \"\"):\n        continue\n    for circle in g.iter(f\"{ns}circle\"):\n        if float(circle.get(\"r\", \"0\")) <= 3:\n            continue\n        cx, cy = float(circle.get(\"cx\", \"0\")), float(circle.get(\"cy\", \"0\"))\n        circle.set(\"fill\", PEF_RED)\n        circle.set(\"r\", \"15\")\n        circle.set(\"stroke\", PAGE_BG)\n        circle.set(\"stroke-width\", \"4\")\n        parent = parent_of.get(circle)\n        if parent is not None:\n            label = ET.SubElement(parent, f\"{ns}text\")\n            label.set(\"x\", f\"{cx + 26:.0f}\")\n            label.set(\"y\", f\"{cy - 22:.0f}\")\n            label.set(\"font-size\", \"42\")\n            label.set(\"font-family\", \"DejaVu Sans, Helvetica, Arial, sans-serif\")\n            label.set(\"fill\", PEF_RED)\n            label.set(\"font-weight\", \"bold\")\n            label.text = f\"PEF = {pef:.1f} L/s\"\n        break\n\n# Clinical values callout box (upper-right, where the loop leaves the canvas open)\nbox_x, box_y, box_w, box_h = 2500, 270, 600, 290\nbox = ET.SubElement(root, f\"{ns}rect\")\nbox.set(\"x\", f\"{box_x}\")\nbox.set(\"y\", f\"{box_y}\")\nbox.set(\"width\", f\"{box_w}\")\nbox.set(\"height\", f\"{box_h}\")\nbox.set(\"rx\", \"14\")\nbox.set(\"fill\", ELEVATED_BG)\nbox.set(\"stroke\", GRID)\nbox.set(\"stroke-width\", \"2\")\n\naccent = ET.SubElement(root, f\"{ns}rect\")\naccent.set(\"x\", f\"{box_x}\")\naccent.set(\"y\", f\"{box_y}\")\naccent.set(\"width\", f\"{box_w}\")\naccent.set(\"height\", \"9\")\naccent.set(\"rx\", \"4\")\naccent.set(\"fill\", BRAND)\n\nclinical_lines = [\n    (\"Clinical Values\", INK, \"bold\", 42),\n    (f\"FEV₁: {fev1:.1f} L  ({fev1 / fvc * 100:.0f}% FVC)\", INK_SOFT, \"normal\", 38),\n    (f\"FVC:  {fvc:.1f} L\", INK_SOFT, \"normal\", 38),\n    (f\"PEF:  {pef:.1f} L/s\", PEF_RED, \"bold\", 38),\n]\nfor i, (txt, color, weight, size) in enumerate(clinical_lines):\n    el = ET.SubElement(root, f\"{ns}text\")\n    el.set(\"x\", f\"{box_x + 32}\")\n    el.set(\"y\", f\"{box_y + 70 + i * 56}\")\n    el.set(\"font-size\", f\"{size}\")\n    el.set(\"font-family\", \"DejaVu Sans, Helvetica, Arial, sans-serif\")\n    el.set(\"fill\", color)\n    el.set(\"font-weight\", weight)\n    el.text = txt\n\n# Zero-flow reference line for clinical context\nplot_area = root.find(f\".//{ns}g[@class='plot overlay']\")\nif plot_area is not None:\n    zero_line = ET.SubElement(plot_area, f\"{ns}line\")\n    zero_line.set(\"x1\", \"0\")\n    zero_line.set(\"y1\", \"0\")\n    zero_line.set(\"x2\", \"3200\")\n    zero_line.set(\"y2\", \"0\")\n    zero_line.set(\"stroke\", INK_MUTED)\n    zero_line.set(\"stroke-width\", \"1.5\")\n    zero_line.set(\"stroke-dasharray\", \"10,8\")\n\n# Save - interactive HTML (SVG with tooltips) + PNG\nfinal_svg = ET.tostring(root, encoding=\"unicode\")\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(final_svg)\ncairosvg.svg2png(\n    bytestring=final_svg.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\", output_width=3200, output_height=1800\n)\n"}