# pft 1.0.1

CRAN pretest fixes; no user-facing behaviour changes.

* Replaced the Unicode superscript-minus characters in the
  `pft_dlco_hb_correct()` docstring with ASCII (`^-1`), so the PDF
  version of the reference manual builds under LaTeX without special
  Unicode support.
* Declared `R (>= 4.1)` in the `Depends` field to match the fact that
  `pft_volume_subpattern()`'s example uses the base-R pipe (`|>`),
  introduced in R 4.1.
* Wrapped technical acronyms (`'ATS'`, `'ERS'`, `'GLI'`) in single
  quotes in the DESCRIPTION `Description` field per CRAN convention.


# pft 1.0.0

Initial public release. Baseline feature set implementing routine
pulmonary function test interpretation under the ERS/ATS 2022 technical
standard, with GLI-family reference equations (GLI-2012 and GLI Global
2022 spirometry, GLI-2021 static lung volumes, GLI-2017 diffusion
capacity with the 2020 author correction), the ERS/ATS 2022 pattern
classifier, severity grading, bronchodilator response, PRISm
identification, hemoglobin correction, the FEV1Q survival index,
GOLD 1-4 airflow-limitation grading, and Graham 2019 A-F spirometry
acceptability grading. Pellegrino et al. 2005 legacy primitives are
retained for cross-standard reclassification analyses. Detailed
change log for the development history follows.

---

## Development history (pre-1.0.0)

## Breaking changes

* The default `year` argument for `pft_spirometry()`, `pft_interpret()`,
  `pft_classify()`, `pft_prism()`, and `pft_volume_subpattern()` is
  now `2022` (the GLI Global / race-neutral equations) — previously
  `2012`. This aligns the package with the current ERS/ATS 2022
  technical-standard recommendation and removes the need for a
  `race` column when callers omit `year`. The combined effect with
  the always-suffixed output convention is that
  `pft_spirometry(d)` now emits `fev1_pred_2022` (was
  `fev1_pred`), and `pft_classify(d)` looks up `fev1_lln_2022`
  by default (was `fev1_lln`). To reproduce the previous behaviour
  pass `year = 2012` explicitly and include a `race` column.

* Spirometry reference outputs from `pft_spirometry()` / `pft_interpret()`
  now always carry a four-digit GLI-year suffix: `fev1_pred_2012`,
  `fev1_lln_2012`, `fev1_zscore_2012`, ... for GLI 2012, and the
  existing `_2022` columns for GLI 2022. Previously the default
  (2012) emitted unsuffixed columns and only 2022 carried a suffix;
  the asymmetry meant flipping a default in the future would
  silently change column names of code written against the
  unsuffixed convention. The always-suffix scheme makes the source
  equation explicit at every column and removes the implicit
  "unsuffixed == default" rule.

  Lung-volume (Hall 2021) and diffusion (GLI 2017) outputs stay
  unsuffixed for now — each module currently ships a single
  standard. The same suffixing convention will be adopted there
  when competing standards emerge.

  Knock-on changes:
  * `pft_classify()`, `pft_prism()`, and `pft_volume_subpattern()`
    gain a `year` argument (default `2012`) that suffixes the
    spirometry-derived `_lln` defaults. Standalone callers should
    pass the year matching their upstream reference-function call.
  * `pft_interpret()` threads `year` through the internal pipeline
    so the helpers above receive it automatically.
  * `pft_long()`'s `year` column is now populated for every
    spirometry row; volume / diffusion rows remain `year = NA` until
    those modules adopt the convention.
  * `print.pft_result` annotates the per-measure rows with the
    chosen GLI year (e.g. "FEV1 (2012)"). When both `_2012` and
    `_2022` columns are present, the highest year is rendered.
  * `pft_plot()` deduplicates per measure when both years are
    present, picking the highest year so a single lollipop renders
    one z-score per measure.

  Tests, vignettes, and rendered docs have been updated to the new
  convention. If you wrote code against `fev1_pred` /
  `fev1_zscore` / etc., add the `_2012` suffix to those names (or
  pass column-name overrides to the helpers).


