Interpretation reference: severity bands, patterns, and standards

Where the Getting-started vignette covers how to compute reference values and z-scores, this one covers the interpretive primitives that consume them: the severity bands, the pattern decision tree, the differences between the 2022 Stanojevic and 2005 Pellegrino standards, and worked examples showing what pft_interpret() produces for a few representative input shapes.

Severity bands

pft_severity() translates a z-score into one of four bands per the Stanojevic 2022 standard. The cut-points come straight from the paper’s interpretation table:

data.frame(
  band     = c("normal", "mild", "moderate", "severe"),
  z_lower  = c(-1.645, -2.5, -4,    -Inf),
  z_upper  = c( Inf,   -1.645, -2.5, -4)
)
#>       band z_lower z_upper
#> 1   normal  -1.645     Inf
#> 2     mild  -2.500  -1.645
#> 3 moderate  -4.000  -2.500
#> 4   severe    -Inf  -4.000

A vectorised call:

pft_severity(c(0.2, -1.7, -3.0, -5.0))
#> [1] "normal"   "mild"     "moderate" "severe"

The 2005 Pellegrino bands grade percent predicted of FEV1 rather than z-score and have five tiers (mild, moderate, moderately-severe, severe, very-severe). They are appropriate when reproducing legacy reports or when matching a clinic’s existing severity-grading convention; use pft_severity_2005():

pft_severity_2005(c(85, 65, 55, 40, 30))
#> [1] "mild"              "moderate"          "moderately severe"
#> [4] "severe"            "very severe"

The same standard = c("2022", "2005") argument flows through pft_classify() and pft_interpret() so a whole report can be re-rendered against either standard without changing input data.

Pattern decision tree

pft_classify() assigns one of five interpretive patterns per Stanojevic 2022 Figure 8 / Table 5:

When TLC is missing, the classifier falls back to the spirometry-only branches in Table 5 (Normal, Obstructed, Non-specific / PRISm); Restricted and Mixed require TLC.

case <- data.frame(
  fev1    = c(2.5, 2.5, 1.5, 1.5,  3.5),
  fev1_lln_2022= c(3.0, 3.0, 2.5, 2.5,  3.0),
  fvc     = c(3.8, 3.8, 2.2, 2.2,  4.5),
  fvc_lln_2022 = c(3.5, 3.5, 2.5, 2.5,  4.0),
  fev1fvc = c(0.66, 0.66, 0.68, 0.80, 0.78),
  fev1fvc_lln_2022 = 0.70,
  tlc     = c(6.0, 5.0, 4.0, 4.0,  6.5),
  tlc_lln = c(5.5, 5.5, 5.5, 5.5,  5.5)
)
pft_classify(case)[, c("ats_classification")]
#> # A tibble: 5 × 1
#>   ats_classification
#>   <chr>             
#> 1 Obstructed        
#> 2 Mixed             
#> 3 Mixed             
#> 4 Restricted        
#> 5 Normal

Reading row by row:

  1. FEV1/FVC < LLN, TLC normal -> Obstructed.
  2. FEV1/FVC < LLN and TLC < LLN -> Mixed.
  3. FEV1/FVC normal, FVC < LLN, TLC < LLN -> Restricted.
  4. FEV1/FVC normal, FVC < LLN, TLC normal -> Non-specific.
  5. Everything >= LLN -> Normal.

When to use 2022 vs 2005

The two standards differ in three ways:

Aspect 2022 (Stanojevic) 2005 (Pellegrino)
Severity input z-score % predicted (FEV1)
Bronchodilator response > 10 % predicted >= 12 % AND >= 200 mL
Pattern flowchart Fig 8 / Table 5 Fig 2

The 2022 standard is the recommended default and is what pft_interpret() applies by default. Use the 2005 path when reproducing a historical report or matching an EMR template that was built against the older flowchart – run pft_interpret(data, standard = "2005") to get the predecessor severity and BDR outputs alongside pft_classify(standard = "2005")’s pattern labels.

