---
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**.

# Choosing between the current and legacy interpretive standards

The two standards differ in three ways:

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

The current standard is the recommended default and is what
`pft_interpret()` applies by default. Use the legacy 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.