* `pft_compare()`, `print.pft_compare()`, `summary.pft_compare()`, and
  the `pft_plot(type = "compare")` mode have been removed. The
  GLI 2012 vs GLI Global 2022 reclassification analysis can still be
  reproduced by calling `pft_interpret(data, year = 2012)` and
  `pft_interpret(data, year = 2022)` and computing deltas on the
  resulting columns directly. Removed for being outside the package's
  core ATS/ERS reference-value + interpretation mission.
* `pft_plot()` has been simplified to the single-patient `lollipop`
  figure; the `histogram`, `trajectory`, and `bdr` modes have been
  removed. The signature is now `pft_plot(data)` with no `type`
  argument. Cohort and longitudinal figures are easier to build
  directly from `pft_long()` output piped into `ggplot2`.
* `pft_cohort_summary()` has been removed. Its outputs (per-measure
  z-score quantiles, ATS pattern frequencies, PRISm prevalence,
  diffusion-category frequencies) are easier expressed as plain
  `dplyr::group_by() |> summarise()` calls on a `pft_interpret()`
  result, optionally piped through `pft_long()` first.
* `pft_dlco_hb_correct()` no longer inspects its `hemoglobin`
  argument for likely-g/dL inputs (the previous behaviour was to
  warn and multiply by 10 when any value was below 30). Unit
  detection is out of scope for the package; pass g/L directly.
  Callers that were relying on the auto-conversion will now get
  numerically wrong corrections instead of a warning, so audit any
  upstream code that produced this argument.
* `pft_required_columns()` has been removed. It returned a hardcoded
  list of column names per function — documentation written as code,
  which had to be hand-synced with the actual function signatures. The
  same content is now in `vignette("input-format")`.
* `pft_validate()` has been removed. Most of its checks (sex coding,
  age and height range, race level membership) duplicated warnings
  already emitted by the reference functions' input normalisation,
  and the rest (positive-value, FEV1 ≤ FVC) would surface naturally
  as `NaN` z-scores from the LMS power transform. Callers can write
  these checks inline against their cohort if they still want them.
* `pft_glance()` and the `broom::glance()` S3 method on `pft_result`
  have been removed. The function returned four passthrough columns
  plus three trivial row-stats (`worst_zscore`, `n_below_lln`,
  `n_above_uln`), all of which are easier computed inline from a
  `pft_long()` result. `pft_long()` and the `broom::tidy()` S3
  method are kept.
* `pft_interpret()` no longer auto-derives `fev1fvc_measured` from
  `fev1_measured / fvc_measured` (and the analogous
  `frc_tlc_measured`). Trust-the-caller: supply the ratio column
  explicitly if you want pattern classification, PRISm, or the
  volume sub-pattern stages to run. Note that in ATS/ERS "best test"
  workflows the reported ratio comes from a single maneuver and may
  not equal `fev1 / fvc` constructed from best-of-N picks, so the
  caller's explicit ratio is the safer source.

## New features

* `pft_classify()`, `pft_prism()`, `pft_volume_subpattern()`, and
  `pft_diffusion_interpret()` now accept tidy-eval column-name
  overrides (bare name, string, or `!!var`) for every input column,
  matching the pattern already used by `pft_spirometry()`,
  `pft_volumes()`, `pft_diffusion()`, and `pft_interpret()`. Defaults
  are the canonical pft column names, so callers using the convention
  pass no extra arguments. The error message when a required column
  is absent now reads `"required column(s) missing from input: ..."`
  (was function-specific wording).
* `pft_diffusion_interpret()` gains an `SI.units` argument matching
  `pft_diffusion()` and `pft_interpret()`. The previous silent
  traditional-vs-SI auto-detect is dropped; SI-units callers must now
  pass `SI.units = TRUE` (or override `dlco` / `kco` directly).
  Threaded through `pft_interpret()` so the package-level `SI.units`
  flag now reaches every stage that needs it.
* `pft_classify()` now tolerates absence of the `tlc` / `tlc_lln`
  columns: when either is missing from `data`, the existing
  spirometry-only fallback (Stanojevic 2022 Table 5 / Pellegrino 2005
  Fig 2) triggers without raising. Previously a caller working from
  spirometry-only data had to add explicit NA TLC columns first.
* `pft_long()` pivots a `pft_result` to long form (one row per
  `(patient, measure)`). The `tidy.pft_result()` S3 method
  dispatches to it when `broom` is installed.