Worked examples

Example 1: low FEV1/FVC with low FEV1

copd <- data.frame(
  sex = "M", age = 68, height = 175, race = "Caucasian",
  fev1_measured    = 1.6,
  fvc_measured     = 3.0,
  fev1fvc_measured = 1.6 / 3.0,
  tlc_measured     = 6.8
)
r <- pft_interpret(copd)
r[, c("ats_classification", "fev1_severity_2022", "fev1_zscore_2022",
       "fev1_pctpred_2022")]
#> # A tibble: 1 × 4
#>   ats_classification fev1_severity_2022 fev1_zscore_2022 fev1_pctpred_2022
#>   <chr>              <chr>                         <dbl>             <dbl>
#> 1 Obstructed         moderate                      -2.80              53.2

The pattern is Obstructed with moderate severity. GOLD staging (FEV1 % predicted) classifies this as GOLD 2:

pft_gold(r$fev1_pctpred_2022, fev1fvc = r$fev1fvc_measured)
#> [1] "GOLD 2"

Example 2: low TLC with preserved KCO

preserved_kco <- data.frame(
  sex = "F", age = 55, height = 160, race = "Caucasian",
  fev1_measured    = 1.2, fvc_measured     = 1.5,
  fev1fvc_measured = 0.80, tlc_measured    = 3.8,
  rv_tlc_measured  = 0.30, dlco_measured   = 22.0,
  va_measured      = 4.6,  kco_tr_measured = 4.5
)
r <- pft_interpret(preserved_kco)
r[, c("ats_classification", "diffusion_category",
       "volume_subpattern")]
#> # A tibble: 1 × 3
#>   ats_classification diffusion_category volume_subpattern 
#>   <chr>              <chr>              <chr>             
#> 1 Restricted         Normal             Simple restriction

The package labels this row as Restricted with a Volume loss diffusion category (low DLCO, low VA, preserved KCO) and a Simple restriction volume sub-pattern.

Example 3: PRISm without TLC

When TLC isn’t available, pft_prism() flags the spirometry-only non-specific picture: low FEV1, low FVC, preserved ratio.

no_tlc <- data.frame(
  sex = "M", age = 50, height = 175, race = "Caucasian",
  fev1_measured    = 2.2, fvc_measured     = 2.8,
  fev1fvc_measured = 0.79
)
r <- pft_interpret(no_tlc)
r[, c("ats_classification", "prism")]
#> # A tibble: 1 × 2
#>   ats_classification prism
#>   <chr>              <lgl>
#> 1 <NA>               TRUE

The prism column is TRUE. The label flags the spirometry pattern only; downstream clinical interpretation is out of scope.

Applying vector helpers inside a data-frame workflow

The package splits its public surface into two kinds of function:

A cohort run that combines reference values with severity, GOLD staging, and bronchodilator response:

library(dplyr)

out <- pft_spirometry(cohort) |>
  mutate(
    fev1_severity_2022 = pft_severity(fev1_zscore_2022),
    fvc_severity_2022  = pft_severity(fvc_zscore_2022),
    gold          = pft_gold(fev1_pctpred_2022, fev1fvc = fev1fvc_measured),
    bdr_sig       = pft_bdr(fev1_pre, fev1_post, fev1_pred_2022)$is_significant
  )

Grading every z-score column in one pass with dplyr::across(). Use matches("_zscore") rather than ends_with("_zscore") so that year-suffixed spirometry columns (fev1_zscore_2022) are also caught:

out |>
  mutate(across(matches("_zscore"), pft_severity, .names = "{.col}_severity"))

The split exists because the data-frame helpers need to read paired columns (a value and its LLN/ULN, or three z-scores at once) and need to know how to find them in your data, while the vector helpers operate on a single named column and so compose naturally as mutate() expressions.

See also