Pulmonology flow-volume loop
normal, obstructive, and restrictive patterns
Example from the compendium of canonical charts
Python Code
"""Pulmonology flow-volume loop — normal, obstructive, and restrictive patterns."""
import numpy as np
import plotly.graph_objects as go
def exp_curve(fvc, pef, concavity=0.62, n=300):
v = np.linspace(0, fvc, n)
pef_v = 0.15 * fvc
flow = np.where(
v <= pef_v,
pef * (v / pef_v) ** 0.6,
pef * ((fvc - v) / (fvc - pef_v)) ** concavity,
)
return v, np.clip(flow, 0, None)
def insp_curve(fvc, peak_insp=3.8, n=200):
theta = np.linspace(np.pi, 0, n)
v = fvc * (1 - np.cos(theta)) / 2
f = -peak_insp * np.sin(theta)
return v, f
def make_loop(fvc, pef, concavity=0.62, peak_insp=3.8):
v_e, f_e = exp_curve(fvc, pef, concavity)
v_i, f_i = insp_curve(fvc, peak_insp)
return v_e, f_e, v_i, f_i
def generate():
print("building flow-volume loops: normal, obstructive, restrictive …")
# ── Normal reference ──────────────────────────────────────────────────────
NRM_FVC, NRM_PEF = 4.8, 10.2
v_ne, f_ne, v_ni, f_ni = make_loop(NRM_FVC, NRM_PEF)
# ── Obstructive (e.g., COPD): low FEV1/FVC, concave scooped curve ────────
OBS_FVC, OBS_PEF = 4.5, 7.0
v_oe, f_oe, v_oi, f_oi = make_loop(OBS_FVC, OBS_PEF, concavity=0.85)
# ── Restrictive (e.g., fibrosis): reduced FVC, normal shape/ratio ─────────
RES_FVC, RES_PEF = 3.2, 7.5
v_re, f_re, v_ri, f_ri = make_loop(RES_FVC, RES_PEF, concavity=0.55, peak_insp=2.8)
fig = go.Figure()
# Zero reference line
fig.add_hline(y=0, line=dict(color=GRID, width=1))
# Normal loop shaded fill (entire loop)
all_v = np.concatenate([v_ne, v_ni[::-1]])
all_f = np.concatenate([f_ne, f_ni[::-1]])
fig.add_trace(go.Scatter(
x=all_v, y=all_f,
fill="toself",
fillcolor="rgba(132,94,238,0.12)",
line=dict(width=0),
showlegend=False,
hoverinfo="skip",
name="Normal fill",
))
# Normal curve
fig.add_trace(go.Scatter(
x=np.concatenate([v_ne, [v_ne[-1]], v_ni]), y=np.concatenate([f_ne, [0], f_ni]),
mode="lines",
name="Normal",
line=dict(color=VIOLET, width=3),
hovertemplate="V=%{x:.2f} L Flow=%{y:.2f} L/s<extra></extra>",
))
# Obstructive loop
fig.add_trace(go.Scatter(
x=np.concatenate([v_oe, [v_oe[-1]], v_oi]), y=np.concatenate([f_oe, [0], f_oi]),
mode="lines",
name="Obstructive",
line=dict(color=PINK, width=2.5),
hovertemplate="V=%{x:.2f} L Flow=%{y:.2f} L/s<extra></extra>",
))
# Restrictive loop
fig.add_trace(go.Scatter(
x=np.concatenate([v_re, [v_re[-1]], v_ri]), y=np.concatenate([f_re, [0], f_ri]),
mode="lines",
name="Restrictive",
line=dict(color=TEAL, width=2.5),
hovertemplate="V=%{x:.2f} L Flow=%{y:.2f} L/s<extra></extra>",
))
# PEF marker on normal curve (same color as normal line)
pef_idx = np.argmax(f_ne)
fig.add_trace(go.Scatter(
x=[v_ne[pef_idx]], y=[f_ne[pef_idx]],
mode="markers+text",
marker=dict(color=VIOLET, size=10, symbol="diamond", line=dict(color="white", width=1.5)),
text=[f"PEF {NRM_PEF:.1f} L/s"],
textposition="top right",
showlegend=False,
hoverinfo="skip",
))
# FEV1 line on normal
NRM_FEV1 = 3.9
fig.add_vline(
x=NRM_FEV1,
line=dict(color=MUTED, width=1.2, dash="dash"),
annotation_text=f"<b>FEV₁={NRM_FEV1} L</b>",
annotation_position="top left",
annotation_font=dict(size=13, color=MUTED),
)
# FVC line on normal
fig.add_vline(
x=NRM_FVC,
line=dict(color=MUTED, width=1, dash="dot"),
annotation_text=f"<b>FVC={NRM_FVC} L</b>",
annotation_position="bottom right",
annotation_font=dict(size=13, color=MUTED),
)
fig.update_layout(
xaxis=dict(title="Volume (L)", range=[-0.1, NRM_FVC + 0.4], zeroline=False),
yaxis=dict(title="Flow (L/s)", range=[-5.5, 12.5], zeroline=False),
legend=dict(orientation="h", y=1.08),
margin=dict(t=50, b=50, l=60, r=60),
height=480,
)
return fig
if __name__ == "__main__":
generate()
Made with Plotly