* `pft_diffusion_interpret(data)` assigns a Hughes & Pride 2012
  clinical category (Normal / Parenchymal / Volume loss / Mixed /
  Vascular / Elevated KCO / Other) from DLCO, VA, KCO z-scores. Run
  automatically by `pft_interpret()` when the diffusion z-scores are
  present.
* `pft_volume_subpattern(data)` differentiates the six Stanojevic
  2022 Figure 10 lung-volume sub-patterns (Normal lung volumes /
  Large lungs / Hyperinflation / Simple restriction / Complex
  restriction / Mixed disorder). Auto-run by `pft_interpret()` when
  the requisite ratio columns are present.
* `pft_fev1q(fev1, sex, age)` implements the FEV1Q adult mortality
  index from Stanojevic 2022 Box 3.
* `pft_dlco_hb_correct(dlco, hemoglobin, sex, age)` applies the
  Cotes 1972 / Stanojevic 2017 hemoglobin correction. Reference Hb
  is 146 g/L (males ≥ 15) or 134 g/L (females, males < 15). Hb input
  must be in g/L; the function does not detect or convert g/dL (see
  the breaking change above).

## Bug fixes

* `pft_quality()` — child age cutoff corrected from `age < 6` to
  `age <= 6` per Graham 2019 Table 10; a 6-year-old is now graded
  as a child.
* `pft_quality()` — child 10%-of-highest repeatability rule
  (Graham 2019 Table 10 footnote) was not applied; now
  `max(absolute, 0.10 * max(values))` for `age <= 6`.
* `pft_quality()` — sessions with `n >= 2` acceptable maneuvers and
  best-two diff above all A/C/D thresholds were graded F; now
  correctly graded E ("usable but with poor repeatability"). Grade
  U ("0 acceptable AND ≥ 1 usable") is not implemented because the
  function takes only acceptable maneuvers.
* `pft_gold()` — added optional `fev1fvc` argument enforcing the
  GOLD "FEV1/FVC < 0.7" prerequisite (Figure 2.10 header). Default
  preserves prior behaviour for existing callers; passing
  `fev1fvc_measured` returns `NA` for non-obstructed rows instead
  of a spurious GOLD grade.

## Maintenance

* Minimum R version bumped from 2.10 to 4.0 to match the actual
  transitive dependency floor (`rlang`, `tibble`).
* Test count: 1195 → 1424 (+229).

---

## Predecessor 2005 standard

Pellegrino 2005 interpretive primitives are now available so the
package can serve a cross-standard reclassification analysis
(comparing Stanojevic 2022 against the predecessor algorithm on the
same cohort). All constants and decision logic are verified line-by-
line against the source PDF (`papers/pellegrino_2005/`); the
extraction is documented in
`papers/pellegrino_2005/verification.md`.

* `pft_classify()` gains a `standard = c("2022", "2005")` argument.
  The 2022 path is the default and is unchanged. The 2005 path
  implements the Pellegrino et al. ERJ 2005 Figure 2 algorithm: it
  has four labels (Normal, Obstructed, Restricted, Mixed) -- no
  Non-specific category, which was introduced after 2005. Cells
  that 2022 labels "Non-specific" are labeled "Restricted" under
  2005.
* `pft_severity_2005(pctpred)` grades severity from FEV1 percent
  predicted into the five Pellegrino bands
  (mild / moderate / moderately severe / severe / very severe).
* `pft_bdr_2005(pre, post)` applies the dual >=12% AND >=200 mL
  criterion from the 2005 standard, without needing the patient's
  predicted value.
* `pft_interpret()` gains a matching `standard = c("2022", "2005")`
  argument that dispatches all three primitives to the 2005 forms in
  one call. `year` (GLI equation year) and `standard` (interpretive
  rules) are independent -- you can pair GLI 2022 race-neutral
  equations with the 2005 interpretive logic, or any other
  combination, for nuanced reclassification analyses.

## Input contract

* `pft_spirometry()`, `pft_volumes()`, `pft_diffusion()`, and
  `pft_interpret()` now accept tidyverse-style column references for
  the demographics inputs. Bare names (`sex = Sex`), strings
  (`sex = "Sex"`), and rlang injection (`sex = !!my_var`) are all
  supported. Defaults match the canonical column names, so existing
  code keeps working unchanged. The user's original column names are
  preserved in the output.
