A pulmonary function test visualization that plots airflow rate (L/s) against lung volume (L) during forced expiration and inspiration, forming a characteristic loop shape. The expiratory limb rises sharply to Peak Expiratory Flow (PEF) then declines, while the inspiratory limb forms a more symmetric curve below the x-axis. This plot is essential for diagnosing obstructive and restrictive lung diseases by comparing measured loops against predicted normal values.

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