--- title: "Interpretation reference: severity bands, patterns, and standards" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Interpretation reference: severity bands, patterns, and standards} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(pft) ``` 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: ```{r} 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) ) ``` A vectorised call: ```{r} pft_severity(c(0.2, -1.7, -3.0, -5.0)) ``` 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()`: ```{r} pft_severity_2005(c(85, 65, 55, 40, 30)) ``` 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: * **Normal** -- FEV1/FVC, FVC, and FEV1 all >= LLN. * **Obstructed** -- FEV1/FVC < LLN. * **Restricted** -- FEV1/FVC >= LLN, FVC < LLN, *and* TLC < LLN. * **Mixed** -- FEV1/FVC < LLN *and* TLC < LLN. * **Non-specific** -- FEV1/FVC >= LLN, FVC < LLN, TLC >= LLN. The spirometry-only version of this pattern (TLC unavailable) is PRISm, surfaced by `pft_prism()`. 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. ```{r} 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")] ``` 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 ```{r} 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")] ``` The pattern is **Obstructed** with **moderate** severity. GOLD staging (FEV1 % predicted) classifies this as **GOLD 2**: ```{r} pft_gold(r$fev1_pctpred_2022, fev1fvc = r$fev1fvc_measured) ``` ## Example 2: low TLC with preserved KCO ```{r} 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")] ``` 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. ```{r} 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")] ``` 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: * **Data-frame helpers** -- `pft_classify()`, `pft_prism()`, `pft_volume_subpattern()`, `pft_diffusion_interpret()` -- consume several paired columns simultaneously and accept column-name overrides via NSE (bare name, string, or `!!var`). * **Vector helpers** -- `pft_severity()`, `pft_severity_2005()`, `pft_gold()`, `pft_fev1q()`, `pft_dlco_hb_correct()`, `pft_quality()`, `pft_change()`, `pft_bdr()`, `pft_bdr_2005()` -- take one or more numeric vectors and return a vector or a small per-row tibble. They are designed to compose inside `dplyr::mutate()`. A cohort run that combines reference values with severity, GOLD staging, and bronchodilator response: ```{r, eval = FALSE} 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: ```{r, eval = FALSE} 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 * `vignette("longitudinal-analysis")` -- decline, conditional change, FEV1Q. * `vignette("diffusion-capacity")` -- DLCO interpretation, Hb correction, Hughes & Pride categories. * `vignette("input-format")` -- input contract and column override syntax.