* New "Input data format" vignette walks through the data-frame
  contract, units and types per column, the override syntax, and
  common errors.

## Input normalization

* **Bug fix (silent-wrong-sex):** any `sex` value other than `"M"`
  was previously treated as `"F"` without warning, so a cohort
  with `"Male"` / `"Female"` / `"male"` etc. silently produced female
  predictions. `pft_spirometry()`, `pft_volumes()`, and
  `pft_diffusion()` now soft-correct common variants
  (`"male"` -> `"M"`, `"Female"` -> `"F"`, etc.) with a warning;
  truly unrecognised values (e.g. `"X"`, `"Unknown"`) are set to NA
  rather than mis-coded.
* `race` values are similarly soft-corrected case-insensitively with
  whitespace and synonym tolerance (`"caucasian"` -> `"Caucasian"`,
  `"white"` -> `"Caucasian"`, `"black"` -> `"AfrAm"`, etc.). All
  normalisation findings roll up into a single consolidated warning
  per call.
* Missing `sex`, `age`, `height` columns (or `race` for GLI 2012) now
  error with a clear message listing the expected names, rather than
  silently producing all-NA output.

## Reference equations

* All internal reference data is now built reproducibly from the
  official ERS / ATS source documents. `data-raw/build_gli_2012.R`,
  `build_gli_2022.R`, `build_gli_2021_volumes.R`, and
  `build_gli_2017_diffusion.R` each read the published lookup-table
  workbook (or, where unavailable, the equation table from the article
  PDF) and regenerate the corresponding `.RData` and CSV artifacts.
* `R/spirometry.R`, `R/lung_volumes.R`, `R/diffusion_capacity.R`, and
  `R/ats_classification.R` now carry `@references` to the source papers
  (and the 2020 author correction for the diffusion equations).
* Removed `data-raw/coeffs_spline_spiro.RData`: an orphan blob holding
  GLI 2012 polynomial coefficients for ages 25-95, which the package
  never used (the lookup-table approach in `R/spirometry.R` interpolates
  directly from spline values instead).

## New clinical extensions

* `pft_quality(values, age)` grades a set of acceptable spirometry
  maneuvers A-F per the Graham et al. ATS/ERS 2019 spirometry
  standardization update (doi:10.1164/rccm.201908-1590ST). Tighter
  repeatability thresholds applied for children under 6.
* `pft_gold(fev1_pctpred)` returns the GOLD COPD severity grade (1-4)
  from FEV1 % predicted.

## New interpretation primitives

* `pft_severity(zscore)` returns one of `"normal"`, `"mild"`,
  `"moderate"`, `"severe"` per the Stanojevic et al. ERJ 2022 z-score
  cut points (>= -1.645, > -2.5, > -4, <= -4).
* `pft_bdr(pre, post, predicted)` classifies BDR per
  the 2022 criterion (>10% change relative to predicted, replacing the
  earlier 12%/200 mL rule).
* `pft_prism(data)` adds a `prism` logical column flagging
  Preserved Ratio Impaired Spirometry (FEV1 below LLN, FEV1/FVC at or
  above LLN). Requires only spirometry; does not need TLC.
* `pft_change(z1, z2, r)` computes the conditional change
  z-score recommended by Stanojevic 2022 for interpreting serial PFT
  measurements over time. Configurable autocorrelation `r`.

## Workflow wrappers

* `pft_interpret(data)` is a single-call workflow that auto-detects
  which inputs are present and emits a complete Stanojevic
  2022-compliant interpretation: reference values, z-scores, percent
  predicted, severity grading, ATS pattern, PRISm flag, and
  bronchodilator response. This is the recommended entry point for
  clinical-style reporting.
* `pft_plot(result)` generates a clinical-style z-score lollipop plot
  with severity-band shading. Requires `ggplot2` (Suggests).

## New outputs: z-score and percent predicted

* `pft_spirometry()`, `pft_volumes()`, and `pft_diffusion()`
  now optionally compute z-scores and percent predicted. Supply a
  `<measure>_measured` column in the input data frame (e.g.
  `fev1_measured`, `frc_measured`, `dlco_measured`) and the function
  appends `<measure>_zscore` and `<measure>_pctpred` columns alongside
  the existing `<measure>_pred` / `<measure>_lln` / `<measure>_uln`.
  Backwards compatible: callers who only supply demographics continue
  to get the three existing reference-value columns and nothing else.
* z-score uses the LMS formula `((measured/M)^L - 1) / (L*S)`; percent
  predicted is `(measured / M) * 100`. Both propagate `NA` from the
  measured value, the LMS parameters, or the LLN as expected.
* Z-score formula sanity (z = 0 at predicted, ~+/-1.645 at LLN/ULN) is
  tested for every measure across the three functions. The GLI 2022
  oracle CSV at `tests/testthat/gli_2022_oracle.csv` covers z-score and
  percent predicted as well as predicted and LLN, validated at
  tolerance `1e-8`.

## Reference-function robustness

* **Bug fix:** `pft_spirometry()`, `pft_volumes()`, and
  `pft_diffusion()` previously crashed with a "missing value where
  TRUE/FALSE needed" error when any row had a missing value (`NA`) in
  `sex`, `age`, or `height`. They now skip such rows and emit `NA` for
  the reference values on that row, matching the behaviour already
  provided for missing `race` (spirometry) and for `pft_classify()`.
  Real clinical PFT data routinely contains missing demographics; the
  prior behaviour required callers to filter `NA`s themselves.

## ATS classification

* **Bug fix (clinically meaningful):** the `ANNN` and `NANN` pattern
  labels were inverted relative to Stanojevic et al. ERJ 2022 Figure 8.
  The classifier now correctly returns:
    - `ANNN` (isolated low FEV1) → "Normal"
    - `NANN` (isolated low FVC) → "Non-specific"
  Previously these two were swapped. The change re-labels patients
  whose spirometry profile is "isolated low FEV1 + everything else
  normal" (previously "Non-specific", now "Normal") and vice versa for
  isolated low FVC. See `notes/ats_classification_label_fix.md` for the
  clinical-review memo.
* **Bug fix:** the "all normal" branch was comparing FVC against
  `fev1_lln` instead of `fvc_lln`. Combined with the label-swap above,
  patients with FVC slightly below their FVC LLN but above their FEV1
  LLN are now correctly routed to "Non-specific" rather than being
  silently labelled "Normal".
* The classifier was expanded to all 16 combinations of (FEV1, FVC,
  FEV1/FVC, TLC) statuses, adding the new `ats_pattern_combination`
  output column. (Initial expansion: previous internal release; bug
  fixes: this release.)

## Tests

* Test coverage grew from 36 to 101 tests across all four test files.
  Additions include predicted-value (median) comparisons against the GLI
  web calculator ground truth, `NA`-propagation tests, out-of-range
  tests, structural / column-contract tests, and a clinical-scenario
  suite for `ats_classification` grounded in Stanojevic 2022 Figure 8 /
  Table 5 / Table 8.
* New GLI 2022 / "GLI Global" oracle: a frozen 30-row ground-truth
  fixture at `tests/testthat/gli_2022_oracle.csv` covering predicted,
  LLN, z-score, and percent predicted for FEV1, FVC, and FEV1/FVC.
  Regenerated via `data-raw/build_gli_2022_oracle.R` (see that script
  for provenance). Only the static CSV ships; no test-time dependency
  on any external package.

## Documentation

* New README with installation, usage, citations, and a research-use
  disclaimer.
* New `inst/CITATION` so `citation("pft")` returns the package and the
  underlying reference papers as `bibentry` objects.
* The MIT license file is now correctly declared in `DESCRIPTION` and
  shipped via `LICENSE` / `LICENSE.md`.
* `notes/ats_classification_label_fix.md` records the rationale and
  clinical-review questions for the ATS pattern-label changes above.

## Internal

* Project now uses `renv` for dependency management.
* Reference paper PDFs and supplement workbooks live under `papers/`
  but are excluded from git and from the `R CMD build` tarball (they
  are copyrighted publisher content).
* `notes/` (clinical-review memos and similar) is also `Rbuildignore`d.
  `docs/` is reserved for the pkgdown-built site (gitignored; built and
  deployed to the `gh-pages` branch by `.github/workflows/pkgdown.yaml`).
