From caf9c6febd63cbb7bf399ddb10038a39050d6c06 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 10 Oct 2026 02:16:39 +0000 Subject: [PATCH 01/12] docs: workflow, intervals, and imputation for PKNCA 0.13.0 Co-Authored-By: Claude Opus 5.5 --- user/imputation.qmd | 228 +++++++++++++++++++++------ user/intervals.qmd | 366 ++++++++++++++++++++++++++++++++++++++++---- user/workflow.qmd | 77 +++++++--- 3 files changed, 566 insertions(+), 105 deletions(-) diff --git a/user/imputation.qmd b/user/imputation.qmd index 5acffdf..ef39564 100644 --- a/user/imputation.qmd +++ b/user/imputation.qmd @@ -37,7 +37,7 @@ d_dose <- Theoph_df |> filter(Time == 0) |> mutate(amt = Dose * Wt) -# Without imputation there is no concentration at the interval start +# No concentration remains at the dose time (the interval start) o_conc <- PKNCAconc(d_conc_no0, conc ~ Time | Subject) o_dose <- PKNCAdose(d_dose, amt ~ Time | Subject) ``` @@ -49,30 +49,56 @@ d_conc_no0 |> head(5) ``` +### Imputation in the automatic intervals + +When `PKNCAdata()` generates the intervals itself (no `intervals` argument), each interval already carries an imputation method in an `impute` column (≥ 0.13.0): `start_predose_conc0` after a single dose, `start_cmin` or `start_predose` for a dosing interval, none for the back-extrapolated `c0` after an intravenous bolus, and only `start_conc0` (single dose) or nothing for sparse data. The `impute` argument of `PKNCAdata()` keeps its default of `NA`, which means "read the column named `impute`": + +```{r} +o_data_auto <- PKNCAdata(o_conc, o_dose) +o_data_auto$impute +o_data_auto$intervals |> + filter(Subject == "1") |> + select(Subject, start, end, impute) +``` + +So with these data, a plain `pk.nca(o_data_auto)` already imputes a concentration at time 0. Passing a method string as `impute`, as the examples below do, applies that method to every interval instead of the column. The intervals themselves are described in the [Interval Selection chapter](intervals.qmd) and the [Automatic Intervals and Dosing Regimens chapter](auto-intervals.qmd). Before 0.13.0, the automatic intervals had no imputation. With `auto.interval.method = "legacy"`, which restores that behavior, these data give `NA` for `auclast` and `aucinf.obs`, excluded with the reason that the interval starts (at 0) before the first measurement. + --- -## The three built-in start-of-interval strategies +## The built-in imputation methods -You can inspect the signatures with `args()`: +`pknca_impute_methods()` (≥ 0.13.0) lists the built-in methods with the names that imputation strings use, a one-line description, and their arguments: ```{r} -args(PKNCA_impute_method_start_conc0) -args(PKNCA_impute_method_start_predose) -args(PKNCA_impute_method_start_cmin) +d_methods <- pknca_impute_methods() +d_methods$method +d_methods$description ``` | Name | String to use | What it does | |------|--------------|--------------| -| `PKNCA_impute_method_start_conc0` | `"start_conc0"` | Inserts a concentration of 0 at the interval start time | -| `PKNCA_impute_method_start_predose` | `"start_predose"` | Uses the last observed concentration before the interval start | -| `PKNCA_impute_method_start_cmin` | `"start_cmin"` | Uses the minimum concentration observed within the interval | +| `PKNCA_impute_method_start_conc0` | `"start_conc0"` | Sets the concentration at the interval start to 0, adding a row if there is none and **replacing** a measured value if there is one | +| `PKNCA_impute_method_start_predose` | `"start_predose"` | When there is no concentration at the start, shifts the most recent predose concentration (within `max_shift`) to the start | +| `PKNCA_impute_method_start_cmin` | `"start_cmin"` | When there is no concentration at the start, adds the minimum concentration observed within the interval there | +| `PKNCA_impute_method_start_predose_conc0` | `"start_predose_conc0"` | Keeps a concentration measured at the start; otherwise shifts a predose concentration there like `start_predose`; otherwise adds 0 (≥ 0.13.0) | +| `PKNCA_impute_method_end_conc_drop` | `"end_conc_drop"` | Drops a concentration measured exactly at the interval end from that interval (≥ 0.13.0) | -All three built-in methods impute a concentration at the **start** of the interval; newer development versions of PKNCA also provide `PKNCA_impute_method_end_conc_drop`, which instead drops a next-dose sample observed exactly at the interval end (not shown here, as it is not yet in a released version). +The four `start_*` methods act at the **start** of the interval; `end_conc_drop` acts at its end. You can inspect the signatures with `args()`: + +```{r} +args(PKNCA_impute_method_start_conc0) +args(PKNCA_impute_method_start_predose) +args(PKNCA_impute_method_start_cmin) +args(PKNCA_impute_method_start_predose_conc0) +args(PKNCA_impute_method_end_conc_drop) +``` ### `start_conc0` — zero concentration at interval start Appropriate in a single-dose setting when no drug is expected before dosing. -The drug concentration at time 0 is assumed to be 0. +The drug concentration at time 0 is assumed to be 0. A concentration measured +at the start time is replaced with 0, so a nonzero time-0 sample (3 of the 12 +`Theoph` subjects have one) is discarded. ```{r} o_data_c0 <- PKNCAdata(o_conc, o_dose, impute = "start_conc0") @@ -114,9 +140,43 @@ o_data_cmin <- PKNCAdata(o_conc, o_dose, impute = "start_cmin") o_data_cmin$impute ``` -### The three strategies side by side +### `start_predose_conc0` — predose if available, otherwise zero + +Appropriate whenever you want a predose concentration used when one exists and +zero when it does not (≥ 0.13.0); it is what the automatic single-dose interval +uses. It also keeps a concentration measured at the start time, which matters +after an intravenous bolus, where that measurement is the C0 for the dose. +The [chaining section](#chaining-methods) below shows why the chain +`"start_predose,start_conc0"` does not do the same thing. + +```{r} +o_data_pdc0 <- PKNCAdata(o_conc, o_dose, impute = "start_predose_conc0") +o_data_pdc0$impute +``` + +### `end_conc_drop` — drop a concentration at the interval end -Running all three on subject 1 (whose first sample is at t = 0.25) makes the +The other methods add or change a point at the start of the interval; +`end_conc_drop` (≥ 0.13.0) removes a concentration observed exactly at the +interval `end`. It is meant for multiple-dose data where a sample at the +boundary belongs to the next dose, for example a post-dose concentration (or an +imputed C0) recorded at the time the next dose was given. Called directly, it +drops the 24 h point: + +```{r} +PKNCA_impute_method_end_conc_drop( + conc = c(10, 7, 5, 3, 11), + time = c(0, 2, 6, 12, 24), + end = 24 +) +``` + +In an analysis, combine it with a start method in a chain, such as +`"start_predose,end_conc_drop"`. + +### The start strategies side by side + +Running the four start methods on subject 1 (whose first sample is at t = 0.25) makes the differences concrete: ```{r} @@ -133,8 +193,9 @@ auclast_with <- function(method) { } bind_rows(auclast_with("start_conc0"), - auclast_with("start_predose"), - auclast_with("start_cmin")) + auclast_with("start_predose"), + auclast_with("start_cmin"), + auclast_with("start_predose_conc0")) ``` `start_conc0` adds conc = 0 at t = 0, while `start_cmin` adds the minimum @@ -143,6 +204,8 @@ so `start_cmin` gives the slightly larger AUC. `start_predose` looks for a sample at or before the interval start, and this dataset has none: it imputes nothing, the interval still has no concentration at its start time, and `auclast` is `NA` (the warning printed by the chunk explains why). +`start_predose_conc0` finds no predose sample either, falls back to adding a +zero, and so matches `start_conc0`. --- @@ -151,7 +214,8 @@ nothing, the interval still has no concentration at its start time, and The single-strategy examples above already used the global form: pass a method name string to the `impute` argument of `PKNCAdata()`, and the same strategy is applied to every subject and every interval — the side-by-side comparison -restricted itself to subject 1 only to keep the output small. +restricted itself to subject 1 only to keep the output small. With automatic +intervals, the string replaces the methods in their `impute` column. ```{r} o_data_global <- PKNCAdata(o_conc, o_dose, impute = "start_conc0") @@ -170,42 +234,86 @@ df_global |> --- -## Chaining methods +## Chaining methods {#chaining-methods} -The side-by-side comparison left `start_predose` with an `NA` because this -dataset has no pre-dose sample. Chaining resolves exactly that situation: -provide a comma-separated string to apply methods in sequence. Methods are -applied in order; a later method has no effect if an earlier one already -added a point at the interval start. +Provide a comma-separated string to apply several methods to the same +interval. Methods are applied **in order, left to right, each to the output of +the previous one**, so a later method sees, and can change, what an earlier +one did: -```{r} -# Try start_predose first; if it cannot find a pre-dose observation, -# fall back to inserting zero -o_data_chain <- PKNCAdata(o_conc, o_dose, - impute = "start_predose,start_conc0") -o_data_chain$impute -``` +- `start_predose`, `start_cmin`, and `start_predose_conc0` only add a point + when there is no concentration at the start yet, so after an earlier method + has supplied one they have no effect. +- `start_conc0` sets the start concentration to 0 whether or not one is + there, so it **overwrites** whatever an earlier method shifted or added. + `"start_predose,start_conc0"` therefore always gives the same result as + `"start_conc0"` alone. + +To use a predose concentration when one exists and zero otherwise, use +`start_predose_conc0` rather than a chain. Three small profiles make the +difference concrete: one with a predose sample at −0.25 h, one whose first +sample is at 0.5 h, and one with a concentration measured at the dose time: ```{r} -res_chain <- pk.nca(o_data_chain) +d_conc_pd <- data.frame( + Subject = rep(c("predose", "no_predose", "c0_measured"), c(7, 6, 7)), + time = c(-0.25, 0.5, 1, 2, 4, 8, 24, + 0.5, 1, 2, 4, 8, 24, + 0, 0.5, 1, 2, 4, 8, 24), + conc = c(2.0, 4.0, 6.0, 5.5, 4.0, 2.5, 0.6, + 4.0, 6.0, 5.5, 4.0, 2.5, 0.6, + 9.0, 7.5, 6.6, 5.4, 4.0, 2.5, 0.6) +) +d_dose_pd <- data.frame( + Subject = c("predose", "no_predose", "c0_measured"), + time = 0, + dose = 100 +) +o_conc_pd <- PKNCAconc(d_conc_pd, conc ~ time | Subject) +o_dose_pd <- PKNCAdose(d_dose_pd, dose ~ time | Subject) + +auclast_by_method <- function(method) { + res <- as.data.frame(pk.nca(PKNCAdata( + o_conc_pd, o_dose_pd, + intervals = data.frame(start = 0, end = Inf, auclast = TRUE), + impute = method + ))) + res <- res[res$PPTESTCD == "auclast", ] + setNames(res$PPORRES, as.character(res$Subject)) +} -as.data.frame(res_chain) |> - filter(Subject == "1", PPTESTCD == "auclast") |> - select(Subject, PPTESTCD, PPORRES) +methods <- c("start_conc0", "start_predose", + "start_predose,start_conc0", "start_predose_conc0") +round(sapply(methods, auclast_by_method), 2) ``` -For subject 1 the chained result matches the global `start_conc0` run in the -previous section — the same auclast (92.3) appears in the first row of both -displays: `start_predose` found no pre-dose sample to carry forward, so the -fallback inserted the zero. +The warning comes from `start_predose` for the `no_predose` profile, which has +nothing at or before time 0 to shift, so its `auclast` is `NA`. Reading the +columns: + +- `start_conc0` puts 0 at time 0 for all three, discarding the predose + sample and replacing the measured 9.0 at time 0. +- `"start_predose,start_conc0"` gives exactly the `start_conc0` column: the + predose 2.0 that `start_predose` shifted to time 0 is overwritten with 0. +- `start_predose_conc0` uses the shifted predose concentration for `predose`, + adds 0 for `no_predose`, and keeps the measured 9.0 for `c0_measured`. + +Chains remain useful when a later method fills a gap rather than overwriting, +for example `"start_predose,start_cmin"` (use a predose sample, otherwise the +interval minimum), or when the methods act at different ends, as in +`"start_predose,end_conc_drop"`. `interval_add_impute()` (with its `after` +argument) and `interval_remove_impute()` edit chains in an interval table; see +[Editing intervals](intervals.qmd#editing-intervals). --- ## Per-interval imputation -Different intervals in the same dataset can use different strategies. Add an -`impute` column to the intervals data frame, then pass the column name (as a -string) to the `impute` argument of `PKNCAdata()`. The same setup is shown in +Different intervals in the same dataset can use different strategies. Add a +character column of methods to the intervals data frame, then pass the column +name (as a string) to the `impute` argument of `PKNCAdata()`. The column may +have any name (≥ 0.13.0); a column named `impute` is also found when the +`impute` argument is left at its default of `NA`. The same setup is shown in [the vignette's per-interval section](https://humanpred.github.io/pknca/articles/v08-data-imputation.html#imputation-by-calculation-interval). ```{r} @@ -215,7 +323,7 @@ my_intervals <- data.frame( auclast = c(TRUE, FALSE), aucinf.obs = c(FALSE, TRUE), cmax = c(TRUE, TRUE), - impute = c("start_conc0", "start_predose,start_conc0") + impute = c("start_conc0", "start_predose_conc0") ) o_data_per_interval <- PKNCAdata( @@ -228,6 +336,14 @@ o_data_per_interval$impute o_data_per_interval$intervals[, c("start", "end", "impute")] ``` +`get_impute_column()` and `get_impute_method()` (≥ 0.13.0) apply the same +rules that `pk.nca()` does to find the column and the method for each row: + +```{r} +get_impute_column(o_data_per_interval$intervals, impute = o_data_per_interval$impute) +get_impute_method(o_data_per_interval$intervals, impute = o_data_per_interval$impute) +``` + ```{r} res_per <- pk.nca(o_data_per_interval) @@ -245,7 +361,9 @@ as.data.frame(res_per) |> The function name **must** follow the pattern `PKNCA_impute_method_`. PKNCA prepends `PKNCA_impute_method_` to the string you supply and looks up -that single function on the search path, so the convention is mandatory. +that single function on the search path, so the convention is mandatory. A +function of your own is found this way without being registered, so it works +in imputation strings but is not listed by `pknca_impute_methods()`. ### Required signature @@ -306,23 +424,31 @@ The vignette's [advanced section](https://humanpred.github.io/pknca/articles/v08 | Situation | Recommended strategy | |-----------|----------------------| -| Single-dose study, no pre-dose drug expected | `start_conc0` | +| Single-dose study; use a predose sample if there is one, otherwise assume zero | `start_predose_conc0` (the automatic single-dose choice) | +| Single-dose study, no pre-dose drug expected, any time-0 value to be ignored | `start_conc0` | | Multiple-dose or steady-state, trough sample collected | `start_predose` | | Endogenous analyte or metabolite, zero implausible | `start_cmin` | -| Uncertain: try pre-dose, fall back to zero | `"start_predose,start_conc0"` | +| Intravenous bolus with a measured concentration at the dose time | `start_predose_conc0` (keeps it), or no imputation and let `c0` back-extrapolate | +| Sample at the interval end that belongs to the next dose | add `end_conc_drop` to the chain | +| Sparse sampling | `start_conc0` only; the sparse estimators refuse other imputed values (`pknca_error_sparse_impute`); see the [Sparse Sampling chapter](sparse.qmd) | | Non-standard logic required | Custom `PKNCA_impute_method_*` function | --- ## Summary -- Imputation is triggered when no observation exists at the interval start time. -- The three built-in start-of-interval methods are referenced by their suffix - strings: `"start_conc0"`, `"start_predose"`, `"start_cmin"`. +- `start_conc0` sets the interval start to 0 even when a concentration was + measured there; the other start methods act only when no concentration + exists at the interval start time. +- The built-in methods are referenced by their suffix strings: + `"start_conc0"`, `"start_predose"`, `"start_cmin"`, `"start_predose_conc0"`, + and `"end_conc_drop"`; `pknca_impute_methods()` lists them. +- Automatic intervals carry their own `impute` column (≥ 0.13.0). - Global imputation: `PKNCAdata(..., impute = "start_conc0")`. -- Chained methods: `impute = "start_predose,start_conc0"`. -- Per-interval: add an `impute` column to the intervals data frame and pass - that column name to the `impute` argument of `PKNCAdata()`. +- Chained methods apply left to right, and `start_conc0` overwrites what + earlier methods did; use `start_predose_conc0` for "predose, else zero". +- Per-interval: add a character column of methods (any name) to the intervals + and pass that column name to the `impute` argument of `PKNCAdata()`. - Custom functions: name them `PKNCA_impute_method_` and return a two-column data frame (`conc`, `time`). @@ -330,5 +456,5 @@ The vignette's [advanced section](https://humanpred.github.io/pknca/articles/v08 --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [PKNCA_impute_method_start_conc0()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) · [PKNCA_impute_method_start_predose()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) · [PKNCA_impute_method_start_cmin()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [PKNCA_impute_method_start_conc0()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) · [PKNCA_impute_method_start_predose()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) · [PKNCA_impute_method_start_cmin()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) · [PKNCA_impute_method_start_predose_conc0()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) · [PKNCA_impute_method_end_conc_drop()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) · [pknca_impute_methods()](https://humanpred.github.io/pknca/reference/pknca_impute_methods.html) · [get_impute_column()](https://humanpred.github.io/pknca/reference/get_impute_column.html) · [get_impute_method()](https://humanpred.github.io/pknca/reference/get_impute_method.html) · [interval_add_impute()](https://humanpred.github.io/pknca/reference/interval_add_impute.html) ::: diff --git a/user/intervals.qmd b/user/intervals.qmd index 6f252a6..27d73ce 100644 --- a/user/intervals.qmd +++ b/user/intervals.qmd @@ -21,7 +21,14 @@ An interval specification is a plain R `data.frame` with one row per calculation | `start` | numeric | Start of the calculation window (same units as time) | | `end` | numeric | End of the calculation window; use `Inf` for open-ended windows | -Every remaining column is normally a **parameter flag**: its name must match a known PKNCA parameter name and its value must be `TRUE` (calculate) or `FALSE` (skip). Columns set to `FALSE` are simply ignored, so you only need to list the parameters you actually want. The exceptions are grouping columns used for matching (see the group-matching section below), an `impute` column (see the [Concentration Imputation chapter](imputation.qmd)), and columns carried through to the results via the `keep_interval_cols` option. +Every remaining column is normally a **parameter flag**: its name must match a known PKNCA parameter name and its value must be `TRUE` (calculate) or `FALSE` (skip). Columns set to `FALSE` are simply ignored, so you only need to list the parameters you actually want. The exceptions are: + +- grouping columns used for matching (see the group-matching section below); +- the imputation column, which may have any name and is the column that the `impute` argument of `PKNCAdata()` names (see [Imputation columns](#imputation-columns) below and the [Concentration Imputation chapter](imputation.qmd)); +- an `interval_id` column and `_ref` columns, which link an interval to a reference interval for secondary parameters such as renal clearance, bioavailability, and ratios (≥ 0.13.0; see the [Secondary Parameters chapter](secondary-parameters.qmd)); and +- columns carried through to the results via the `keep_interval_cols` option. + +Any other column is an error (`pknca_error_invalid_interval_columns`) when the intervals are checked by `PKNCAdata(intervals = )`, `set_intervals()`, or `pk.nca()` (≥ 0.13.0; see [Validating intervals](#validating-intervals)). ```{r interval-structure} # A minimal interval: AUClast over the first 24 hours @@ -94,18 +101,7 @@ as.data.frame(o_nca_subj) |> ## Default intervals with no `intervals` argument -When you call `PKNCAdata()` without an explicit `intervals` argument, PKNCA substitutes the built-in default single-dose intervals. These are stored in `PKNCA.options("single.dose.aucs")`: - -```{r default-intervals} -PKNCA.options("single.dose.aucs") -``` - -The two default rows are: - -- **Row 1** (`end = 24`): calculates `auclast` only — the workhorse for most single-dose studies. -- **Row 2** (`end = Inf`): calculates `cmax`, `tmax`, `half.life`, and `aucinf.obs`. - -These defaults are suitable for a single-dose extravascular study with at least 24 hours of sampling. For any other design (multiple dose, IV, urine, etc.) you should supply explicit intervals. To change the default, set `single.dose.aucs` — see [Options for Controlling PKNCA](https://humanpred.github.io/pknca/articles/v40-options-for-controlling-PKNCA.html). +When you call `PKNCAdata()` without an `intervals` argument, PKNCA generates intervals from the dose times of each group. It finds the dosing regimen, places the intervals (after a single dose, after the first dose, over the last dosing interval at steady state), and asks `pknca_interval_table()` (described [below](#building-intervals)) for the parameters and the imputation that suit each interval (≥ 0.13.0). For a single extravascular dose, as in `Theoph`, every subject gets one interval from the dose to infinity: ```{r use-default} o_dose <- PKNCAdose( @@ -115,14 +111,38 @@ o_dose <- PKNCAdose( Dose ~ Time | Subject ) -# No intervals argument — uses PKNCA.options("single.dose.aucs") automatically +# No intervals argument: intervals are generated automatically o_data_default <- PKNCAdata( PKNCAconc(Theoph, conc ~ Time | Subject), o_dose ) -o_data_default$intervals + +# Show subject 1, keeping only the columns that request a parameter +o_data_default$intervals |> + filter(Subject == "1") |> + select(where(~ !is.logical(.x) || any(.x))) +``` + +The row requests `auclast`, `cmax`, `tmax`, `tlag`, `count_conc`, `half.life`, `aucinf.obs`, `aucpext.obs`, and `cl.obs`, and its `impute` column holds `start_predose_conc0`, which keeps a concentration measured at the dose time, carries a predose concentration forward when there is none, and imputes 0 otherwise. An intravenous bolus gets an extra row that back-extrapolates `c0` without imputation, multiple-dose data get rows built on the `aucint.*` family, and a sparse design imputes only a zero at a single dose. The [Automatic Intervals and Dosing Regimens chapter](auto-intervals.qmd) covers how the regimen is detected, the `auto.interval.tolerance` and `tau.choices` options, and the intervals each design gets. + +::: {.callout-note} +## Changed in 0.13.0 +Before 0.13.0, a single dose got the two rows of the `single.dose.aucs` option instead: `auclast` over 0 to 24 and `cmax`, `tmax`, `half.life`, and `aucinf.obs` over 0 to infinity, with no imputation. The option still exists and is used when `auto.interval.method = "legacy"`: + +```{r default-legacy} +o_data_legacy <- PKNCAdata( + PKNCAconc(Theoph, conc ~ Time | Subject), + o_dose, + options = list(auto.interval.method = "legacy") +) +o_data_legacy$intervals |> + filter(Subject == "1") |> + select(where(~ !is.logical(.x) || any(.x))) ``` +The 0 to 24 window of `single.dose.aucs` is written for hours; with a time unit other than hours, the legacy method warns (`pknca_warning_single_dose_aucs_unit`). See also [Options for Controlling PKNCA](https://humanpred.github.io/pknca/articles/v40-options-for-controlling-PKNCA.html). +::: + --- ## Multiple intervals per group @@ -185,9 +205,247 @@ For IV infusions, `setDuration()` on the dose object sets the infusion duration, --- +## Building intervals with `pknca_interval_table()` {#building-intervals} + +`pknca_interval_table()` (≥ 0.13.0) builds an interval specification from a description of the analysis instead of a list of parameter names. Given a start, an end, how often the drug was given (`dosing`), and how (`route`), it chooses the parameters usually reported for that context, the AUC they are built on, and the imputation to calculate them from. It is the function the automatic intervals use, so for a single extravascular dose it returns the same row as the default intervals above: + +```{r interval-table-single} +pknca_interval_table(start = 0, end = Inf, dosing = "single", route = "extravascular") |> + select(where(~ !is.logical(.x) || any(.x))) +``` + +The arguments describe the context: + +| Argument | Values | Meaning | +|---|---|---| +| `start`, `end` | numbers (vectors allowed) | The interval bounds; vectors give one set of rows per interval | +| `dosing` | `"single"` (default), `"multiple"`, `"steady_state"` | Given once, repeatedly, or repeatedly at steady state (`pknca_dosing()`) | +| `route` | `"extravascular"` (default), `"iv_bolus"`, `"iv_infusion"`, `"iv_continuous_infusion"` | How the drug was given (`pknca_routes()`); note these are not the `"intravascular"` spelling of `PKNCAdose()` | +| `sample_type` | `"spot"` (default), `"interval"` | Samples at a point in time (blood, plasma) or collections over an interval (urine, feces) (`pknca_sample_types()`) | +| `sparse` | `FALSE` (default), `TRUE` | A sparse design keeps the same parameters but imputes only `start_conc0` at a single dose and nothing otherwise | +| `tier` | `"common"` (default), `"all"` | The parameters usually reported, or every parameter the context can calculate | +| `include`, `exclude` | parameter names or concepts | Add to or remove from what the context gives; naming one in both is an error | +| `impute` | `NULL` (default), a method string, or `NA` | `NULL` chooses the imputation from the context; `NA` uses none | +| `preset` | a name from `pknca_presets()` | A named set of the arguments above to start from; explicit arguments override it | +| `clast_type` | `"obs"` (default), `"pred"` | Extrapolate to infinity from the observed or the predicted last concentration | +| `...` | named values | Columns added to every row, typically the groups the interval applies to | + +A single dose is built on AUClast and its extrapolation to infinity; a repeated dose is built on the `aucint.*` family, which interpolates at both interval boundaries, with clearance, volume, and MRT chosen to match. At steady state with the fluctuation parameters added (and a group column given through `...`): + +```{r interval-table-ss} +pknca_interval_table( + start = 144, end = 168, + dosing = "steady_state", route = "extravascular", + include = "fluctuation", + Subject = "1" +) |> + select(where(~ !is.logical(.x) || any(.x))) +``` + +`include` and `exclude` accept parameter names or the concepts listed by `pknca_concepts()`, so `include = "mrt"` adds the MRT that matches the AUC basis and `exclude = "clearance"` drops `cl.obs`: + +```{r interval-table-include} +pknca_interval_table(0, Inf, include = c("mrt", "aumclast"), exclude = "clearance") |> + select(where(~ !is.logical(.x) || any(.x))) +``` + +When an imputed value would become the answer, the parameter is kept out of the imputation by splitting the interval into more than one row. After an intravenous bolus, `c0` is back-extrapolated in a second row with no imputation, so that an imputed concentration at the dose time cannot become C0: + +```{r interval-table-iv} +pknca_interval_table(0, Inf, dosing = "single", route = "iv_bolus") |> + select(where(~ !is.logical(.x) || any(.x))) +``` + +`tier = "all"` gives every parameter the context can calculate (`r sum(vapply(pknca_interval_table(0, Inf, tier = "all"), function(v) is.logical(v) && any(v), logical(1)))` parameters for a single extravascular dose, rather than 9), which is a convenient starting point that you can then trim with `interval_remove_param()`. + +The table is an ordinary interval data frame, so it goes straight into `PKNCAdata()`. Its `impute` column is read because `impute` defaults to `NA`, which looks for a column named `"impute"`: + +```{r interval-table-run} +o_conc <- PKNCAconc(Theoph, conc ~ Time | Subject) +d_int_table <- pknca_interval_table(0, Inf, include = "aumclast") +o_nca_table <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = d_int_table)) +as.data.frame(o_nca_table) |> + filter(Subject == "1") |> + select(Subject, start, end, PPTESTCD, PPORRES) +``` + +### Presets + +`pknca_presets()` gives named argument sets for common analyses. Each is a list of arguments to `pknca_interval_table()`: + +```{r presets} +names(pknca_presets()) +pknca_presets()$bioequivalence +``` + +| Preset | Arguments it sets | +|---|---| +| `single_dose` | `dosing = "single"`, `route = "extravascular"` | +| `steady_state` | `dosing = "steady_state"`, `route = "extravascular"` | +| `bioequivalence` | single extravascular dose, `include = c("aucall", "clast.obs", "tlast")` | +| `first_in_human` | single extravascular dose, `include = c("clast.obs", "tlast", "span.ratio")` | +| `mass_balance` | single extravascular dose, `sample_type = "interval"`, `include = "excretion_rate"` | +| `sparse_single_dose` | single extravascular dose, `sparse = TRUE` | + +Arguments given directly override the preset, here removing the imputation: + +```{r presets-use} +pknca_interval_table(0, Inf, preset = "bioequivalence", impute = NA) |> + select(where(~ !is.logical(.x) || any(.x))) +``` + +The [Bioequivalence chapter](bioequivalence.qmd) uses the `bioequivalence` preset, and the [Sparse Sampling chapter](sparse.qmd) covers sparse designs. + +--- + +## How parameters are classified with `pknca_parameter_table()` + +`pknca_interval_table()` chooses parameters from a classification of every registered parameter (≥ 0.13.0). `pknca_parameter_table()` shows it: the `concept` a parameter computes, its reporting `tier`, its `sample_type`, whether it is a `sparse` estimator output, `secondary` (needs inputs from more than one profile), or `dose_normalized`, and the comma-separated `route` and `dosing` contexts it applies to. + +```{r param-table} +pknca_parameter_table(c("auclast", "aucint.last", "cmax", "cl.obs", "ae", "f.obs")) |> + select(parameter, concept, tier, sample_type, secondary, dosing) +``` + +The vocabularies have their own listing functions: `pknca_concepts()`, `pknca_tiers()`, `pknca_dosing()`, and `pknca_sample_types()` (with `pknca_routes()` for routes). + +```{r param-vocab} +pknca_concepts() +pknca_tiers() +``` + +A parameter's concept is declared on its calculation function with `pknca_concept()`, so a parameter registered by another package (or by you, see the [Writing Custom Parameter Functions chapter](custom-parameters.qmd)) can be chosen automatically too. An unclassified parameter is not an error: it is calculated when you request it by name but is never selected automatically, and `pknca_check_parameter_classification()` lists such parameters (zero rows means every registered parameter is classified). + +```{r param-concept} +pknca_concept(pk.calc.cmax) +nrow(pknca_check_parameter_classification()) +``` + +Secondary parameters (`f.obs`, `clr.obs`, `ratio.*`, and others) are never selected automatically, because one interval cannot say which other profile is the reference; request them by name as described in the [Secondary Parameters chapter](secondary-parameters.qmd). + +--- + +## Editing intervals {#editing-intervals} + +Four functions edit an interval specification (≥ 0.13.0). Each accepts either a data frame of intervals or a `PKNCAdata` object (whose `$intervals` it edits), so you can start from the automatic intervals or from `pknca_interval_table()` and adjust them: + +| Function | What it does | Key arguments | +|---|---|---| +| `interval_add_param()` | Request more parameters | `param` (names), `param_pattern` (regular expressions), `target_groups` | +| `interval_remove_param()` | Stop requesting parameters | `param`, `param_pattern`, `target_groups` | +| `interval_add_impute()` | Add an imputation method | `target_impute`, `after` (position in an existing chain: `0` first, `Inf` last), `target_params`, `target_groups` | +| `interval_remove_impute()` | Remove an imputation method | `target_impute`, `target_params`, `target_groups` | + +`target_groups` is a data frame of group values; a row of the intervals is edited when it matches every column of at least one of its rows. + +```{r edit-param} +d_edit <- data.frame(start = 0, end = Inf, cmax = TRUE, tmax = TRUE) + +# Add parameters by name, or by regular expression +interval_add_param(d_edit, param = c("auclast", "half.life")) |> + select(where(~ !is.logical(.x) || any(.x))) +interval_add_param(d_edit, param_pattern = "^aucint\\.(last|all)$") |> + select(where(~ !is.logical(.x) || any(.x))) +``` + +Removing every parameter from every interval is an error (`pknca_error_interval_nothing_remains`), since nothing would be left to calculate. + +When the parameters of one row no longer share the same imputation, the row is split into as many rows as needed, and rows that come to share every value are merged again. Imputing a start concentration for `auclast` only splits the row: + +```{r edit-impute} +d_edit2 <- data.frame(start = 0, end = Inf, cmax = TRUE, auclast = TRUE, half.life = TRUE) +interval_add_impute(d_edit2, target_impute = "start_conc0", target_params = "auclast") |> + select(where(~ !is.logical(.x) || any(.x))) +``` + +Editing a `PKNCAdata` object changes all of its intervals at once. Starting from the automatic `Theoph` intervals, drop `tlag` and `count_conc`, add `aumclast`, and calculate the half-life without the imputed start concentration: + +```{r edit-pkncadata} +o_data_edited <- o_data_default |> + interval_remove_param(param = c("tlag", "count_conc")) |> + interval_add_param(param = "aumclast") |> + interval_remove_impute(target_impute = "start_predose_conc0", target_params = "half.life") + +o_data_edited$intervals |> + filter(Subject == "1") |> + select(where(~ !is.logical(.x) || any(.x))) +``` + +`target_groups` restricts an edit to some groups, here subject 2 only: + +```{r edit-groups} +d_edit3 <- data.frame(Subject = c("1", "2"), start = 0, end = Inf, auclast = TRUE) +interval_add_impute(d_edit3, target_impute = "start_conc0", + target_groups = data.frame(Subject = "2")) +``` + +### Imputation columns {#imputation-columns} + +The imputation column of an interval table may have any name (≥ 0.13.0). `PKNCAdata(impute = )` reads it this way: + +1. If `impute` names a column of the intervals, each row's method comes from that column. +2. Otherwise, if `impute` is `NA` (the default) and the intervals have a column named `"impute"`, each row's method comes from that column. This is how the automatic intervals and `pknca_interval_table()` carry their imputation. +3. Otherwise, `impute` is itself the method string for every interval (an `NA` with no `"impute"` column means no imputation). + +The column must be character (`pknca_error_interval_impute_not_character`), except that a column of only `NA` means no imputation. `get_impute_column()` and `get_impute_method()` apply the same rule, which is useful when you write code around PKNCA objects: + +```{r impute-column} +d_int_named <- data.frame( + start = c(0, 0), end = c(24, Inf), + auclast = c(TRUE, FALSE), aucinf.obs = c(FALSE, TRUE), + my_impute = c("start_conc0", "start_predose_conc0") +) +o_data_named <- PKNCAdata(o_conc, o_dose, intervals = d_int_named, impute = "my_impute") +get_impute_column(o_data_named$intervals, impute = o_data_named$impute) +get_impute_method(o_data_named$intervals, impute = o_data_named$impute) + +# A method string is not a column +get_impute_column(d_int_named, impute = "start_conc0") +``` + +`interval_add_impute()` and `interval_remove_impute()` edit whichever column the object's `impute` setting names. The methods themselves are described in the [Concentration Imputation chapter](imputation.qmd). + +--- + +## Date-time intervals + +With date-time (POSIXct or Date) concentration and dose times (≥ 0.13.0), `start` and `end` may also be date-times. `pk.nca()` converts each row to numeric time relative to the time reference (the first dose) of the group it applies to. A row that does not name every reference group becomes one row per group, because one clock-time window is a different relative window for each subject. An `end` of `Inf` stays infinite. + +```{r datetime-intervals} +d_conc_dt <- data.frame( + Subject = rep(c("A", "B"), each = 8), + datetime = as.POSIXct("2024-03-04 08:00", tz = "UTC") + + 3600 * c(c(0, 0.5, 1, 2, 4, 8, 12, 24), 25.5 + c(0, 0.5, 1, 2, 4, 8, 12, 24)), + conc = c(0, 4.1, 6.3, 5.9, 4.4, 2.6, 1.5, 0.4, + 0, 3.2, 5.5, 5.8, 4.6, 2.9, 1.8, 0.6) +) +d_dose_dt <- data.frame( + Subject = c("A", "B"), + datetime = as.POSIXct(c("2024-03-04 08:00", "2024-03-05 09:30"), tz = "UTC"), + dose = 100 +) +# One clock-time window for both subjects: from subject A's dose onward +d_int_dt <- data.frame( + start = as.POSIXct("2024-03-04 08:00", tz = "UTC"), + end = Inf, + cmax = TRUE +) +o_nca_dt <- pk.nca(PKNCAdata( + PKNCAconc(d_conc_dt, conc ~ datetime | Subject), + PKNCAdose(d_dose_dt, dose ~ datetime | Subject), + intervals = d_int_dt +)) +as.data.frame(o_nca_dt) |> + select(Subject, start, end, PPTESTCD, PPORRES) +``` + +Subject B was dosed 25.5 hours after subject A, so the same window starts at −25.5 h relative to subject B's dose. The converted intervals, with an `interval_time_kind` column, are in `o_nca_dt$data$intervals`. Date-time bounds mixed with finite numeric bounds, an infinite start, date-time intervals for numeric data, and a time zone that differs from the data are errors. The [Date-Time Data and Time References chapter](datetime.qmd) covers date-time data in full. + +--- + ## Full parameter catalog -The complete list of calculable parameters is available programmatically via `get.interval.cols()`. Each entry carries a description, the calculation function, unit type, and dependency information. +The complete list of calculable parameters is available programmatically via `get.interval.cols()`. Each entry carries a description, the calculation function, unit type, and dependency information. The table below joins in the `concept` and `tier` from `pknca_parameter_table()`: ```{r param-catalog} cols <- get.interval.cols() @@ -200,11 +458,16 @@ param_table <- data.frame( # Exclude the structural columns start/end from the display param_table <- param_table[!param_table$parameter %in% c("start", "end"), ] +param_table <- merge( + param_table, + pknca_parameter_table()[, c("parameter", "concept", "tier")], + by = "parameter", sort = FALSE +) rownames(param_table) <- NULL knitr::kable( param_table, - col.names = c("Parameter", "Description"), + col.names = c("Parameter", "Description", "Concept", "Tier"), caption = paste0( "All ", nrow(param_table), " calculable parameters in PKNCA ", packageVersion("PKNCA") @@ -214,16 +477,18 @@ knitr::kable( Parameters fall into broad families: -- **AUC / AUMC** — `auclast`, `aucall`, `aucinf.obs`, `aucinf.pred`, `aumclast`, and interval-restricted `aucint.*` variants -- **Concentration** — `cmax`, `cmin`, `ctrough`, `clast.obs`, `clast.pred`, `c0`, `cstart`, `ceoi` -- **Time** — `tmax`, `tmin`, `tlast`, `tfirst`, `tlag` -- **Clearance / Volume** — `cl.obs`, `cl.pred`, `vz.obs`, `vz.pred`, `vss.obs`, `vss.iv.obs`, renal `clr.*` -- **Elimination** — `half.life`, `lambda.z`, `lambda.z.n.points`, `span.ratio`, `r.squared`, `adj.r.squared` -- **Excretion** — `ae`, `fe` -- **Steady-state / multiple-dose** — `deg.fluc`, `swing`, `ptr`, `cav`, `mrt.*`, `thalf.eff.*` -- **Sparse sampling** — `sparse_auclast`, `sparse_auc_se`, `sparse_auc_df` -- **IV-specific** — `aucivlast`, `aucivpbext*`, `aucivinf.*` +- **AUC / AUMC** — `auclast`, `aucall`, `aucinf.obs`, `aucinf.pred`, `aumclast`, `aumcall`, `aumcinf.*`, the interval-restricted `aucint.*` and `aumcint.*` variants, and `aucabove.*` +- **Concentration** — `cmax`, `cmin`, `ctrough`, `clast.obs`, `clast.pred`, `c0`, `cstart`, `ceoi`, `cav` +- **Time** — `tmax`, `tmin`, `tlast`, `tfirst`, `tlag`, `time_above` +- **Clearance / volume / MRT** — the `cl.*`, `vz.*`, `vss.*`, `mrt.*`, and `kel.*` families, each in observed, predicted, last, all, interval, and IV forms (for example `cl.obs`, `cl.last`, `cl.int.inf.obs`, `cl.iv.obs`) +- **Elimination** — `half.life`, `lambda.z`, `lambda.z.n.points`, `span.ratio`, `r.squared`, `adj.r.squared`, `lambda.z.corrxy`, and the Tobit outputs +- **Excretion** — `ae`, `fe`, `volpk`, `clr.*`, `ermax`, `ertmax`, `ertlst`, `erint`, `erlst` +- **Steady-state / multiple-dose** — `deg.fluc`, `swing`, `ptr`, `mrt.md.*`, `vss.md.*`, `mrt.ivmd.*`, `vss.ivmd.*`, `thalf.eff.*` +- **Secondary (reference interval needed)** — bioavailability `f.obs`, `f.pred`, `f.last`, `f.int.*` and the `ratio.*` family (see the [Secondary Parameters chapter](secondary-parameters.qmd)) +- **Sparse estimator outputs** — `auclast_se`, `auclast_df`, and the other `_se` and `_df` companions, which arrive with the sparse estimates; the catalog also still lists the deprecated `sparse_*` and `*.sparse.last` names (see the [Sparse Sampling chapter](sparse.qmd)) +- **IV-specific** — `aucivlast`, `aucivall`, `aucivinf.*`, `aumciv*`, `aucivpbext*` - **Dose-normalized** — `auclast.dn`, `cmax.dn`, and other `*.dn` variants +- **Counts and dose** — `count_conc`, `count_conc_measured`, `totdose` If a parameter you need is not in the catalog, you can add it — see the [Writing Custom Parameter Functions chapter](custom-parameters.qmd) and the vignette [Writing PKNCA Parameter Functions](https://humanpred.github.io/pknca/articles/v80-writing-parameter-functions.html). @@ -231,7 +496,7 @@ If a parameter you need is not in the catalog, you can add it — see the [Writi ## Checking parameter dependencies with `get.parameter.deps()` -Some parameters depend on others being computed first. `get.parameter.deps()` returns all parameters that use a given parameter as an input: +Some parameters depend on others being computed first. By default, `get.parameter.deps()` returns all parameters that use a given parameter as an input, which answers "what becomes invalid if this parameter changes?": ```{r param-deps} # Which parameters depend on auclast? @@ -240,11 +505,20 @@ get.parameter.deps("auclast") This is useful when you plan to modify or exclude a parameter and want to know which downstream parameters depend on it. Dependencies are resolved automatically — you do not need to set dependency flags to `TRUE` manually. +`recursive = TRUE` (≥ 0.13.0) searches in the other direction, answering "what does this parameter need?". It follows each dependency back through the intermediate parameters and returns only what they end at: data inputs such as `conc`, `time`, and `dose`, and the other arguments of the calculation functions, such as `options` (intermediate parameters such as `aucinf.obs` are not listed): + +```{r param-deps-recursive} +get.parameter.deps("auclast", recursive = TRUE) +get.parameter.deps("cl.obs", recursive = TRUE) +``` + --- -## Validating custom intervals with `check.interval.specification()` +## Validating intervals {#validating-intervals} + +Interval specifications are checked at two levels. -Before passing intervals to `PKNCAdata()`, you can validate them with `check.interval.specification()`. It checks that `start` and `end` are present and valid (`start < end`, non-missing start), that known parameter columns hold valid values, and it warns when a row requests no parameters. Unknown extra columns pass through untouched (deliberately, so grouping columns survive). +`check.interval.specification()` checks the specification on its own: that `start` and `end` are present and valid (`start < end`, non-missing start), that known parameter columns hold valid values, and it warns when a row requests no parameters. Unknown extra columns pass through untouched (deliberately, so grouping columns survive). ```{r check-intervals} # A valid interval @@ -258,16 +532,42 @@ valid_int <- data.frame( check.interval.specification(valid_int) ``` -```{r check-invalid, error=TRUE} -# An invalid interval — start >= end +An invalid interval (`start >= end`) is an error; here it is caught to show its class and message: + +```{r check-invalid} bad_int <- data.frame( start = 24, end = 0, auclast = TRUE ) -check.interval.specification(bad_int) +tryCatch( + check.interval.specification(bad_int), + error = function(e) c(class = class(e)[1], message = conditionMessage(e)) +) ``` +`assert_intervals()` checks a specification against a `PKNCAdata` object, and `set_intervals()` checks it and then stores it in the object. They run whenever intervals are given to `PKNCAdata()`, and `pk.nca()` runs `assert_intervals()` again, so intervals assigned directly to `o_data$intervals` are checked too (≥ 0.13.0). Beyond the checks above, they require that every column is a group column, a parameter, the imputation column, `interval_id` or a `_ref` column, or listed in `keep_interval_cols`; that every `start` is finite and not missing; and that every `end` is after its `start` (`Inf` is allowed, but not missing, `NaN`, or `-Inf`). The errors name the offending columns or rows: + +```{r assert-intervals} +o_data_check <- PKNCAdata(o_conc, o_dose, intervals = valid_int) + +# Replace the intervals after checking them +o_data_check <- set_intervals(o_data_check, data.frame(start = 0, end = Inf, cmax = TRUE)) +o_data_check$intervals + +show_error <- function(expr) { + tryCatch(expr, error = function(e) c(class = class(e)[1], message = conditionMessage(e))) +} +# A column that is neither a group, a parameter, nor kept +show_error(set_intervals(o_data_check, data.frame(start = 0, end = 24, auclast = TRUE, visit = 1))) +# An end that is missing +show_error(set_intervals(o_data_check, data.frame(start = 0, end = NA_real_, auclast = TRUE))) +# An end before its start +show_error(set_intervals(o_data_check, bad_int)) +``` + +To carry an extra column such as `visit` into the results, list it in the `keep_interval_cols` option instead (see the [Workflow chapter](workflow.qmd)). + `check.interval.specification()` returns the checked, column-completed specification — every registered parameter column filled in with its default — so it can be used inline before passing to `PKNCAdata()`: ```{r check-inline} @@ -299,5 +599,5 @@ as.data.frame(o_nca) |> head(8) --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [setDuration()](https://humanpred.github.io/pknca/reference/setDuration.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [get.parameter.deps()](https://humanpred.github.io/pknca/reference/get.parameter.deps.html) · [check.interval.specification()](https://humanpred.github.io/pknca/reference/check.interval.specification.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [pknca_interval_table()](https://humanpred.github.io/pknca/reference/pknca_interval_table.html) · [pknca_presets()](https://humanpred.github.io/pknca/reference/pknca_presets.html) · [pknca_parameter_table()](https://humanpred.github.io/pknca/reference/pknca_parameter_table.html) · [pknca_concepts()](https://humanpred.github.io/pknca/reference/pknca_concepts.html) · [pknca_tiers()](https://humanpred.github.io/pknca/reference/pknca_concepts.html) · [pknca_concept()](https://humanpred.github.io/pknca/reference/pknca_concept.html) · [pknca_check_parameter_classification()](https://humanpred.github.io/pknca/reference/pknca_check_parameter_classification.html) · [interval_add_param()](https://humanpred.github.io/pknca/reference/interval_add_param.html) · [interval_remove_param()](https://humanpred.github.io/pknca/reference/interval_add_param.html) · [interval_add_impute()](https://humanpred.github.io/pknca/reference/interval_add_impute.html) · [interval_remove_impute()](https://humanpred.github.io/pknca/reference/interval_add_impute.html) · [get_impute_column()](https://humanpred.github.io/pknca/reference/get_impute_column.html) · [get_impute_method()](https://humanpred.github.io/pknca/reference/get_impute_method.html) · [setDuration()](https://humanpred.github.io/pknca/reference/setDuration.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [get.parameter.deps()](https://humanpred.github.io/pknca/reference/get.parameter.deps.html) · [check.interval.specification()](https://humanpred.github.io/pknca/reference/check.interval.specification.html) · [assert_intervals()](https://humanpred.github.io/pknca/reference/assert_intervals.html) · [set_intervals()](https://humanpred.github.io/pknca/reference/set_intervals.html) ::: diff --git a/user/workflow.qmd b/user/workflow.qmd index cfc2eb5..6f660e2 100644 --- a/user/workflow.qmd +++ b/user/workflow.qmd @@ -76,18 +76,25 @@ print(o_dose) ### Step 4 — PKNCAdata -`PKNCAdata` combines concentration and dose objects. If you don't specify `intervals`, PKNCA builds defaults: for single-dose data, from the built-in `single.dose.aucs` specification anchored at the dosing times; multiple-dose data get tau-based intervals instead. +`PKNCAdata` combines concentration and dose objects. If you don't specify `intervals`, PKNCA generates them from the dose times. It finds each group's dosing regimen and, for each interval it places, asks `pknca_interval_table()` for the parameters and the imputation that suit that interval's context (≥ 0.13.0). A single dose, as here, gets one interval from the dose to infinity that requests `auclast`, `cmax`, `tmax`, `tlag`, `count_conc`, `half.life`, `aucinf.obs`, `aucpext.obs`, and `cl.obs`. Its `impute` column holds `start_predose_conc0`: a concentration measured at the dose time is kept, a predose concentration is carried forward to the dose time when there is none, and 0 is imputed when there is neither (see the [Concentration Imputation chapter](imputation.qmd)). Multiple-dose data get one row per sampled dosing interval instead (typically the first one and the last one), built on the `aucint.*` family with `start_cmin` or `start_predose` imputation; [Automatic Intervals and Dosing Regimens](auto-intervals.qmd) explains how the regimen is detected and which intervals each design gets. ```{r} o_data <- PKNCAdata(o_conc, o_dose) ``` -You can inspect the auto-generated intervals: +You can inspect the auto-generated intervals. The interval table has one logical column per known parameter, so this keeps only the columns that request something: ```{r} -o_data$intervals |> head() +o_data$intervals |> + select(where(~ !is.logical(.x) || any(.x))) |> + head(3) ``` +::: {.callout-note} +## Changed in 0.13.0 +Before 0.13.0, a single dose got the intervals in the `single.dose.aucs` option instead: `auclast` over 0 to 24 (which assumed hours) plus `cmax`, `tmax`, `half.life`, and `aucinf.obs` over 0 to infinity, with no imputation. Because the single-dose interval now imputes a concentration at the dose time, results change when no sample was taken at the dose time: AUCs that need a concentration there are now calculated from the imputed one, where the legacy intervals give `NA` with an exclusion reason (see the [Concentration Imputation chapter](imputation.qmd)). `PKNCA.options(auto.interval.method = "legacy")` (or `options = list(auto.interval.method = "legacy")` in `PKNCAdata()`) restores the previous parameter lists. The [Interval Selection chapter](intervals.qmd) shows how to build the same tables yourself with `pknca_interval_table()`. +::: + ### Step 5 — pk.nca Run all calculations: @@ -112,7 +119,7 @@ summary(o_nca) ## Specifying intervals manually -Auto-interval detection works for simple designs. For complex studies (multiple doses, custom windows), define intervals yourself. +Automatic intervals cover the common designs. When you need other windows or parameters, define intervals yourself, either as a data frame as below or with the helpers `pknca_interval_table()` and `interval_add_param()` described in the [Interval Selection chapter](intervals.qmd). An **interval** is a row in a data frame with: @@ -140,10 +147,18 @@ as.data.frame(o_nca2) ## Controlling calculations with PKNCA.options() -`PKNCA.options()` is the global settings registry. Call with no arguments to see all current settings. See also the vignette [Options for Controlling PKNCA](https://humanpred.github.io/pknca/articles/v40-options-for-controlling-PKNCA.html). +`PKNCA.options()` is the global settings registry. Call it with no arguments to get a named list of all current settings. See also the vignette [Options for Controlling PKNCA](https://humanpred.github.io/pknca/articles/v40-options-for-controlling-PKNCA.html). + +```{r} +current_options <- PKNCA.options() +names(current_options) +``` + +PKNCA `r packageVersion("PKNCA")` has `r length(PKNCA.options())` options. (Printing `PKNCA.options()` itself shows every value, including `single.dose.aucs`, which is a full interval data frame with one column per parameter.) Two helpers read the registry without changing it (≥ 0.13.0 for `PKNCA_options_defaults()`): `PKNCA_options_defaults()` gives the default of one option or all of them, which is useful after the current values have been changed, and `PKNCA.options.describe()` gives the package's own description of an option. ```{r} -PKNCA.options() +PKNCA_options_defaults("auto.interval.tolerance") +PKNCA.options.describe("auto.interval.tolerance") ``` All options and what they control: @@ -152,7 +167,7 @@ All options and what they control: | Option | Default | Effect | |---|---|---| -| `auc.method` | `"lin up/log down"` | Integration rule: `"linear"`, `"lin-log"`, or `"lin up/log down"` | +| `auc.method` | `"lin up/log down"` | Integration rule: `"lin up/log down"`, `"linear"`, or `"lin-log"` | Each rule is derived in [AUC integration methods](https://humanpred.github.io/pknca/articles/v23-auc-integration-methods.html). @@ -161,21 +176,30 @@ Each rule is derived in [AUC integration methods](https://humanpred.github.io/pk | Option | Default | Effect | |---|---|---| | `min.hl.points` | `3` | Minimum data points for a valid λz regression | +| `max.hl.points` | `Inf` | Maximum number of points that automatic point selection may use; must be more than `min.hl.points` (≥ 0.13.0) | +| `min.hl.start.time` | `0` | Earliest time at which automatic point selection may start the λz regression; `0` does not restrict it (≥ 0.13.0) | +| `adj.r.squared.factor` | `0.0001` | Selection tolerance: the regression with the most points whose adjusted R² is within this value of the best adjusted R² is chosen (favors more points when fits are nearly equal) | +| `r.squared.factor` | `NA` | The same tolerance on the unadjusted R² instead (≥ 0.13.0). Setting either factor takes the other out of use, so exactly one is in use; `NA` means "select on the other one" | | `min.hl.r.squared` | `0.9` | Minimum unadjusted R² for λz acceptance (post-hoc exclusion via `exclude_nca_min.hl.r.squared()`) | -| `adj.r.squared.factor` | `0.0001` | Selection tolerance: the longest regression window whose adj.R² is within this value of the best adj.R² is chosen (favors more points when fits are nearly equal) | | `min.span.ratio` | `2` | Minimum ratio of time span to half-life (post-hoc exclusion via `exclude_nca_span.ratio()`) | -| `allow.tmax.in.half.life` | `FALSE` | Allow Tmax timepoint in λz regression | -| `hl_method` | `"log-linear"` | `"log-linear"` (OLS) or `"tobit"` (censored-likelihood, ≥ 0.12.2) | -| `tobit_n_points_penalty` | `0` | Exponent in the Tobit window-selection criterion (residual × n^penalty); positive values favor shorter windows (≥ 0.12.2) | -| `tobit_optim_control` | `list()` | Control list forwarded to `stats::optim()` for Tobit optimization (≥ 0.12.2) | +| `allow.tmax.in.half.life` | `FALSE` | Allow the Tmax timepoint in the λz regression | +| `hl_method` | `"log-linear"` | `"log-linear"` (OLS) or `"tobit"` (censored likelihood) (≥ 0.13.0) | +| `tobit_n_points_penalty` | `0` | Exponent in the Tobit window-selection criterion (residual × n^penalty); positive values favor shorter windows (≥ 0.13.0) | +| `tobit_optim_control` | `list()` | Control list forwarded to `stats::optim()` for Tobit optimization (≥ 0.13.0) | -Details: [Half-Life Calculation](https://humanpred.github.io/pknca/articles/v06-half-life-calculation.html) and [Half-life calculation with Tobit regression](https://humanpred.github.io/pknca/articles/v06-half-life-calculation-tobit.html). +Details: the [Half-Life chapter](halflife.qmd), [Half-Life Calculation](https://humanpred.github.io/pknca/articles/v06-half-life-calculation.html), and [Half-life calculation with Tobit regression](https://humanpred.github.io/pknca/articles/v06-half-life-calculation-tobit.html). **AUCinf extrapolation** | Option | Default | Effect | |---|---|---| -| `max.aucinf.pext` | `20` | Flag results where extrapolated % of AUCinf exceeds this threshold | +| `max.aucinf.pext` | `20` | Maximum percent of AUCinf that may be extrapolated for AUCinf to be considered valid (post-hoc exclusion via `exclude_nca_max.aucinf.pext()`) | + +**Sparse sampling** + +| Option | Default | Effect | +|---|---|---| +| `sparse_lambda_z_se` | `"delta"` | How the standard error of a sparse `aucinf.obs` or `aumcinf.obs` accounts for the estimated λz: `"delta"` adds its uncertainty with the delta method; `"none"` treats λz as known (≥ 0.13.0). See the [Sparse Sampling chapter](sparse.qmd) | **Concentration handling** @@ -189,21 +213,30 @@ Details: [Half-Life Calculation](https://humanpred.github.io/pknca/articles/v06- | Option | Default | Effect | |---|---|---| | `first.tmax` | `TRUE` | `TRUE` = return first Tmax when tied; `FALSE` = return last | -| `first.tmin` | `TRUE` | `TRUE` = return first Tmin when tied; `FALSE` = return last (≥ 0.12.2) | +| `first.tmin` | `TRUE` | `TRUE` = return first Tmin when tied; `FALSE` = return last (≥ 0.13.0) | + +**Automatic intervals** + +| Option | Default | Effect | +|---|---|---| +| `auto.interval.method` | `"builder"` | Where the parameters of automatically generated intervals come from: `"builder"` asks `pknca_interval_table()` for the parameters and imputation that suit each interval; `"legacy"` uses the pre-0.13.0 lists (`single.dose.aucs` for a single dose; AUClast, Cmax, and Tmax for each dosing interval) (≥ 0.13.0) | +| `auto.interval.tolerance` | `0.05` | How far before an interval boundary a sample may be drawn and still count as the sample at that boundary, as a fraction of the interval length (a trough at 167.5 h still ends an interval nominally ending at 168 h) (≥ 0.13.0) | +| `single.dose.aucs` | (data frame) | Intervals for single-dose data, used only with `auto.interval.method = "legacy"` | +| `tau.choices` | `NA` | Candidate dosing intervals (τ) considered when detecting the dosing interval of multiple-dose data; `NA` considers any interval | -**Summary / output** +The [Automatic Intervals and Dosing Regimens chapter](auto-intervals.qmd) shows these options at work. + +**Summary / output / other** | Option | Default | Effect | |---|---|---| | `max.missing` | `0.5` | Maximum fraction of missing values allowed before suppressing a summary statistic | | `keep_interval_cols` | `NULL` | Additional columns from the intervals data frame to carry through to results | | `allow_partial_missing_units` | `FALSE` | Allow some parameters to lack unit definitions when a units table is provided | -| `single.dose.aucs` | (data frame) | Default interval specifications used when no intervals are provided for a single dose | -| `tau.choices` | `NA` | Candidate dosing intervals (τ) for auto-detection in multiple-dose designs | -| `progress` | `TRUE` | Show progress bar during `pk.nca()` | +| `progress` | `TRUE` | Show a progress bar during `pk.nca()` | | `debug` | `NULL` | Enable internal debug output (not for routine use) | -Override for a single analysis by passing `options` to `PKNCAdata()`. PKNCA has no `update()` method, so to rerun with different settings you build a new `PKNCAdata` object with the new `options` and call `pk.nca()` again: +Override for a single analysis by passing `options` to `PKNCAdata()`. To rerun with different settings, build a new `PKNCAdata` object with the new `options` and call `pk.nca()` again: ```{r} o_data_strict <- PKNCAdata( @@ -215,6 +248,8 @@ o_data_strict <- PKNCAdata( ) ``` +`update()` has a method for `PKNCAresults` objects only (there is none for `PKNCAconc`, `PKNCAdose`, or `PKNCAdata`). `update(o_nca, data = o_data_new)` is meant for the case where only the concentration or dose data change, such as an interactive app that excludes a point: it recalculates the groups whose data changed and keeps the other results. Any other difference between the old and new `PKNCAdata` objects, including different `options`, makes it recalculate everything with a `pknca_warning_full_recalculation` warning, so for a change of settings, calling `pk.nca()` on the new object directly is the clearer choice. + --- ## Extracting and plotting results @@ -237,5 +272,5 @@ ggplot(auclast_df, aes(x = Subject, y = PPORRES)) + --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [exclude_nca_min.hl.r.squared()](https://humanpred.github.io/pknca/reference/exclude_nca.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [PKNCA_options_defaults()](https://humanpred.github.io/pknca/reference/PKNCA_options_defaults.html) · [PKNCA.options.describe()](https://humanpred.github.io/pknca/reference/PKNCA.options.describe.html) · [pknca_interval_table()](https://humanpred.github.io/pknca/reference/pknca_interval_table.html) · [update()](https://humanpred.github.io/pknca/reference/update.PKNCAresults.html) · [exclude_nca_min.hl.r.squared()](https://humanpred.github.io/pknca/reference/exclude_nca.html) ::: From d3c815a6b04beae56308f506a2d39892aaf1f8e2 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 10 Oct 2026 02:16:39 +0000 Subject: [PATCH 02/12] docs: add the automatic intervals chapter; update multiple dose, superposition, and time to steady state Co-Authored-By: Claude Opus 5.5 --- user/auto-intervals.qmd | 439 ++++++++++++++++++++++++++++++++++++++++ user/multiple-dose.qmd | 73 +++++-- user/superposition.qmd | 16 +- user/tss.qmd | 27 ++- 4 files changed, 532 insertions(+), 23 deletions(-) create mode 100644 user/auto-intervals.qmd diff --git a/user/auto-intervals.qmd b/user/auto-intervals.qmd new file mode 100644 index 0000000..decd05a --- /dev/null +++ b/user/auto-intervals.qmd @@ -0,0 +1,439 @@ +--- +title: "Automatic Intervals and Dosing Regimens" +--- + +```{r setup, include=FALSE} +library(PKNCA) +library(dplyr) +conflicted::conflicts_prefer(dplyr::filter, dplyr::select, .quiet = TRUE) +``` + +## When PKNCA chooses the intervals (≥ 0.13.0) + +If `PKNCAdata()` is given no `intervals`, it builds them for each group from the concentration and dose times with `choose.auc.intervals()`. This chapter covers how those intervals are chosen, how the dosing interval (τ) is found from the dose times, and the warnings PKNCA gives when the dosing does not follow a clean pattern. To write intervals yourself, see [Intervals](intervals.qmd); the package vignette [Selection of Calculation Intervals](https://humanpred.github.io/pknca/articles/v03-selection-of-calculation-intervals.html) gives the same rules in reference form. + +In PKNCA 0.13.0 the parameters in each generated interval come from `pknca_interval_table()`, which picks the parameters and the imputation that suit the interval's context. The `auto.interval.method` option names this approach `"builder"` (the default). There are three contexts: + +| Context | When | Imputation | AUC family | +|---|---|---|---| +| Single dose | One dose, or a dose followed by a washout | `start_predose_conc0` | `auclast`, `aucinf.obs` | +| Dosing interval | Between two doses whose samples run up to the next dose | `start_cmin` | `aucint.last`, `aucint.inf.obs` | +| Steady state | One τ after the last dose | `start_predose` | `aucint.last`, `aucint.inf.obs` | + +A sparse design imputes nothing except a zero at the start of a single dose (see [Sparse Sampling](sparse.qmd)). The imputation methods themselves are described in [Imputation](imputation.qmd). + +The rules, applied group by group, are: + +- A group with no dose times gives no intervals (`pknca_warning_no_dose_times_for_group`). +- A single dose with any sample after it gives one interval from the dose to infinity. +- Two consecutive doses with a sample at each dose time and at least one sample between them give an interval between them. It is a dosing interval when the samples run up to the next dose, and a single-dose profile when they stop partway, leaving a washout. +- For the last dose, τ is found with `find.tau()`. If a sample ends it, an interval one τ long is generated as a steady-state interval. It starts at the first dose of the last complete dosing cycle, so it never contains a dose that was not recorded. If samples continue past it, `half.life` is calculated from the last dose to infinity. If the last dose gets neither, its profile is calculated to infinity as a single dose. +- When the dose times change regimen, the intervals are chosen segment by segment instead (see [A change of regimen](#a-change-of-regimen)). + +Two helpers keep the examples short. `show_intervals()` collapses the logical parameter columns of an interval specification into one `parameters` column. The specification itself has a column for every registered parameter. `conc_1cpt()` simulates one-compartment oral concentrations for any set of dose times: + +```{r} +# One row per interval: the non-parameter columns, then the requested parameters +show_intervals <- function(intervals) { + intervals <- as.data.frame(intervals) + param_cols <- names(intervals)[vapply(intervals, is.logical, logical(1))] + ret <- intervals[, setdiff(names(intervals), param_cols), drop = FALSE] + ret$parameters <- + apply(intervals[, param_cols, drop = FALSE], 1, + function(x) paste(param_cols[x], collapse = ", ")) + ret +} + +# One-compartment oral model with superposition over every earlier dose +conc_1cpt <- function(time, dose_times, dose = 100, ka = 1, ke = 0.1, v = 10) { + vapply(time, function(t) { + since_dose <- t - dose_times[dose_times <= t] + sum(dose / v * ka / (ka - ke) * (exp(-ke * since_dose) - exp(-ka * since_dose))) + }, numeric(1)) +} +``` + +--- + +## A single dose + +Theophylline is a single oral dose per subject: + +```{r} +d_conc_theoph <- as.data.frame(datasets::Theoph) +d_dose_theoph <- d_conc_theoph |> + filter(Time == 0) |> + select(Subject, Dose, Time) + +o_conc_theoph <- PKNCAconc(d_conc_theoph, conc ~ Time | Subject) +o_dose_theoph <- PKNCAdose(d_dose_theoph, Dose ~ Time | Subject) +o_data_theoph <- PKNCAdata(o_conc_theoph, o_dose_theoph) + +show_intervals(o_data_theoph$intervals) |> head(3) +``` + +Each subject gets one interval from the dose to infinity. That interval requests `auclast`, `aucinf.obs`, `cmax`, `tmax`, `tlag`, `half.life`, `aucpext.obs`, `cl.obs`, and `count_conc`, with `start_predose_conc0` imputation. That imputation carries a predose sample to the dose time, or imputes a concentration of 0 there if there is none. + +::: {.callout-important} +## Default results changed in 0.13.0 + +Before 0.13.0, single-dose data got two automatic intervals, AUClast over 0 to 24 and a 0 to infinity interval, with no imputation. Now: + +- There is no separate 0 to 24 AUClast, so the interval no longer assumes that time is in hours. +- **AUC values change when no sample sits at the dose time.** The concentration at the dose is now imputed, so AUClast is integrated from the dose instead of from the first sample. +- The default parameters gain `tlag`, `count_conc`, `aucpext.obs`, and `cl.obs`. +::: + +### Going back to the legacy parameter lists + +`auto.interval.method = "legacy"` restores the earlier parameter lists: the `single.dose.aucs` option for single-dose data, and `auclast`, `cmax`, and `tmax` for each interval of multiple-dose data. Only the parameters differ; the intervals are found the same way either way. + +```{r} +o_data_legacy <- PKNCAdata(o_conc_theoph, o_dose_theoph, + options = list(auto.interval.method = "legacy")) +show_intervals(o_data_legacy$intervals) |> head(2) +``` + +The default `single.dose.aucs` ends its first interval at 24, which assumes hours. With the legacy method and a time unit other than hours given to `PKNCAconc(timeu = )`, PKNCA gives a `pknca_warning_single_dose_aucs_unit` warning, because the interval would end at 24 of that unit. + +--- + +## Once-daily dosing to steady state + +Two subjects take 100 mg once daily for seven days. They have a full profile after the first dose and after the last, with troughs in between and two samples after the last dosing interval: + +```{r} +d_dose_qd <- data.frame(ID = rep(1:2, each = 7), + time = rep(seq(0, 144, by = 24), 2), + dose = 100) +times_qd <- c(0, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 96, 120, + 144, 144.5, 145, 146, 148, 152, 156, 168, 192, 216) +d_conc_qd <- bind_rows( + data.frame(ID = 1, time = times_qd, + conc = conc_1cpt(times_qd, seq(0, 144, by = 24), ke = 0.1)), + data.frame(ID = 2, time = times_qd, + conc = conc_1cpt(times_qd, seq(0, 144, by = 24), ke = 0.07)) +) + +o_data_qd <- PKNCAdata(PKNCAconc(d_conc_qd, conc ~ time | ID), + PKNCAdose(d_dose_qd, dose ~ time | ID)) +show_intervals(o_data_qd$intervals) +``` + +Each subject gets three intervals: + +- **0 to 24**, the first dosing interval: the samples at 0 and 24 bound it and the samples between run up to the next dose. It is a dosing interval with `start_cmin` imputation and the `aucint.*` parameters. +- **144 to 168**, one τ after the last dose: `find.tau()` finds τ = 24 from the dose times, and the sample at 168 ends the interval. It is a steady-state interval with `start_predose` imputation. +- **144 to infinity**, with only `half.life`, because the samples at 192 and 216 continue past τ. + +There is no interval for 24 to 48 and so on, because nothing was sampled between those troughs. + +```{r} +o_nca_qd <- pk.nca(o_data_qd) + +as.data.frame(o_nca_qd) |> + filter(PPTESTCD %in% c("aucint.last", "cmax", "cl.int.inf.obs", "half.life")) |> + select(ID, start, end, PPTESTCD, PPORRES) |> + tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) +``` + +The steady-state clearance from the 144 to 168 hour interval is close to the simulated values (1 L/h for subject 1 and 0.7 L/h for subject 2). The same parameter over the first dosing interval is higher, because the first interval has not accumulated to steady state. + +### Adding parameters to a generated interval + +The generated intervals are an ordinary interval specification, so they can be edited before `pk.nca()`. `interval_add_param()` with `target_groups` adds the multiple-dose MRT and Vss to the steady-state interval only. τ for those parameters is detected from the dose times (a `tau` column in the intervals would take precedence; see [Multiple-Dose and Steady-State](multiple-dose.qmd#multiple-dose-mrt-and-vss)): + +```{r} +o_data_qd_md <- interval_add_param( + o_data_qd, + param = c("mrt.md.obs", "vss.md.obs"), + target_groups = data.frame(start = 144, end = 168) +) + +pk.nca(o_data_qd_md) |> + as.data.frame() |> + filter(PPTESTCD %in% c("mrt.md.obs", "vss.md.obs")) |> + select(ID, start, end, PPTESTCD, PPORRES) +``` + +For a one-compartment oral model, MRT is 1/ke + 1/ka: 11 hours for subject 1 and about 15.3 hours for subject 2, which matches the estimates. + +--- + +## How the dosing interval is found + +### `find.tau()` and `find.dose.regimen()` + +`find.tau()` returns the dosing interval for one subject's dose times. It returns `NA` when there are no dose times or when nothing repeats, and 0 for a single dose time: + +```{r} +find.tau(c(0, 24, 48, 72)) # equally spaced +find.tau(0) # a single dose +find.tau(numeric(0)) # no doses +find.tau(c(0, 5, 31, 100)) # no interval repeats +``` + +The interval comes from `find.dose.regimen()`, which describes the whole regimen as a table with one row per segment: the period the doses repeat over (`interval`), a nominal `label`, whether the interval was snapped to a nominal value (`source`), counts of missed and off-schedule doses, and the dose times within one period (`offsets`). `find.tau()` reports the period of the segment with the most doses. + +```{r} +# Twice daily at breakfast (0 h) and dinner (10 h) +find.dose.regimen(c(0, 10, 24, 34, 48, 58), timeu = "hr") +``` + +Twice-daily dosing at 0 and 10 hours repeats every 24 hours with offsets 0 and 10, labeled `"BID"` (two doses per day). PKNCA looks for a pattern that repeats over the whole regimen before reading a gap as a missed dose. So three-times-daily dosing with an overnight gap is a daily regimen, not a 6-hour regimen with doses missing: + +```{r} +find.tau(c(0, 6, 12, 24, 30, 36, 48, 54, 60)) +``` + +The dose times are sorted and duplicates are dropped first, so the order of the dosing rows and a dose recorded twice do not change the answer. + +### Nominal intervals and time units + +Dose times are matched to the usual nominal intervals within a tolerance on the log scale. The built-in set runs from every 4 hours to every 12 weeks: Q4H, QID (6 h), TID (8 h), BID (12 h), QD (24 h), QOD (48 h), Q72H, QW, Q2W, Q3W, Q4W, Q6W, Q8W, and Q12W. So doses recorded a little early or late still give the nominal interval: + +```{r} +find.tau(c(0, 23.6, 48, 72.4, 96), timeu = "hr") +``` + +**Times without a unit are taken to be hours.** So `find.tau(c(0, 24, 50))` is the once-daily 24, not the median spacing of 25: + +```{r} +find.tau(c(0, 24, 50)) +``` + +Data recorded in another unit should say so. Daily doses recorded in days, without `timeu`, are compared with intervals in hours. The interval of 1 matches none of them, so PKNCA reports it as found in the data with a `pknca_warning_tau_not_nominal` warning that includes the regimen table: + +```{r} +find.tau(c(0, 1, 2, 3)) +find.tau(c(0, 1, 2, 3), timeu = "day") +``` + +Inside `PKNCAdata()` and `pk.nca()`, the unit is the `timeu` given to `PKNCAconc()` (see [Units](units.qmd)). How other units are handled: + +- **Unit that cannot be converted to hours:** `find.tau()` and `find.dose.regimen()` stop with `pknca_error_regimen_time_unit`. `PKNCAdata()` uses the dose times alone instead. `timeu = NA` asks for the dose times alone explicitly. +- **Units package not installed:** a unit other than `"hr"` gives a `pknca_message_tau_units_fallback` message, once for each group, and the interval found in the dose times is used. Results for data in other units can therefore differ between installations with and without the units package. + +### Restricting the choices with `tau.choices` + +The `tau.choices` option replaces the built-in nominal set. Its names become the labels. `PKNCAdata(options = list(tau.choices = ...))` is honored when intervals are generated. + +```{r} +find.dose.regimen(c(0, 12, 24, 36, 48), tau.choices = c(q12h = 12, daily = 24)) +``` + +An interval that matches none of the choices is reported as found in the data, with a `pknca_warning_tau_not_nominal` warning. + +--- + +## Irregular dosing + +### A missed dose + +The subject below missed the 72-hour dose. `find.dose.regimen()` still reads the regimen as once daily, and `PKNCAdata()` gives a `pknca_warning_tau_irregular_dosing` warning that names the gap. Warnings from `PKNCAdata()` start with the group they apply to: + +```{r} +d_dose_missed <- data.frame(ID = 3, time = c(0, 24, 48, 96, 120, 144), dose = 100) +times_missed <- c(0, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 96, 120, + 144, 144.5, 145, 146, 148, 152, 156, 168) +d_conc_missed <- data.frame(ID = 3, time = times_missed, + conc = conc_1cpt(times_missed, d_dose_missed$time)) + +o_data_missed <- PKNCAdata(PKNCAconc(d_conc_missed, conc ~ time | ID), + PKNCAdose(d_dose_missed, dose ~ time | ID)) +show_intervals(o_data_missed$intervals)[, c("ID", "start", "end", "impute")] +``` + +Two of the intervals are what full adherence would give: 0 to 24 and 144 to 168. The third runs from 48 to 96. It spans the missed dose because 48 and 96 are consecutive *recorded* doses with samples at both and a sample between them. It is calculated as a 48-hour dosing interval. If it should not be reported, remove that row from `o_data_missed$intervals` or give `intervals` yourself. + +### Doses given early or late + +`choose.auc.intervals()` is the function `PKNCAdata()` calls for each group. It takes the sample and dose times directly, which makes it handy for checking a design. Here once-daily doses were given at 0, 24.3, and 47.8 hours, and the profile after the last dose ends with a trough at 71.5 hours: + +```{r} +doses_offtime <- c(0, 24.3, 47.8) +samples_last <- c(0, 24.3, 47.8, 48.5, 49, 50, 52, 56) + +choose.auc.intervals(c(samples_last, 71.5), doses_offtime) |> + show_intervals() +``` + +The steady-state interval starts at the last dose (47.8) and runs one nominal τ (24 hours) to 71.8. An interval for the last dose never contains a later recorded dose. Before 0.13.0 this design gave 24.3 to 48.3, which contains the dose at 47.8. + +The tolerance for matching a sample to the end of an interval only reaches **backward**. A trough drawn at 72.0, after the 71.8 end, does not end the interval. So the last dose is calculated to infinity as a single-dose profile instead: + +```{r} +choose.auc.intervals(c(samples_last, 72), doses_offtime) |> + show_intervals() +``` + +Check the generated intervals whenever doses are recorded off schedule. If the steady-state interval is the one you need, give it explicitly (see [Intervals](intervals.qmd)). + +### How close is close enough: `auto.interval.tolerance` + +A sample drawn a little before its nominal time still bounds the interval it belongs to. The window is the `auto.interval.tolerance` option, a fraction of the interval's length that defaults to 0.05 (1.2 hours for a 24-hour τ). With the last trough drawn at 167 instead of 168 hours, the steady-state interval is still found. With a tolerance of 0.01 (0.24 hours) it is not, and the last dose falls back to a single-dose profile: + +```{r} +times_early_trough <- c(0, 1, 2, 4, 8, 24, 48, 72, 96, 120, 144, 145, 146, 148, 152, 167) +dose_qd <- seq(0, 144, by = 24) + +choose.auc.intervals(times_early_trough, dose_qd)[, c("start", "end", "impute")] +choose.auc.intervals(times_early_trough, dose_qd, + options = list(auto.interval.tolerance = 0.01))[, c("start", "end", "impute")] +``` + +The window only reaches backward, so a concentration drawn just after a dose never stands in for the predose sample. + +--- + +## A change of regimen + +When `find.dose.regimen()` finds more than one segment, the intervals for dense data follow the segments: + +- one steady-state interval for each segment at its own period, if a sample ends it; +- the interval after the first dose; +- the interval after any other dose that is sampled densely, meaning at least `dense.samples` samples (3 by default) within one period after it. + +A `pknca_warning_intervals_by_segment` warning names the segments and the intervals chosen. That warning also has the class `pknca_warning_tau_regimen_change`. + +The subject below takes 100 mg once daily for four days, then twice daily from 84 hours. There is a full profile after the first dose, after the 108-hour dose, and after the last dose. The samples were drawn 0.4 hours late after the 108-hour dose, so `time.nominal` gives the scheduled times: + +```{r} +d_dose_switch <- data.frame(ID = 4, time = c(seq(0, 72, by = 24), seq(84, 156, by = 12)), + dose = 100) +times_nominal <- c(0, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 84, 96, + 108, 108.5, 109, 110, 112, 120, 132, 144, + 156, 156.5, 157, 158, 160, 164, 168) +times_actual <- ifelse(times_nominal > 108 & times_nominal < 120, + times_nominal + 0.4, times_nominal) +d_conc_switch <- data.frame(ID = 4, time = times_actual, time_nom = times_nominal, + conc = conc_1cpt(times_actual, d_dose_switch$time)) + +o_data_switch <- PKNCAdata( + PKNCAconc(d_conc_switch, conc ~ time | ID, time.nominal = "time_nom"), + PKNCAdose(d_dose_switch, dose ~ time | ID) +) +show_intervals(o_data_switch$intervals)[, c("ID", "start", "end", "impute")] +``` + +The intervals are: + +- 0 to 24 after the first dose; +- 48 to 72, the last complete once-daily cycle (an earlier segment's steady-state interval ends at the dose that starts its next cycle); +- 108 to 120, the densely sampled twice-daily dose; +- 156 to 168, one twice-daily period after the last dose. + +The last dose always uses the period of the last segment, here 12 hours, even when an earlier segment has more doses. + +Each sample counts once toward `dense.samples`: by its nominal time when the data carry `time.nominal`, by its actual time otherwise, and not at all if its concentration is missing. Nominal times must share the actual times' origin. Nominal times that restart at each dose (time since the latest dose) fall back to the actual times with a `pknca_warning_intervals_nominal_restart` warning. `choose.auc.intervals()` takes the same information through its `time.conc.nominal`, `time.dosing.nominal`, `conc`, and `dense.samples` arguments. + +Sparse designs and designs with a single regimen are not affected by this per-segment choice. + +--- + +## Two periods separated by a washout + +When the samples after a dose stop well before the next dose, the gap is read as a washout, not a dosing interval. Each dose gets a single-dose interval, and the first one is bounded by the next dose: + +```{r} +choose.auc.intervals( + time.conc = c(0, 0.5, 1, 2, 4, 8, 24, 48, 336, 336.5, 337, 338, 340, 344, 360, 384), + time.dosing = c(0, 336) +) |> + show_intervals() +``` + +Before 0.13.0 this gave one AUClast that spanned the washout. + +--- + +## Intravenous bolus + +`PKNCAdata()` passes the route recorded with the doses to `choose.auc.intervals()` through its `route` argument. For an IV bolus, PKNCA adds a second row with the same start and end that requests `c0`, the concentration back-extrapolated to the dose time, without imputation. Indomethacin was given as an IV bolus: + +```{r} +d_conc_indo <- as.data.frame(datasets::Indometh) +d_dose_indo <- data.frame(Subject = unique(d_conc_indo$Subject), dose = 25, time = 0) + +o_data_indo <- PKNCAdata( + PKNCAconc(d_conc_indo, conc ~ time | Subject), + PKNCAdose(d_dose_indo, dose ~ time | Subject, route = "intravascular") +) +show_intervals(o_data_indo$intervals) |> head(2) + +pk.nca(o_data_indo) |> + as.data.frame() |> + filter(Subject == 1, PPTESTCD %in% c("c0", "auclast", "aucinf.obs", "cl.obs")) |> + select(Subject, start, end, PPTESTCD, PPORRES, PPANMETH) +``` + +The main row keeps `start_predose_conc0` imputation. So `auclast` and `aucinf.obs` start from an imputed concentration of 0 at the dose, not from `c0`. The IV-specific AUCs that start from `c0` (`aucivlast`, `aucivinf.obs`) are covered in [Intravascular Dosing](intravascular.qmd); request them in your own intervals when the bolus AUC is what you need. + +If a group gives more than one route, PKNCA uses the first dose's route and gives a `pknca_warning_multiple_dose_routes` warning. + +--- + +## When no interval can be made + +When the dose times do not repeat, τ cannot be found. PKNCA reports that the steady-state interval for the last dose was dropped (`pknca_warning_no_tau_for_intervals`) and calculates the last dose to infinity as a single dose instead: + +```{r} +d_dose_irregular <- data.frame(ID = 5, time = c(0, 7, 31, 100), dose = 100) +times_irregular <- c(0, 1, 2, 4, 7, 8, 10, 31, 32, 34, 100, 101, 102, 104, 108, 124) +d_conc_irregular <- data.frame(ID = 5, time = times_irregular, + conc = conc_1cpt(times_irregular, d_dose_irregular$time)) + +o_data_irregular <- PKNCAdata(PKNCAconc(d_conc_irregular, conc ~ time | ID), + PKNCAdose(d_dose_irregular, dose ~ time | ID)) +show_intervals(o_data_irregular$intervals)[, c("ID", "start", "end", "impute")] +``` + +The 0 to 7 interval is a dosing interval, because its samples run up to the next dose. The 7 to 31 and 31 to 100 intervals are single-dose profiles bounded by the next dose, because their samples stop well before it. + +When no group gets any interval, `PKNCAdata()` stops with a `pknca_error_no_intervals` error after the per-group warnings that explain why. Below, the only samples are at or before the dose: + +```{r} +tryCatch( + PKNCAdata(PKNCAconc(data.frame(ID = 6, time = c(0, 5, 10), conc = c(0, 1, 2)), + conc ~ time | ID), + PKNCAdose(data.frame(ID = 6, time = 10, dose = 100), dose ~ time | ID)), + pknca_error_no_intervals = function(e) conditionMessage(e) +) +``` + +--- + +## The warnings and messages you may see + +| Condition class | When it is given | +|---|---| +| `pknca_warning_tau_irregular_dosing` | Missed doses or doses off schedule; the message names them | +| `pknca_warning_tau_not_nominal` | The interval matches no nominal interval (or no `tau.choices` value); includes the regimen table | +| `pknca_warning_tau_regimen_change` | The regimen changes within the dose times; includes the regimen table | +| `pknca_warning_intervals_by_segment` | Intervals were chosen per segment after a change of regimen (also a `pknca_warning_tau_regimen_change`) | +| `pknca_warning_intervals_nominal_restart` | Nominal times restart at each dose, so actual times were used to judge dense sampling | +| `pknca_warning_tau_undetermined` | `pk.nca()` could not find τ for a multiple-dose parameter such as `mrt.md.obs`; the parameter is `NA` (add a `tau` column to the intervals) | +| `pknca_warning_no_tau_for_intervals` | τ could not be found, so no steady-state interval was generated for the last dose | +| `pknca_warning_no_dose_times_for_group` | A concentration group has no dose rows | +| `pknca_warning_no_intervals_limited_data` | A group got no automatically generated interval (for example, no sample after its dose) | +| `pknca_warning_multiple_dose_routes` | A group gives more than one route; the first dose's route is used | +| `pknca_warning_single_dose_aucs_unit` | The legacy single-dose intervals are used with a time unit other than hours | +| `pknca_message_tau_units_fallback` | The units package is not installed, so times in a unit other than `"hr"` cannot be compared with the nominal intervals | +| `pknca_error_no_intervals` | No group got an automatically generated interval | + +The first four and `pknca_warning_tau_undetermined` share the parent class `pknca_warning_dose_regimen`. A script that has reviewed its regimens can therefore handle all of them at once without silencing other warnings: + +```{r} +o_data_reviewed <- withCallingHandlers( + PKNCAdata(PKNCAconc(d_conc_missed, conc ~ time | ID), + PKNCAdose(d_dose_missed, dose ~ time | ID)), + pknca_warning_dose_regimen = function(w) invokeRestart("muffleWarning") +) +nrow(o_data_reviewed$intervals) +``` + +--- + +::: {.callout-note icon=false appearance="minimal"} +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [choose.auc.intervals()](https://humanpred.github.io/pknca/reference/choose.auc.intervals.html) · [find.tau()](https://humanpred.github.io/pknca/reference/find.tau.html) · [find.dose.regimen()](https://humanpred.github.io/pknca/reference/find.dose.regimen.html) · [pknca_interval_table()](https://humanpred.github.io/pknca/reference/pknca_interval_table.html) · [interval_add_param()](https://humanpred.github.io/pknca/reference/interval_add_param.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) +::: diff --git a/user/multiple-dose.qmd b/user/multiple-dose.qmd index 4cd8e4c..dc5300e 100644 --- a/user/multiple-dose.qmd +++ b/user/multiple-dose.qmd @@ -11,7 +11,7 @@ conflicted::conflicts_prefer(dplyr::filter, dplyr::select, .quiet = TRUE) ## Multiple-dose NCA concepts -At steady state, the dosing interval τ (tau) is the key unit of analysis. Instead of analyzing from 0 to ∞, you analyze within one dosing interval [0, τ]. Additional parameters describe how concentrations fluctuate over the interval. Two companion chapters complete the steady-state picture: [Superposition](superposition.qmd) predicts steady-state profiles (and accumulation ratios) from single-dose data, and [Time to Steady State](tss.qmd) estimates when steady state is reached. +At steady state, the dosing interval τ (tau) is the key unit of analysis. Instead of analyzing from 0 to ∞, you analyze within one dosing interval [0, τ]. Additional parameters describe how concentrations fluctuate over the interval. Companion chapters complete the steady-state picture: [Automatic Intervals and Dosing Regimens](auto-intervals.qmd) shows the intervals PKNCA generates from the dose times, [Superposition](superposition.qmd) predicts steady-state profiles from single-dose data, [Time to Steady State](tss.qmd) estimates when steady state is reached, and [Secondary Parameters](secondary-parameters.qmd#ratios) computes accumulation ratios. | Term | Meaning | |---|---| @@ -206,28 +206,44 @@ two_dose_intervals <- data.frame( end = c(12, 24), auclast = TRUE, cmax = TRUE, - ctrough = TRUE, deg.fluc = TRUE ) o_nca_2d <- pk.nca(PKNCAdata(o_conc_2d, o_dose_2d, intervals = two_dose_intervals)) as.data.frame(o_nca_2d) |> - filter(PPTESTCD %in% c("auclast", "cmax", "ctrough")) |> + filter(PPTESTCD %in% c("auclast", "cmax", "deg.fluc")) |> select(Subject, start, end, PPTESTCD, PPORRES) |> arrange(Subject, start, PPTESTCD) ``` +### Letting PKNCA choose the intervals + +Without `intervals`, `PKNCAdata()` generates them from the dose and sample times (≥ 0.13.0). For these two doses it finds the dosing interval from 0 to 12 hours, and the steady-state interval one τ (12 hours) after the last dose: + +```{r} +o_data_2d_auto <- PKNCAdata(o_conc_2d, o_dose_2d) + +o_data_2d_auto$intervals |> + filter(Subject == 1) |> + select(Subject, start, end, impute, aucint.last, cmax, half.life) +``` + +The generated intervals request the `aucint.*` parameters with `start_cmin` imputation for the dosing interval and `start_predose` imputation at steady state. [Automatic Intervals and Dosing Regimens](auto-intervals.qmd) explains how those choices are made and what happens with missed or late doses and regimen changes. + --- ## Finding tau automatically: `find.tau()` -When the dosing interval is not known a priori, `find.tau()` estimates it from a numeric vector of dose times. If the doses are equally spaced it returns that spacing; otherwise it searches the candidate intervals in `tau.choices` for the repeating interval most consistent with the dose times. Sentinel values flag special cases: `0` when all doses occur at a single time, `-1` when no candidate interval repeats, and `NA` when tau cannot be determined. +When the dosing interval is not known a priori, `find.tau()` finds it from one subject's dose times. In 0.13.0 it was rebuilt on `find.dose.regimen()` (≥ 0.13.0): the dose times are sorted and duplicates dropped, and then the repeating pattern is found. The result is matched to the usual nominal intervals (every 4 hours to every 12 weeks), so doses recorded a little early or late still give the nominal interval. Times without a unit are taken to be hours; give `timeu` for other units. `find.tau()` returns `0` for a single dose time and `NA` when there are no dose times or no interval repeats. Missed or off-schedule doses still give the underlying interval, with a `pknca_warning_tau_irregular_dosing` warning that names them. ```{r} # Dose times for one subject: 0, 12, 24, 36, 48, 60 h dose_times <- c(0, 12, 24, 36, 48, 60) find.tau(dose_times) + +# Recorded a little off schedule, and with the 36 h dose missed +find.tau(c(0, 11.5, 24, 48, 60.5)) ``` Detect tau from the two-occasion dataset above: @@ -238,28 +254,57 @@ tau_detected <- find.tau(d_dose_2dose$time[d_dose_2dose$Subject == 1]) cat("Detected tau:", tau_detected, "h\n") ``` +The details, including `find.dose.regimen()`, time units, and the `tau.choices` option, are in [Automatic Intervals and Dosing Regimens](auto-intervals.qmd#how-the-dosing-interval-is-found). + --- -## Multiple-dose Vss (`vss.md.*`) +## Multiple-dose MRT and Vss {#multiple-dose-mrt-and-vss} -`vss.md.obs` and `vss.md.pred` are estimates of the volume of distribution at steady state using the multiple-dose MRT formula: +`mrt.md.obs`, `mrt.md.pred`, `vss.md.obs`, and `vss.md.pred` estimate MRT and the volume of distribution at steady state from a steady-state dosing interval: -$$V_{ss} = CL \times MRT_{md}$$ +$$MRT_{md} = \frac{AUMC_\tau + \tau \cdot (AUC_\infty - AUC_\tau)}{AUC_\tau} \qquad V_{ss} = CL \times MRT_{md}$$ -where `mrt.md` accounts for the accumulation over repeated dosing. +The τ·(AUC∞ − AUCτ) term accounts for the drug still in the body at the end of the interval, which AUMCτ/AUCτ alone leaves out. | Parameter | Meaning | |---|---| -| `mrt.md.obs` | Multiple-dose MRT (observed Clast) | -| `mrt.md.pred` | Multiple-dose MRT (predicted Clast) | -| `vss.md.obs` | Vss from mrt.md.obs and cl.last | -| `vss.md.pred` | Vss from mrt.md.pred and cl.last | +| `mrt.md.obs` | Multiple-dose MRT (AUC∞ extrapolated with observed Clast) | +| `mrt.md.pred` | Multiple-dose MRT (AUC∞ extrapolated with predicted Clast) | +| `vss.md.obs` | Vss from `mrt.md.obs` and `cl.last` | +| `vss.md.pred` | Vss from `mrt.md.pred` and `cl.last` | + +These parameters need τ. A `tau` column in the intervals gives it and takes precedence whenever it is present (≥ 0.13.0). Without that column, τ is detected from the group's dose times with `find.tau()`, which needs at least two doses. The steady-state dataset above records only the profiled dose, so nothing repeats and the `tau` column is required: + +```{r} +md_intervals <- data.frame( + start = 0, + end = tau, + tau = tau, + mrt.md.obs = TRUE, + vss.md.obs = TRUE +) + +o_nca_md <- pk.nca(PKNCAdata(o_conc_ss, o_dose_ss, intervals = md_intervals)) + +as.data.frame(o_nca_md) |> + filter(PPTESTCD %in% c("mrt.md.obs", "cl.last", "vss.md.obs")) |> + select(Subject, PPTESTCD, PPORRES) |> + tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |> + arrange(Subject) +``` + +For the one-compartment model used in the simulation, MRT is 1/ke + 1/ka ≈ 13.2 h at the typical ke of 0.08/h. The individual estimates spread around that value because ke varies between subjects. + +If τ can be neither given nor detected, these parameters are `NA` with a `pknca_warning_tau_undetermined` warning. PKNCA does not fall back to the single-dose equation. For IV infusions, use `mrt.ivmd.obs`, `mrt.ivmd.pred`, `vss.ivmd.obs`, and `vss.ivmd.pred` (≥ 0.13.0). They subtract half the infusion duration, without which MRT is high by `duration/2` and Vss by CL × `duration/2`. + +--- -> **Note:** As of the version this book was built against, `vss.md.*` may fail to resolve its MRT inputs from the interval specification (the calculation errors rather than returning a value). If you need Vss at steady state, compute it manually from `cl.*` and `mrt.md.*` results. +## Accumulation ratio +The accumulation ratio compares a steady-state interval with the first dosing interval, for example AUCτ,ss / AUCτ,1. PKNCA calculates it directly as a secondary parameter (≥ 0.13.0): `ratio.auclast`, `ratio.aucint.last`, `ratio.cmax`, and the other `ratio.*` parameters divide a parameter in one interval by the same parameter in a reference interval. `interval_add_secondary()` links the steady-state interval to the first-dose interval. See [Secondary Parameters](secondary-parameters.qmd#ratios) for a worked accumulation-ratio example. To predict accumulation from single-dose data instead, see [Superposition](superposition.qmd). --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [find.tau()](https://humanpred.github.io/pknca/reference/find.tau.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [find.tau()](https://humanpred.github.io/pknca/reference/find.tau.html) · [interval_add_secondary()](https://humanpred.github.io/pknca/reference/interval_add_secondary.html) ::: diff --git a/user/superposition.qmd b/user/superposition.qmd index 8509d77..ad3c4c0 100644 --- a/user/superposition.qmd +++ b/user/superposition.qmd @@ -184,9 +184,9 @@ then a 16 h overnight gap). --- -## Extracting accumulation ratio +## Predicted accumulation ratio -One common definition of the **accumulation ratio (Rac)** is Cmax at steady state divided by Cmax after the first dose (an AUC-based Rac, AUCτ,ss / AUCτ,first, is equally common): +One common definition of the **accumulation ratio (Rac)** is Cmax at steady state divided by Cmax after the first dose (an AUC-based Rac, AUCτ,ss / AUCτ,first, is equally common). With superposition, the steady-state Cmax is a prediction, so Rac is computed by hand from the predicted profile: ```{r} cmax_sd <- max(subj1$conc, na.rm = TRUE) @@ -197,6 +197,8 @@ cat("Single-dose Cmax:", round(cmax_sd, 2), "mg/L\n") cat("Steady-state Cmax:", round(cmax_ss, 2), "mg/L\n") ``` +When both the first-dose and the steady-state profiles were *observed*, PKNCA calculates the accumulation ratio itself with the `ratio.cmax`, `ratio.auclast`, or `ratio.aucint.last` secondary parameters (≥ 0.13.0); see [Secondary Parameters](secondary-parameters.qmd#ratios). + --- ## Key arguments reference @@ -211,10 +213,20 @@ cat("Steady-state Cmax:", round(cmax_ss, 2), "mg/L\n") | `steady.state.tol` | No (default 0.001) | Convergence tolerance for `n.tau = Inf` | | `check.blq` | No (default `TRUE`) | Require first concentration = 0; set `FALSE` if first sample is non-zero | | `auc.type` | No (default `"AUCinf"`) | How to extrapolate the single-dose tail: `"AUCinf"`, `"AUClast"`, or `"AUCall"` | +| `method` | No (default the `auc.method` option) | Interpolation method: `"lin up/log down"`, `"linear"`, or `"lin-log"` | +| `lambda.z`, `clast.pred`, `tlast` | No | Supply the terminal elimination rate, predicted Clast (or `TRUE` to calculate it), and Tlast instead of having them calculated | | `additional.times` | No | Extra timepoints to include in the output | Interpolation and tail extrapolation follow [the dose-aware interpolation methods](https://humanpred.github.io/pknca/articles/v21-methods-for-dose-aware-interpolation-and-extrapolation.html). +### Input checks and edge cases (≥ 0.13.0) + +- Missing (`NA`) concentrations are dropped before calculating; they used to stop the half-life fit with an error. +- `method` and `auc.type` are checked for every input, even when the calculation needs neither interpolation nor extrapolation. +- With `n.tau = Inf` and concentrations extrapolated as zero after `tlast` (for example `auc.type = "AUClast"` when `tlast < tau`), steady state is judged on the concentrations that can become nonzero, and a warning says that zero concentrations remain in the profile. If steady state is not reached within 10000 dosing intervals, `superposition()` stops with an error. Earlier versions looped forever in both cases. +- Warnings and messages from `superposition()` on a `PKNCAconc` object now reach the caller on every platform. Each group runs in a forked worker, and its conditions used to be lost outside Windows. +- Date-time sample times are an error (`pknca_error_datetime_superposition`). Superposition needs numeric times; `PKNCAdata()` converts date-times to numbers relative to the first dose. + > **Limitation:** Superposition assumes linear PK. It should not be used for drugs with non-linear kinetics (e.g. Michaelis-Menten elimination, time-varying clearance, or saturable protein binding). diff --git a/user/tss.qmd b/user/tss.qmd index 7a97017..9a52dd1 100644 --- a/user/tss.qmd +++ b/user/tss.qmd @@ -19,21 +19,21 @@ Both methods work on trough concentrations (Ctrough = pre-dose concentration; th ## Simulated dataset -We simulate trough concentrations for 6 subjects across 10 dose occasions. Concentrations approach an individual steady-state plateau following a monoexponential rise. +We simulate trough concentrations for 8 subjects across 10 dose occasions. Each subject's troughs rise monoexponentially toward an individual steady-state plateau, $C_{trough}(n) = C_{ss} (1 - e^{-k n})$. The accumulation rate per dose, $k$, is 0.3 for a typical subject, with 30% between-subject variability, so a typical subject reaches 90% of steady state after about $\ln(10)/0.3 \approx 7.7$ doses. $C_{ss}$ has 20% between-subject variability, and each trough has 5% proportional residual error. ```{r} -set.seed(42) -n_subjects <- 6 +set.seed(6) +n_subjects <- 8 n_doses <- 10 d_tss <- data.frame( Subject = rep(1:n_subjects, each = n_doses), dose_number = rep(1:n_doses, n_subjects), - ke = rep(0.1 * exp(rnorm(n_subjects, 0, 0.2)), each = n_doses), - Css = rep(10 * exp(rnorm(n_subjects, 0, 0.3)), each = n_doses) + k = rep(0.3 * exp(rnorm(n_subjects, 0, 0.3)), each = n_doses), + Css = rep(10 * exp(rnorm(n_subjects, 0, 0.2)), each = n_doses) ) -d_tss$ctrough <- d_tss$Css * (1 - exp(-d_tss$ke * d_tss$dose_number * 24)) + - rnorm(nrow(d_tss), 0, 0.1) +d_tss$ctrough <- d_tss$Css * (1 - exp(-d_tss$k * d_tss$dose_number)) * + exp(rnorm(nrow(d_tss), 0, 0.05)) ggplot(d_tss, aes(x = dose_number, y = ctrough, group = Subject, colour = factor(Subject))) + geom_line() + geom_point() + @@ -140,8 +140,21 @@ tss_combined | `type` | `c("monoexponential", "stepwise.linear")` | `pk.tss` wrapper | Which method(s) to run — both by default | | `tss.fraction` | `0.9` | monoexponential | Fraction of Css that defines "at steady state" | | `output` | `c("population","popind","individual","single")` | monoexponential | Which output types to return | +| `min.points` | `3` | stepwise linear | Fewest dose occasions a linear model is fit to | +| `level` | `0.95` | stepwise linear | Confidence level for the test of a nonzero slope | | `verbose` | `FALSE` | both | Print model fitting details | +## When the models do not fit + +Both methods fit models, and the fits can fail when the troughs do not support them. That happens, for example, when the troughs are already flat from the first dose, are very noisy, or come from too few subjects or occasions. The warnings say which estimate is affected: + +- `nlme` warnings such as "nlminb() did not converge" or "Singular precision matrix" come from the population models that `pk.tss.monoexponential()` tries: random effects on Ctrough,ss and TSS, on TSS only, or on Ctrough,ss only. The best converged model by AIC is used. +- `pknca_warning_tss_popind_no_random_effect`: the best population model has no random effect on TSS, so `tss.monoexponential.popind` is `NA`. +- `pknca_warning_tss_population_no_convergence`: no population model converged, so `tss.monoexponential.population` and `tss.monoexponential.popind` are `NA`. +- `pknca_warning_tss_stepwise_fit_failed` (≥ 0.13.0): steady state was not found and some stepwise linear fits failed. The warning names the start times that were skipped; before 0.13.0 the `NA` was silent. + +The simulated data above rise over several doses, so every model fits without a warning. Treat any of these warnings as a sign to plot the troughs and check that the design can show the approach to steady state. + > **Note:** TSS estimation requires **multiple dose occasions with consistent trough sampling**. It is not applicable to single-dose studies or studies where trough collection was inconsistent. From 545c237fb60afc40355a620e6b64ee7fd1e4e127 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 10 Oct 2026 02:16:39 +0000 Subject: [PATCH 03/12] docs: add the secondary parameters chapter; update extravascular, intravascular, and urine chapters Co-Authored-By: Claude Opus 5.5 --- user/extravascular.qmd | 108 +++--- user/intravascular.qmd | 115 +++++-- user/secondary-parameters.qmd | 613 ++++++++++++++++++++++++++++++++++ user/urine-excretion.qmd | 127 +++---- 4 files changed, 825 insertions(+), 138 deletions(-) create mode 100644 user/secondary-parameters.qmd diff --git a/user/extravascular.qmd b/user/extravascular.qmd index 16e87bb..f8bf9df 100644 --- a/user/extravascular.qmd +++ b/user/extravascular.qmd @@ -85,13 +85,15 @@ as.data.frame(o_nca) |> | `cmax` | Maximum observed concentration | | `tmax` | Time of Cmax | | `cmin` | Minimum observed concentration in the interval | -| `tmin` | Time of the minimum observed concentration (≥ 0.12.2) | +| `tmin` | Time of the minimum observed concentration (≥ 0.13.0) | | `tfirst` | First time with a non-zero (non-BLQ) concentration | | `tlast` | Last time with a measurable concentration | | `clast.obs` | Observed concentration at `tlast` | | `clast.pred` | Predicted concentration at `tlast` (from λz fit) | -| `count_conc` | Total observations (including BLQ) | -| `count_conc_measured` | Observations above LOQ (non-BLQ) | +| `count_conc` | Number of non-missing concentrations (including BLQ) | +| `count_conc_measured` | Number of concentrations above LOQ (non-BLQ) | + +Both counts are taken after imputation and do not distinguish imputed from measured values: an imputed zero (`start_conc0`) adds to `count_conc` but not to `count_conc_measured`, while a concentration carried to the start (`start_predose`) adds to both. To count only measured samples, calculate the counts in an interval without imputation. | `lambda.z.time.last` | Last timepoint used in the λz regression | ```{r} @@ -137,7 +139,7 @@ identical( ) ``` -The option has no effect here because no Theoph subject reaches its maximum concentration at two timepoints — `first.tmax` only matters when the maximum is an exact tie. Tie-breaking for Tmin is controlled by the `first.tmin` option (≥ 0.12.2; default `TRUE` = return first occurrence). All calculation options are cataloged in [Options for Controlling PKNCA](https://humanpred.github.io/pknca/articles/v40-options-for-controlling-PKNCA.html). +The option has no effect here because no Theoph subject reaches its maximum concentration at two timepoints — `first.tmax` only matters when the maximum is an exact tie. Tie-breaking for Tmin is controlled by the `first.tmin` option (≥ 0.13.0; default `TRUE` = return first occurrence). All calculation options are cataloged in [Options for Controlling PKNCA](https://humanpred.github.io/pknca/articles/v40-options-for-controlling-PKNCA.html). ### AUC variants @@ -228,6 +230,8 @@ For extravascular dosing the fraction absorbed (F) is unknown, so clearance and | `cl.obs` | Apparent clearance = Dose / AUCinf.obs (this is CL/F) | | `vz.obs` | Apparent volume = CL.obs / λz (this is Vz/F) | +Each clearance and volume is available for every AUC basis, named by its suffix: `.obs` and `.pred` (AUCinf with observed or predicted Clast), `.last` (AUClast), `.all` (AUCall), and `.int.last`, `.int.all`, `.int.inf.obs`, `.int.inf.pred` (the interval AUCs, `aucint.*`). PKNCA 0.13.0 completed these families for `cl.*`, `vz.*`, `vss.*`, `mrt.*`, and `kel.*` (for example `cl.all`, `vz.last`, `vss.int.last`, `mrt.int.inf.obs`); the full list is printed at the end of this chapter. + ### Lag time (tlag) `tlag` is the time before absorption begins — the delay between dosing and the first measurable rise in concentration. Common for enteric-coated formulations or SC injections with a diffusion delay. @@ -245,9 +249,9 @@ as.data.frame(o_nca_tlag) |> > For the Theoph dataset, tlag is 0 for all subjects (no lag). It becomes non-zero when the concentration-time profile shows a flat period after dosing before rising. -### Bioavailability (f) +### Bioavailability (f.obs and related) -`f` (relative bioavailability via `pk.calc.f()`) requires paired test/reference treatments in the grouping structure — see the [Bioavailability](#bioavailability) section below. +Bioavailability (`pk.calc.f()`) compares this administration with a reference administration, so it needs two profiles — see the [Bioavailability](#bioavailability) section below. The parameter formerly named `f` is `f.obs` since PKNCA 0.13.0 (requesting `f` is an error), alongside `f.pred`, `f.last`, and the interval-based `f.int.*`. ### Volume at steady state (Vss) @@ -351,13 +355,15 @@ as.data.frame(o_nca_tau) |> select(subject, PPTESTCD, PPORRES) In some studies, the predose sample is missing or was not collected. PKNCA can impute a time-0 concentration before calculations. Full method descriptions and per-interval examples are in the [Concentration Imputation chapter](imputation.qmd) and the [Data Imputation vignette](https://humanpred.github.io/pknca/articles/v08-data-imputation.html). -Available built-in methods (comma-separate to chain them): +Available built-in methods (comma-separate to chain them; `pknca_impute_methods()` lists them): | Method | What it does | |---|---| -| `start_predose` | Uses the last pre-interval concentration at the interval start (no sample is added if no pre-interval observation exists) | -| `start_conc0` | Sets the predose concentration to 0 unconditionally | -| `start_cmin` | Uses the minimum observed concentration as predose value | +| `start_predose` | Shifts the most recent measured pre-interval concentration to the interval start, if no concentration exists at the start (nothing is added if there is no pre-interval observation) | +| `start_conc0` | Sets the start concentration to 0, replacing any existing value at the start time | +| `start_predose_conc0` | Uses a predose concentration when one is available and 0 otherwise, keeping a concentration measured at the start time (≥ 0.13.0) | +| `start_cmin` | Adds the minimum concentration in the interval at the start time | +| `end_conc_drop` | Drops a concentration measured exactly at the interval end (≥ 0.13.0) | ```{r} # Remove the time=0 observation from subject 1 to simulate missing predose @@ -398,7 +404,7 @@ d_intervals_impute <- data.frame( o_data_per_interval <- PKNCAdata( o_conc_miss, o_dose, intervals = d_intervals_impute, - impute = "impute" # tells PKNCA to look in the intervals column named "impute" + impute = "impute" # the name of the intervals column holding the method ) o_nca_per_interval <- pk.nca(o_data_per_interval) @@ -411,46 +417,22 @@ bind_rows( select(version, PPTESTCD, PPORRES) ``` -With a single interval row the per-interval column reproduces the global setting; with several rows, each row's `impute` value applies only to its own calculation window. +With a single interval row the per-interval column reproduces the global setting; with several rows, each row's `impute` value applies only to its own calculation window. The column can have any name (≥ 0.13.0); `PKNCAdata(impute = )` names it: a value that is a column name of the intervals is read as that column, and any other value is the method for every interval. --- ## Bioavailability -Bioavailability (F) compares dose-normalized exposure from the EV route against an IV reference. In PKNCA, F is computed via the `f` interval parameter — backed by `pk.calc.f()` — which requires the test and reference treatments to be distinguished in the grouping structure (e.g. a treatment or period column), with the reference AUC available for the ratio. Theoph (oral theophylline) and Indometh (IV indomethacin) are different drugs from different studies, so no real F can be computed here; the code below only illustrates the AUC inputs and the arithmetic: +Bioavailability (F) compares the dose-normalized exposure of the extravascular dose with an IV reference (absolute F) or another formulation (relative F). Since PKNCA 0.13.0 it is a *secondary parameter* calculated in the same `pk.nca()` run as the AUCs: the intervals carry a treatment (or route, or formulation) grouping column, the test intervals request `f.obs`, and the reference profile is named with `PKNCAdata(group_ref = )`, an `f.obs_ref` pointer, or `interval_add_secondary()`. `f.obs` takes the reference dose and AUCinf,obs from the reference interval and the test dose (`totdose`) and AUCinf,obs from its own interval; `dose2`/`auc2` interval columns, used by older workarounds, are ignored. Variants name the AUC they are built on: -```{r} -# IV reference (using Indometh as stand-in — illustrative syntax only) -d_iv_conc <- as.data.frame(Indometh) -d_iv_dose <- data.frame(Subject = unique(d_iv_conc$Subject), dose = 25, time = 0) - -o_iv_conc <- PKNCAconc(d_iv_conc, conc ~ time | Subject) -o_iv_dose <- PKNCAdose(d_iv_dose, dose ~ time | Subject, route = "intravascular") -o_iv_data <- PKNCAdata( - o_iv_conc, o_iv_dose, - intervals = data.frame(start = 0, end = Inf, aucinf.obs = TRUE), - impute = "start_conc0" # Indometh has no t = 0 sample -) -o_iv_nca <- pk.nca(o_iv_data) - -iv_aucinf <- as.data.frame(o_iv_nca) |> - filter(PPTESTCD == "aucinf.obs") |> - summarise(mean_aucinf = mean(PPORRES)) |> - pull(mean_aucinf) - -ev_aucinf <- as.data.frame(o_nca_auc) |> - filter(PPTESTCD == "aucinf.obs") |> - summarise(mean_aucinf = mean(PPORRES)) |> - pull(mean_aucinf) - -# F = (AUC_ev / Dose_ev) / (AUC_iv / Dose_iv); here only the AUC ratio is shown -cat(sprintf("Mean IV AUCinf (Indometh): %.2f\n", iv_aucinf)) -cat(sprintf("Mean EV AUCinf (Theoph): %.2f\n", ev_aucinf)) -cat(sprintf("AUC ratio EV/IV: %.1f — illustrative only, different drugs\n", - ev_aucinf / iv_aucinf)) -``` +| Parameter | AUC basis | +|---|---| +| `f.obs` | `aucinf.obs` (formerly `f`) | +| `f.pred` | `aucinf.pred` | +| `f.last` | `auclast` | +| `f.int.last`, `f.int.all`, `f.int.obs`, `f.int.pred` | `aucint.last`, `aucint.all`, `aucint.inf.obs`, `aucint.inf.pred` | -> In a real bioavailability study, subjects receive both the IV reference and the EV test treatment, and `pk.calc.f()` computes F as the dose-normalized AUC ratio of test to reference. `f` is typically used in crossover bioequivalence studies — see `?pk.calc.f` for the full setup. +Theoph has no reference administration, so the worked example — an IV and oral crossover with `f.obs` and `f.last` checked by hand — is in the [Secondary Parameters chapter](secondary-parameters.qmd#bioavailability). The same chapter covers food-effect ratios (`ratio.cmax`, `ratio.aucinf.obs`) for comparing fed and fasted extravascular doses. --- @@ -564,7 +546,7 @@ o_data_blq <- PKNCAdata( On Theoph the per-phase rule reproduces the default results (the predose zeros sit before Tmax and are kept), while the global `"drop"` is a trap for intervals starting at the dose time: it removes the predose zeros too, so AUCs that start at 0 begin before the first remaining measurement and return `NA` with a warning. -To use BLQ observations in half-life estimation rather than dropping them, see [Half-life calculation with Tobit regression](https://humanpred.github.io/pknca/articles/v06-half-life-calculation-tobit.html) (≥ 0.12.2). +To use BLQ observations in half-life estimation rather than dropping them, see [Half-life calculation with Tobit regression](https://humanpred.github.io/pknca/articles/v06-half-life-calculation-tobit.html) (≥ 0.13.0). --- @@ -640,34 +622,54 @@ ev_relevant <- c( # AUC family "auclast", "aucall", "aucinf.obs", "aucinf.pred", "aucpext.obs", "aucpext.pred", - "aucint.last", "aucint.last.dose", "aucint.all", "aucint.all.dose", - "aucint.inf.obs", "aucint.inf.obs.dose", + "aucint.last", "aucint.all", "aucint.inf.obs", "aucint.inf.pred", # AUMC "aumclast", "aumcall", "aumcinf.obs", "aumcinf.pred", + "aumcint.last", "aumcint.all", "aumcint.inf.obs", "aumcint.inf.pred", # Half-life / λz "half.life", "lambda.z", "lambda.z.n.points", "lambda.z.time.first", "lambda.z.time.last", "r.squared", "adj.r.squared", "span.ratio", # Clearance (apparent) "cl.obs", "cl.pred", "cl.last", "cl.all", + "cl.int.last", "cl.int.all", "cl.int.inf.obs", "cl.int.inf.pred", # Volume (apparent) - "vz.obs", "vz.pred", "vss.obs", "vss.pred", "vss.last", + "vz.obs", "vz.pred", "vz.last", "vz.all", + "vz.int.last", "vz.int.all", "vz.int.inf.obs", "vz.int.inf.pred", + "vss.obs", "vss.pred", "vss.last", "vss.all", + "vss.int.last", "vss.int.all", "vss.int.inf.obs", "vss.int.inf.pred", # MRT - "mrt.last", "mrt.obs", "mrt.pred", + "mrt.last", "mrt.obs", "mrt.pred", "mrt.all", + "mrt.int.last", "mrt.int.all", "mrt.int.inf.obs", "mrt.int.inf.pred", # Effective half-life / kel "thalf.eff.last", "thalf.eff.obs", "thalf.eff.pred", - "kel.obs", "kel.pred", "kel.last", - # Bioavailability - "f", + "kel.obs", "kel.pred", "kel.last", "kel.all", + "kel.int.last", "kel.int.all", "kel.int.inf.obs", "kel.int.inf.pred", + # Bioavailability (secondary parameters; need a reference interval) + "f.obs", "f.pred", "f.last", "f.int.last", "f.int.all", "f.int.obs", "f.int.pred", # Dose-normalized "auclast.dn", "aucinf.obs.dn", "cmax.dn", "clast.obs.dn" ) sort(names(interval_cols)[names(interval_cols) %in% ev_relevant]) + +# Every name above is registered +setdiff(ev_relevant, names(interval_cols)) +``` + +This list is curated. `pknca_parameter_table()` (≥ 0.13.0) classifies every registered parameter, including the routes it applies to, so the complete catalog for extravascular dosing is one filter away: + +```{r} +d_param_table <- pknca_parameter_table() +d_ev_params <- d_param_table[grepl("extravascular", d_param_table$route), ] +nrow(d_ev_params) +head(d_ev_params[, c("parameter", "concept", "tier", "sparse", "secondary")]) ``` +The `sparse` column flags outputs that apply to sparse sampling (such as the standard error `auclast_se`), and `secondary` flags parameters that need a reference interval, such as `f.obs`. + --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pk.calc.f()](https://humanpred.github.io/pknca/reference/pk.calc.f.html) · [pknca_units_table()](https://humanpred.github.io/pknca/reference/pknca_units_table.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [PKNCA_impute_method_start_*()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pk.calc.f()](https://humanpred.github.io/pknca/reference/pk.calc.f.html) · [pknca_parameter_table()](https://humanpred.github.io/pknca/reference/pknca_parameter_table.html) · [pknca_units_table()](https://humanpred.github.io/pknca/reference/pknca_units_table.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [PKNCA_impute_method_start_*()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) · [pknca_impute_methods()](https://humanpred.github.io/pknca/reference/pknca_impute_methods.html) ::: diff --git a/user/intravascular.qmd b/user/intravascular.qmd index ef1a111..a73ce17 100644 --- a/user/intravascular.qmd +++ b/user/intravascular.qmd @@ -52,8 +52,9 @@ o_conc <- PKNCAconc(d_conc, conc ~ time | Subject) o_dose <- PKNCAdose(d_dose, dose ~ time | Subject, route = "intravascular") # Indometh: no sample at t = 0 (first sample is at 0.25 h). -# Use impute = "start_conc0" to add conc = 0 at t = 0 so the interval can start at 0. -# This slightly underestimates AUClast vs. true C0 back-extrapolation (see C0 section below). +# auclast/aucinf.obs need a concentration at the interval start, so +# impute = "start_conc0" adds conc = 0 at t = 0. For an IV bolus this +# underestimates the AUC; the auciv* family below uses the back-extrapolated C0. # Request the core IV parameters iv_intervals <- data.frame( start = 0, @@ -78,7 +79,7 @@ as.data.frame(o_nca) |> arrange(Subject, PPTESTCD) ``` -Imputation methods, including `start_conc0`, are described in the [Data Imputation vignette](https://humanpred.github.io/pknca/articles/v08-data-imputation.html). +Imputation methods, including `start_conc0`, are described in the [Data Imputation vignette](https://humanpred.github.io/pknca/articles/v08-data-imputation.html). The zero imputed at the dose time keeps this chapter's examples short, but it is not the concentration after an IV bolus: the [`auciv*` family](#iv-route-auc-auciv) and the clearance and volume built on it (`cl.iv.*`, `vz.iv.*`) replace the first segment using the back-extrapolated C0. When `intervals` is omitted, the automatically generated intervals (≥ 0.13.0) treat an IV bolus the same way: they impute with `start_predose_conc0` (a predose concentration if there is one, else 0, keeping any concentration measured at the dose time) and add a separate request for `c0`. --- @@ -93,7 +94,7 @@ For IV bolus, Cmax is the first measured concentration (no absorption phase). Tm | `cmax` | Maximum observed concentration | | `tmax` | Time of Cmax | | `cmin` | Minimum observed concentration in the interval | -| `tmin` | Time of the minimum observed concentration (≥ 0.12.2) | +| `tmin` | Time of the minimum observed concentration (≥ 0.13.0) | | `tfirst` | First time with a non-zero (non-BLQ) concentration | | `tlast` | Last time with a measurable concentration | | `clast.obs` | Observed concentration at `tlast` | @@ -232,7 +233,7 @@ The λz point-selection algorithm and its quality criteria are detailed in the [ **Excluding specific points from λz:** -Use the `exclude_half.life` argument of `PKNCAconc()` to name a logical column (here `excl_hl`) marking points that should not be included in the terminal regression. Below, subject 1's 8 h sample is dropped from the λz fit, and the regression is re-selected without it: +Use the `exclude_half.life` argument of `PKNCAconc()` to name a logical column (here `excl_hl`) marking points that should not be included in the terminal regression. Below, subject 1's 8 h sample is dropped from the λz fit, and the regression is re-selected without it. The half-life parameters need no concentration at the dose time, so this interval is calculated without imputation: ```{r} d_conc_excl <- d_conc |> @@ -244,18 +245,30 @@ o_conc_excl <- PKNCAconc( exclude_half.life = "excl_hl" ) -o_nca_excl_hl <- pk.nca(PKNCAdata(o_conc_excl, o_dose, intervals = hl_params, - impute = "start_conc0")) +hl_params_times <- hl_params |> + mutate(lambda.z.time.first = TRUE, lambda.z.time.last = TRUE) + +o_nca_hl_all <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = hl_params_times)) +o_nca_excl_hl <- pk.nca(PKNCAdata(o_conc_excl, o_dose, intervals = hl_params_times)) # Subject 1's terminal fit changes when the 8 h point is excluded bind_rows( - as.data.frame(o_nca_hl) |> mutate(version = "all points"), - as.data.frame(o_nca_excl_hl) |> mutate(version = "8 h point excluded") + as.data.frame(o_nca_hl_all) |> mutate(version = "all points"), + as.data.frame(o_nca_excl_hl) |> mutate(version = "8 h excluded") ) |> - filter(Subject == "1", PPTESTCD %in% c("lambda.z.n.points", "half.life")) |> - select(version, PPTESTCD, PPORRES) + filter(Subject == "1", + PPTESTCD %in% c("lambda.z.time.first", "lambda.z.time.last", + "lambda.z.n.points", "half.life")) |> + select(version, PPTESTCD, PPORRES) |> + tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) ``` +With all points, the best fit uses the last three samples (5 to 8 h). Without the 8 h sample, the selected fit runs from 2 to 6 h with five points, and the half-life is shorter. + +::: {.callout-warning} +In the PKNCA development version this book documents, combining `exclude_half.life` (or `include_half.life`) with an imputation that adds a concentration, such as `start_conc0`, misaligns the per-point flags with the concentrations: the flags stay attached to the original row positions while the imputed point shifts the concentrations by one. Calculate half-life with point selection in an interval without imputation, as here. +::: + ### Clearance and volume For IV dosing, clearance and volume are obtained directly (no bioavailability factor involved), unlike the apparent CL/F and Vz/F of extravascular routes. @@ -270,7 +283,12 @@ For IV dosing, clearance and volume are obtained directly (no bioavailability fa | `vz.pred` | CL.pred / λz | Vz using predicted Clast | | `vss.obs` | CL.obs × MRT | Volume at steady state (observed) | | `vss.pred` | CL.pred × MRT | Volume at steady state (predicted) | -| `vss.iv.obs` | CL.obs × MRT.iv | Vss corrected for IV bolus input | +| `vss.iv.obs` | CL.obs × MRT.iv.obs | Vss with the IV MRT (infusion-duration correction) | +| `cl.iv.obs` | Dose / AUCiv,inf,obs | CL from the AUC with C0 back-extrapolation (≥ 0.13.0) | +| `cl.iv.last` | Dose / AUCiv,last | As above, from `aucivlast` (≥ 0.13.0) | +| `vz.iv.obs` | CL.iv.obs / λz | Vz from the back-extrapolated AUC (≥ 0.13.0) | + +PKNCA 0.13.0 completed these families across AUC bases: each of `cl.*`, `vz.*`, and `vss.*` has `.obs`, `.pred`, `.last`, `.all`, and interval-AUC (`.int.last`, `.int.all`, `.int.inf.obs`, `.int.inf.pred`) forms, and the IV bolus variants `cl.iv.*`, `vz.iv.*` (built on `auciv*`) and `vss.iv.*` (built on `mrt.iv.*`) come in the matching forms (`cl.iv.all`, `cl.ivint.last`, `vz.iv.last`, `vss.ivint.all`, …). The full list is printed at the end of this chapter. ```{r} cl_params <- data.frame( @@ -282,18 +300,23 @@ cl_params <- data.frame( vz.obs = TRUE, vz.pred = TRUE, vss.obs = TRUE, - vss.iv.obs = TRUE + vss.iv.obs = TRUE, + cl.iv.obs = TRUE, + vz.iv.obs = TRUE ) o_data_cl <- PKNCAdata(o_conc, o_dose, intervals = cl_params, impute = "start_conc0") o_nca_cl <- pk.nca(o_data_cl) as.data.frame(o_nca_cl) |> - filter(PPTESTCD %in% c("cl.obs", "cl.pred", "cl.last", "vz.obs", "vss.obs", "vss.iv.obs")) |> + filter(PPTESTCD %in% c("cl.obs", "cl.pred", "cl.last", "vz.obs", "vss.obs", "vss.iv.obs", + "cl.iv.obs", "vz.iv.obs")) |> select(Subject, PPTESTCD, PPORRES) |> arrange(Subject, PPTESTCD) ``` +`cl.iv.obs` is lower than `cl.obs` for every subject because its AUC includes the back-extrapolated area between the dose and the first sample, which the imputed zero leaves out of `aucinf.obs`. + ### Mean residence time (MRT) MRT quantifies the average time a drug molecule spends in the body. @@ -311,6 +334,8 @@ All MRT variants for IV: | `mrt.obs` | MRT extrapolated to ∞, observed Clast (no IV correction) | | `mrt.pred` | MRT extrapolated to ∞, predicted Clast (no IV correction) | +The other AUC bases follow the same naming: `mrt.all` / `mrt.iv.all` (AUCall), `mrt.int.*` / `mrt.ivint.*` (interval AUCs; ≥ 0.13.0), and the steady-state `mrt.md.*` and `mrt.ivmd.*` (see the [infusion section](#mrt-correction-for-infusions)). + The IV-corrected variants (`mrt.iv.*`) subtract half the infusion duration from the raw AUMC/AUC ratio, while the non-IV variants (`mrt.*`) use the raw ratio — see [MRT correction for infusions](#mrt-correction-for-infusions) below for the formula and a worked example. ```{r} @@ -385,7 +410,7 @@ as.data.frame(o_nca_eff) |> ### IV-route AUC (`auciv*`) -`auciv*` parameters compute AUC using IV-specific formulas. Unlike the general `auclast`/`aucinf.obs`, these treat the dose as a bolus at time 0 and back-extrapolate C0 before integrating. +`auciv*` parameters compute the AUC of an IV bolus with C0: the first segment, from the dose to the first sample, is integrated from the back-extrapolated `c0` (see [C0](#c0-extrapolated-initial-concentration) above) rather than from whatever concentration the data hold at time 0. Since PKNCA 0.13.0 they do not need a concentration at time 0 at all: when the profile starts after the dose, `c0` supplies it. The `aumciv*` parameters (≥ 0.13.0) do the same for the AUMC. Complete `auciv*` family: @@ -397,6 +422,7 @@ Complete `auciv*` family: | `aucivinf.pred` | AUC(0–∞) IV method, predicted Clast | | `aucivint.last` | AUC over interval [start,end], IV method, AUClast extrapolation | | `aucivint.all` | AUC over interval [start,end], IV method, AUCall extrapolation | +| `aumcivlast`, `aumcivall`, `aumcivinf.obs`, `aumcivinf.pred`, `aumcivint.last`, `aumcivint.all` | The matching AUMCs, IV method | | `aucivpbextlast` | % of `aucivlast` contributed by back-extrapolation — the C0 segment before the first measured sample | | `aucivpbextall` | % of `aucivall` contributed by the back-extrapolated C0 segment | | `aucivpbextinf.obs` | % of `aucivinf.obs` contributed by the back-extrapolated C0 segment | @@ -404,6 +430,10 @@ Complete `auciv*` family: | `aucivpbextint.last` | % of `aucivint.last` contributed by the back-extrapolated C0 segment | | `aucivpbextint.all` | % of `aucivint.all` contributed by the back-extrapolated C0 segment | +The percent back-extrapolated, `100 × (1 − AUC / AUCiv)`, compares the IV AUC with the ordinary AUC calculated from the data, so it needs a concentration at time 0 in the data: one that was measured, or one imputed as here. Without any concentration at time 0, `aucivpbext*` is `NA` (≥ 0.13.0) with the reason "Percent back-extrapolated requires a measured concentration at time 0", because the ordinary AUC then either cannot be calculated or already extrapolates back to time 0 and does not describe the observed part. + +This example uses `start_predose_conc0`, the imputation that the automatic intervals use for an IV bolus (Indometh has no predose samples, so it imputes 0 at the dose time, as `start_conc0` would): + ```{r} auciv_params <- data.frame( start = 0, @@ -412,21 +442,23 @@ auciv_params <- data.frame( aucivall = TRUE, aucivinf.obs = TRUE, aucivinf.pred = TRUE, + aumcivinf.obs = TRUE, aucivpbextlast = TRUE, aucivpbextall = TRUE, aucivpbextinf.obs = TRUE, aucivpbextinf.pred = TRUE ) -o_nca_auciv <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = auciv_params, impute = "start_conc0")) +o_nca_auciv <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = auciv_params, + impute = "start_predose_conc0")) as.data.frame(o_nca_auciv) |> - filter(grepl("^auciv", PPTESTCD)) |> + filter(grepl("^(auciv|aumciv)", PPTESTCD)) |> select(Subject, PPTESTCD, PPORRES) |> arrange(Subject, PPTESTCD) ``` -> **When to use `auciv*` vs `auclast`/`aucinf.obs`:** For a true IV bolus where C0 is back-extrapolated, `aucivlast` and `aucivinf.obs` give the more theoretically correct AUC. For practical purposes the numerical difference is usually small. +> **When to use `auciv*` vs `auclast`/`aucinf.obs`:** For an IV bolus sampled after the dose, `aucivlast` and `aucivinf.obs` give the more theoretically correct AUC, and `cl.iv.*` / `vz.iv.*` carry it into clearance and volume. `aucivpbext*` shows how much of that AUC came from back-extrapolation; for Indometh it is about 9–20% depending on the subject and AUC, so the choice matters here. --- @@ -490,6 +522,8 @@ For an IV bolus (`duration = 0`) the correction is zero. For a 0.5 h infusion, t Use `mrt.iv.obs` (extrapolated to ∞) when the terminal phase is well characterized and `mrt.iv.last` (to last measurable) otherwise. +At steady state after repeated infusions, use `mrt.ivmd.obs` / `mrt.ivmd.pred` and `vss.ivmd.obs` / `vss.ivmd.pred` (≥ 0.13.0). They subtract half the infusion duration, which `mrt.md.*` and `vss.md.*` do not, so for an infusion those are high by `duration/2` (MRT) and by `cl.last × duration/2` (Vss). All of them need the dosing interval `tau`, which is detected from the dose times or given as a `tau` column of the intervals; see the [Multiple-Dose chapter](multiple-dose.qmd). + --- ## AUC over a specific interval @@ -513,6 +547,8 @@ as.data.frame(o_nca_partial) |> arrange(Subject) ``` +`auclast` integrates only the measured points inside the window. For an AUC over exactly 0–4 h whether or not a sample falls at 4 h, request `aucint.last` (or `aucint.all`, `aucint.inf.obs`, `aucint.inf.pred`), which interpolate or extrapolate to the window ends; `aucivint.last` and `aucivint.all` do the same with the IV C0. Every `aucint` parameter is dose-aware since PKNCA 0.13.0, so the former `aucint.*.dose` variants are retired (requesting one is an error naming its replacement); see the [AUC chapter](auc-methods.qmd) and [Dose-Aware Interpolation chapter](dose-aware-interpolation.qmd). + Interval semantics are explained in [Selection of Calculation Intervals](https://humanpred.github.io/pknca/articles/v03-selection-of-calculation-intervals.html). --- @@ -601,39 +637,64 @@ iv_relevant <- c( # AUC family "auclast", "aucall", "aucinf.obs", "aucinf.pred", "aucpext.obs", "aucpext.pred", - "aucint.last", "aucint.last.dose", "aucint.all", "aucint.all.dose", - "aucint.inf.obs", "aucint.inf.obs.dose", - # IV-specific AUC + "aucint.last", "aucint.all", "aucint.inf.obs", "aucint.inf.pred", + # IV-specific AUC and AUMC "aucivlast", "aucivall", "aucivinf.obs", "aucivinf.pred", "aucivint.last", "aucivint.all", "aucivpbextlast", "aucivpbextall", "aucivpbextinf.obs", "aucivpbextinf.pred", + "aucivpbextint.last", "aucivpbextint.all", + "aumcivlast", "aumcivall", "aumcivinf.obs", "aumcivinf.pred", + "aumcivint.last", "aumcivint.all", # AUMC "aumclast", "aumcall", "aumcinf.obs", "aumcinf.pred", + "aumcint.last", "aumcint.all", "aumcint.inf.obs", "aumcint.inf.pred", # Half-life / λz "half.life", "lambda.z", "lambda.z.n.points", "lambda.z.time.first", "lambda.z.time.last", "r.squared", "adj.r.squared", "span.ratio", # Clearance "cl.obs", "cl.pred", "cl.last", "cl.all", + "cl.int.last", "cl.int.all", "cl.int.inf.obs", "cl.int.inf.pred", + "cl.iv.obs", "cl.iv.pred", "cl.iv.last", "cl.iv.all", "cl.ivint.last", "cl.ivint.all", # Volume - "vz.obs", "vz.pred", "vss.obs", "vss.pred", "vss.iv.obs", "vss.iv.pred", - "vss.last", "vss.iv.last", + "vz.obs", "vz.pred", "vz.last", "vz.all", + "vz.int.last", "vz.int.all", "vz.int.inf.obs", "vz.int.inf.pred", + "vz.iv.obs", "vz.iv.pred", "vz.iv.last", "vz.iv.all", "vz.ivint.last", "vz.ivint.all", + "vss.obs", "vss.pred", "vss.last", "vss.all", + "vss.int.last", "vss.int.all", "vss.int.inf.obs", "vss.int.inf.pred", + "vss.iv.obs", "vss.iv.pred", "vss.iv.last", "vss.iv.all", "vss.ivint.last", "vss.ivint.all", + "vss.md.obs", "vss.md.pred", "vss.ivmd.obs", "vss.ivmd.pred", # MRT - "mrt.iv.last", "mrt.iv.obs", "mrt.iv.pred", "mrt.last", "mrt.obs", "mrt.pred", + "mrt.last", "mrt.obs", "mrt.pred", "mrt.all", + "mrt.int.last", "mrt.int.all", "mrt.int.inf.obs", "mrt.int.inf.pred", + "mrt.iv.last", "mrt.iv.obs", "mrt.iv.pred", "mrt.iv.all", "mrt.ivint.last", "mrt.ivint.all", + "mrt.md.obs", "mrt.md.pred", "mrt.ivmd.obs", "mrt.ivmd.pred", # Effective half-life / kel "thalf.eff.iv.last", "thalf.eff.iv.obs", "thalf.eff.iv.pred", "thalf.eff.pred", "thalf.eff.last", "thalf.eff.obs", - "kel.obs", "kel.pred", "kel.last", "kel.iv.obs", "kel.iv.pred", "kel.iv.last", + "kel.obs", "kel.pred", "kel.last", "kel.all", + "kel.int.last", "kel.int.all", "kel.int.inf.obs", "kel.int.inf.pred", + "kel.iv.obs", "kel.iv.pred", "kel.iv.last", "kel.iv.all", "kel.ivint.last", "kel.ivint.all", # Dose-normalized "auclast.dn", "aucinf.obs.dn", "cmax.dn", "clast.obs.dn" ) -# All registered in PKNCA sort(names(interval_cols)[names(interval_cols) %in% iv_relevant]) + +# Every name above is registered +setdiff(iv_relevant, names(interval_cols)) +``` + +This list is curated. `pknca_parameter_table()` (≥ 0.13.0) records the routes each registered parameter applies to, so the parameters for an IV bolus can be listed directly; for example, those that apply to an IV bolus but not to extravascular dosing: + +```{r} +d_param_table <- pknca_parameter_table() +d_param_table$parameter[grepl("iv_bolus", d_param_table$route) & + !grepl("extravascular", d_param_table$route)] ``` --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pknca_units_table()](https://humanpred.github.io/pknca/reference/pknca_units_table.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [business.*()](https://humanpred.github.io/pknca/reference/business.mean.html) · [PKNCA_impute_method_start_*()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pk.calc.c0()](https://humanpred.github.io/pknca/reference/pk.calc.c0.html) · [pk.calc.auciv()](https://humanpred.github.io/pknca/reference/pk.calc.auxciv.html) · [pk.calc.mrt.iv()](https://humanpred.github.io/pknca/reference/pk.calc.mrt.html) · [pknca_parameter_table()](https://humanpred.github.io/pknca/reference/pknca_parameter_table.html) · [pknca_units_table()](https://humanpred.github.io/pknca/reference/pknca_units_table.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [business.*()](https://humanpred.github.io/pknca/reference/business.mean.html) · [PKNCA_impute_method_start_*()](https://humanpred.github.io/pknca/reference/PKNCA_impute_method.html) ::: diff --git a/user/secondary-parameters.qmd b/user/secondary-parameters.qmd new file mode 100644 index 0000000..58c50e5 --- /dev/null +++ b/user/secondary-parameters.qmd @@ -0,0 +1,613 @@ +--- +title: "Secondary Parameters" +--- + +```{r setup, include=FALSE} +library(PKNCA) +library(dplyr) +conflicted::conflicts_prefer(dplyr::filter, dplyr::select, .quiet = TRUE) +``` + +## What a secondary parameter is (≥ 0.13.0) + +Almost every NCA parameter is calculated from the concentrations and doses within one interval of one profile. A few cannot be: + +- **Renal clearance** divides the amount excreted into a urine collection by the plasma AUC over the same times. The amount and the AUC come from different profiles (urine and plasma). +- **Bioavailability** compares the dose-normalized exposure of a test administration with a reference administration (for absolute bioavailability, an IV dose). +- **Ratios** compare a parameter with the same parameter somewhere else: steady state against the first dose (accumulation), a metabolite against its parent, fed against fasted. + +PKNCA calls these **secondary parameters**. Starting with PKNCA 0.13.0 they are calculated in the same `pk.nca()` run as everything else, by linking two intervals of the interval specification. Two terms are used throughout this chapter: + +- The **requesting interval** is the intervals row that asks for the parameter. The result is reported there, on that row's groups and times. +- The **reference interval** is the row that supplies the value from the other profile. + +`pknca_parameter_table()` lists which parameters are secondary: + +```{r} +d_params <- pknca_parameter_table() +d_params |> + filter(secondary) |> + select(parameter, concept, sample_type, tier) |> + knitr::kable(row.names = FALSE) +``` + +Secondary parameters are never selected automatically (by the default intervals or by `pknca_interval_table()`), because one interval cannot say what the other profile is. Request them by name, like any other parameter. The package vignette [Secondary Parameters (Renal Clearance, Bioavailability, and Ratios)](https://humanpred.github.io/pknca/articles/v09-secondary-parameters.html) covers the same material with more detail on each mechanism. + +--- + +## Linking intervals by hand + +Two columns of the interval specification make the link: + +| Column | Meaning | +|---|---| +| `interval_id` | A name for this interval, so that another row can point at it. `NA` on rows nothing points at. | +| `_ref` | On the requesting interval, the `interval_id` of the reference interval for `` (for example `clr.last_ref`). | + +The identifiers may be character strings, factors, or numbers, as long as `interval_id` and every `*_ref` column share one class so that they can be compared. + +### Renal clearance from plasma and urine + +These data have two subjects, each with a plasma profile and three urine collections. All concentrations are in one data frame, distinguished by the `PCSPEC` (specimen) grouping column. For urine, `time` is the start of each collection, `dur` its duration, `conc` the concentration in the collected urine, and `vol` the collected volume; plasma rows have no volume. + +```{r} +d_plasma <- data.frame( + subject = rep(1:2, each = 7), + PCSPEC = "plasma", + time = rep(c(0, 1, 2, 4, 8, 12, 24), 2), + conc = c(0, 4.1, 5.0, 4.2, 2.6, 1.6, 0.4, + 0, 3.2, 4.4, 3.9, 2.5, 1.5, 0.4), + vol = NA_real_, + dur = 0 +) +d_urine <- data.frame( + subject = rep(1:2, each = 3), + PCSPEC = "urine", + time = rep(c(0, 4, 12), 2), # collection start + dur = rep(c(4, 8, 12), 2), # collection duration + conc = c(30, 25, 8, 26, 22, 9), + vol = c(0.30, 0.45, 0.80, 0.35, 0.50, 0.70) +) +d_conc <- bind_rows(d_plasma, d_urine) +d_dose <- data.frame(subject = 1:2, time = 0, dose = 100) + +o_conc <- PKNCAconc(d_conc, conc ~ time | PCSPEC + subject, + volume = "vol", duration = "dur") +o_dose <- PKNCAdose(d_dose, dose ~ time | subject) +``` + +The plasma row gets an `interval_id`, and the urine row requests `clr.last` with `clr.last_ref` pointing at it: + +```{r} +d_intervals_linked <- data.frame( + PCSPEC = c("plasma", "urine"), + start = 0, + end = 24, + interval_id = c("plasma_0_24", NA), + auclast = c(TRUE, FALSE), + ae = c(FALSE, TRUE), + fe = c(FALSE, TRUE), + clr.last = c(FALSE, TRUE), + clr.last_ref = c(NA, "plasma_0_24") +) +d_intervals_linked + +o_data_linked <- PKNCAdata(o_conc, o_dose, intervals = d_intervals_linked) +o_nca_linked <- pk.nca(o_data_linked) + +as.data.frame(o_nca_linked) |> + select(PCSPEC, subject, PPTESTCD, PPORRES, PCSPEC_ref) |> + as.data.frame() +``` + +The numbers can be checked by hand for subject 1. The amount excreted is $30 \times 0.30 + 25 \times 0.45 + 8 \times 0.80 = 26.65$, so `fe` is $26.65 / 100 = 0.2665$, and the renal clearance is the amount excreted divided by the plasma AUClast: $26.65 / 47.75 = 0.558$. + +Note where each result is reported. `clr.last` is on the urine row, which requested it, not on the plasma row that supplied the AUC. The `PCSPEC_ref` column (and `subject_ref`; one `_ref` column per grouping column) names the group the reference value came from; it is `NA` on results that took nothing from another interval. + +### The reference interval gains what the link needs + +`auclast` was requested on the plasma row to make the arithmetic visible, but it did not need to be. A reference interval calculates whatever the link reads from it, the same way any parameter's dependencies are calculated. `as.data.frame(filter_requested = TRUE)` reports only what the interval specification asked for: + +```{r} +d_intervals_implicit <- d_intervals_linked |> + mutate(auclast = FALSE, cmax = c(TRUE, FALSE)) + +o_nca_implicit <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = d_intervals_implicit)) + +as.data.frame(o_nca_implicit, filter_requested = TRUE) |> + select(PCSPEC, subject, PPTESTCD, PPORRES) |> + as.data.frame() +``` + +The plasma `auclast` was still calculated (it is in `as.data.frame(o_nca_implicit)`), and `clr.last` is unchanged. + +--- + +## Letting PKNCA find the reference for renal clearance + +For renal clearance, the pointer is usually unnecessary. When a secondary parameter is requested with no pointer and cannot be calculated within its own interval, `pk.nca()` derives the reference from the data. Here the interval specification mentions only the urine collections: + +```{r} +d_intervals_urine <- data.frame( + PCSPEC = "urine", + start = 0, + end = 24, + ae = TRUE, + clr.last = TRUE, + clr.obs = TRUE +) + +o_nca_auto <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = d_intervals_urine)) + +as.data.frame(o_nca_auto, filter_requested = TRUE) |> + select(PCSPEC, subject, PPTESTCD, PPORRES, PCSPEC_ref) |> + as.data.frame() + +# The intervals stored with the results are the ones that went in +o_nca_auto$data$intervals$PCSPEC +``` + +Each derived link is reported with a `pknca_message_secondary_ref_created` message (above) and named in the `PPANMETH` column of the result. The `clr.last` values are the same as with the hand-written link. + +The derivation is mechanical: renal clearance is measured over an interval collection while everything it references is a spot sample, and the concentration data declare a collection `volume`, so the reference is looked for among the profiles that have no collection volume. Of those, each interval takes the profile that differs from its own groups in the fewest columns (here, the plasma profile of the same subject). The derived interval is used only for the calculation; the intervals stored with the results still hold only the urine row that went in (last line above). + +This automatic derivation applies to renal clearance. Bioavailability and the `ratio.*` parameters have no way to tell from the data which profile is the reference, so they need `group_ref`, a `*_ref` pointer, or `interval_add_secondary()` (shown below); requesting one with none of these is an error that names those three options. + +### When more than one profile could be the reference + +With a second spot-sample specimen (serum as well as plasma), no single profile is nearest. PKNCA does not guess and does not stop the analysis: the affected results are `NA`, the reason is in the `exclude` column, and one `pknca_warning_secondary_auto_reference` warning is raised per parameter: + +```{r} +d_serum <- d_plasma |> + mutate(PCSPEC = "serum", conc = round(conc * 1.1, 2)) +d_conc_3 <- bind_rows(d_plasma, d_serum, d_urine) +o_conc_3 <- PKNCAconc(d_conc_3, conc ~ time | PCSPEC + subject, + volume = "vol", duration = "dur") + +d_intervals_3 <- data.frame( + PCSPEC = c("plasma", "serum", "urine"), + start = 0, + end = 24, + auclast = c(TRUE, TRUE, FALSE), + ae = c(FALSE, FALSE, TRUE), + clr.last = c(FALSE, FALSE, TRUE) +) + +o_nca_ambiguous <- pk.nca(PKNCAdata(o_conc_3, o_dose, intervals = d_intervals_3)) + +as.data.frame(o_nca_ambiguous) |> + filter(PPTESTCD == "clr.last") |> + select(PCSPEC, subject, PPTESTCD, PPORRES, exclude) |> + as.data.frame() +``` + +The warning is the intended signal that the analysis needs to say which specimen is the reference; everything else (the AUCs and `ae`) was calculated. + +### Steering the choice with `group_ref` + +`PKNCAdata(group_ref = )` is a data frame of group values saying which profiles may be references. It settles the ambiguity above: + +```{r} +o_data_steered <- PKNCAdata( + o_conc_3, o_dose, + intervals = d_intervals_3, + group_ref = data.frame(PCSPEC = "plasma") +) +o_data_steered + +as.data.frame(pk.nca(o_data_steered)) |> + filter(PPTESTCD == "clr.last") |> + select(PCSPEC, subject, PPTESTCD, PPORRES, PCSPEC_ref) |> + as.data.frame() +``` + +`group_ref` also supplies the reference for parameters that cannot be derived at all, such as bioavailability and metabolite ratios (both shown below). A plain data frame applies to every secondary parameter. When one analysis needs different steering per parameter (renal clearance by specimen and a metabolite ratio by analyte), add a `parameter` column, where the columns a row leaves `NA` do not constrain that parameter, or give a named list of data frames, one per parameter; see `?PKNCAdata` and the [group_ref section of the vignette](https://humanpred.github.io/pknca/articles/v09-secondary-parameters.html#steering-the-choice-with-group_ref). + +--- + +## Writing the linkage with `interval_add_secondary()` + +`interval_add_secondary()` writes the linkage into the interval specification, where it can be seen, edited, and saved. Given a parameter and a data frame describing the reference interval (`reference`; its columns are `start`, `end`, and grouping columns of the intervals), it requests the parameter on the other intervals, gives the reference interval an `interval_id`, and adds the `_ref` pointer. + +It returns the intervals after `check.interval.specification()`, which adds a column for every registered parameter, so the examples here show only the columns that are not all `FALSE`: + +```{r} +show_set_columns <- function(intervals) { + keep <- vapply(intervals, function(x) !is.logical(x) || any(x, na.rm = TRUE), logical(1)) + intervals[, keep, drop = FALSE] +} + +d_intervals_plain <- data.frame( + PCSPEC = c("plasma", "urine"), + start = 0, + end = 24, + auclast = c(TRUE, FALSE), + ae = c(FALSE, TRUE) +) + +interval_add_secondary( + d_intervals_plain, + param = "clr.last", + reference = data.frame(PCSPEC = "plasma") +) |> + show_set_columns() +``` + +When no interval matches `reference`, the reference interval is created, requesting what the link reads from it (here `auclast`), and the creation is reported with a `pknca_message_secondary_created_interval` message: + +```{r} +interval_add_secondary( + data.frame(PCSPEC = "urine", start = 0, end = 24, ae = TRUE), + param = "clr.last", + reference = data.frame(PCSPEC = "plasma") +) |> + show_set_columns() +``` + +Other behaviors to know: + +- `interval_add_secondary()` also accepts a `PKNCAdata` object and then returns the object with updated intervals. On a `PKNCAdata` object, `reference = NULL` (the default) writes out the same derivation that `pk.nca()` would make on its own (using the object's `group_ref` when it has one); anything the derivation cannot resolve is an error there rather than an `NA` result. +- `target_groups` restricts which intervals request the parameter; by default every interval other than the reference requests it. +- An interval that already points at a different reference for the parameter is left alone with a `pknca_warning_secondary_ref_exists` warning, so the helper never silently re-points an analysis. +- `ref_id` names the reference interval's `interval_id` instead of the generated one. + +--- + +## Bioavailability + +Bioavailability is calculated with `pk.calc.f()`: $F = (\text{AUC}_\text{test}/\text{Dose}_\text{test}) / (\text{AUC}_\text{ref}/\text{Dose}_\text{ref})$. As a secondary parameter, the reference dose and AUC come from the reference interval and the test dose and AUC from the requesting interval. + +**Renamed in 0.13.0:** the `f` parameter is now `f.obs`; an interval specification requesting `f` is an error pointing at `f.obs`. `f.obs` takes the reference values (`dose1`, `auc1`) from the reference interval and calculates the test values in its own interval as `totdose` and `aucinf.obs`; `dose2` and `auc2` interval columns are ignored. + +The name of each bioavailability parameter says which AUC it is built on: + +```{r} +f_params <- c("f.obs", "f.pred", "f.last", "f.int.last", "f.int.all", + "f.int.obs", "f.int.pred") +data.frame( + parameter = f_params, + `AUC basis` = vapply(f_params, function(x) get.interval.cols()[[x]]$formalsmap$auc2, ""), + description = vapply(f_params, function(x) get.interval.cols()[[x]]$desc, ""), + check.names = FALSE +) |> + knitr::kable(row.names = FALSE) +``` + +`f.obs`, `f.pred`, and `f.last` use the AUC extrapolated to infinity or the AUC to the last measurable concentration; the `f.int.*` variants use the interval AUCs (`aucint.*`), for example over a steady-state dosing interval. + +### Absolute bioavailability: IV reference, oral test + +In this two-period crossover, each subject received a 100 mg IV bolus and a 200 mg oral dose. The route is a column of the dosing data. To keep the example short the IV arm has a measured concentration at time 0; see the [Intravascular chapter](intravascular.qmd) for handling a bolus without one. + +```{r} +d_conc_ba <- data.frame( + subject = rep(1:2, each = 14), + treatment = rep(rep(c("IV", "oral"), each = 7), 2), + time = rep(c(0, 0.5, 1, 2, 4, 8, 12), 4), + conc = c(3.33, 3.07, 2.82, 2.39, 1.71, 0.879, 0.451, + 0, 1.72, 2.53, 2.91, 2.35, 1.22, 0.629, + 2.86, 2.62, 2.40, 2.02, 1.42, 0.712, 0.358, + 0, 1.90, 2.80, 3.25, 2.67, 1.42, 0.749) +) +d_dose_ba <- data.frame( + subject = rep(1:2, each = 2), + treatment = c("IV", "oral"), + time = 0, + dose = c(100, 200), + route = c("intravascular", "extravascular") +) + +o_conc_ba <- PKNCAconc(d_conc_ba, conc ~ time | treatment + subject) +o_dose_ba <- PKNCAdose(d_dose_ba, dose ~ time | treatment + subject, route = "route") + +d_intervals_ba <- data.frame( + treatment = c("IV", "oral"), + start = 0, + end = Inf, + aucinf.obs = TRUE, + f.obs = c(FALSE, TRUE), + f.last = c(FALSE, TRUE) +) + +o_data_ba <- PKNCAdata( + o_conc_ba, o_dose_ba, + intervals = d_intervals_ba, + group_ref = data.frame(treatment = "IV") +) +o_nca_ba <- pk.nca(o_data_ba) + +d_ba <- as.data.frame(o_nca_ba) +d_ba |> + filter(PPTESTCD %in% c("totdose", "aucinf.obs", "auclast", "f.obs", "f.last")) |> + select(treatment, subject, PPTESTCD, PPORRES, treatment_ref) |> + arrange(subject, treatment, PPTESTCD) |> + as.data.frame() +``` + +The `f.obs` result is the ratio of dose-normalized AUCinf,obs; for subject 1: + +```{r} +value_of <- function(param, trt, subj = 1) { + d_ba$PPORRES[d_ba$PPTESTCD == param & d_ba$treatment == trt & d_ba$subject == subj] +} +(value_of("aucinf.obs", "oral") / value_of("totdose", "oral")) / + (value_of("aucinf.obs", "IV") / value_of("totdose", "IV")) +``` + +The reference for an absolute bioavailability is not derived from the route of administration; it was given here with `group_ref`. An `f.obs_ref` pointer or `interval_add_secondary()` work equally well. For relative bioavailability between two extravascular formulations, the reference is simply the other formulation's treatment value. + +--- + +## Ratios + +The `ratio.*` parameters divide a parameter in the requesting interval by the same parameter in the reference interval, using `pk.calc.ratio()`: + +| Parameter | Ratio of | +|---|---| +| `ratio.cmax` | `cmax` | +| `ratio.auclast` | `auclast` | +| `ratio.aucinf.obs` | `aucinf.obs` | +| `ratio.aucinf.pred` | `aucinf.pred` | +| `ratio.aucint.last` | `aucint.last` | +| `ratio.aucint.all` | `aucint.all` | + +`pk.calc.ratio()` can be called directly. It returns `NA` when the reference is missing or not above zero, because a ratio to a zero or negative reference is not interpretable: + +```{r} +pk.calc.ratio(test = c(10, 10, 10), reference = c(20, 0, NA)) +``` + +### Accumulation ratio: first dose against steady state + +Because `reference` can name `start` and `end`, the same helper links intervals in time rather than across groups. Here two subjects received 100 mg every 24 hours for five days, with full profiles after the first dose (0 to 24 hours) and the fifth dose (96 to 120 hours): + +```{r} +times_profile <- c(0, 1, 2, 4, 8, 12, 24) +d_conc_md <- data.frame( + subject = rep(1:2, each = 14), + time = rep(c(times_profile, times_profile + 96), 2), + conc = c(0, 4.1, 5.0, 4.2, 2.6, 1.6, 0.4, + 0.9, 5.4, 6.2, 5.3, 3.4, 2.1, 0.9, + 0, 3.2, 4.4, 3.9, 2.5, 1.5, 0.4, + 0.8, 4.4, 5.6, 5.0, 3.3, 2.0, 0.8) +) +d_dose_md <- data.frame( + subject = rep(1:2, each = 5), + time = rep(seq(0, 96, by = 24), 2), + dose = 100 +) + +d_intervals_md <- + data.frame(start = c(0, 96), end = c(24, 120), cmax = TRUE, auclast = TRUE) |> + interval_add_secondary(param = "ratio.auclast", + reference = data.frame(start = 0, end = 24)) |> + interval_add_secondary(param = "ratio.cmax", + reference = data.frame(start = 0, end = 24)) +show_set_columns(d_intervals_md) + +o_data_md <- PKNCAdata( + PKNCAconc(d_conc_md, conc ~ time | subject), + PKNCAdose(d_dose_md, dose ~ time | subject), + intervals = d_intervals_md +) +o_nca_md <- pk.nca(o_data_md) + +d_md <- as.data.frame(o_nca_md) +d_md |> + select(subject, start, end, PPTESTCD, PPORRES) |> + as.data.frame() +``` + +`ratio.auclast` on the 96 to 120 hour interval is the accumulation ratio of AUC over the dosing interval ($R_{ac}$), here `r round(d_md$PPORRES[d_md$PPTESTCD == "auclast" & d_md$subject == 1 & d_md$start == 96] / d_md$PPORRES[d_md$PPTESTCD == "auclast" & d_md$subject == 1 & d_md$start == 0], 3)` for subject 1 ($65.33 / 47.75$). The second call reused the `interval_id` that the first call gave the 0 to 24 hour interval. The [Superposition chapter](superposition.qmd) predicts steady state from single-dose data instead; `ratio.*` compares observed profiles. + +### Metabolite-to-parent ratio + +Nothing in a dataset says which analyte is the parent, so a metabolite ratio needs `group_ref` (or a pointer): + +```{r} +d_conc_met <- data.frame( + subject = 1, + Analyte = rep(c("parent", "metabolite"), each = 7), + time = rep(times_profile, 2), + conc = c(0, 4.1, 5.0, 4.2, 2.6, 1.6, 0.4, + 0, 0.6, 1.1, 1.4, 1.2, 0.9, 0.3) +) +d_dose_met <- data.frame(subject = 1, time = 0, dose = 100) + +d_intervals_met <- data.frame( + Analyte = c("parent", "metabolite"), + start = 0, + end = Inf, + cmax = TRUE, + aucinf.obs = TRUE, + ratio.cmax = c(FALSE, TRUE), + ratio.aucinf.obs = c(FALSE, TRUE) +) + +o_data_met <- PKNCAdata( + PKNCAconc(d_conc_met, conc ~ time | Analyte + subject), + PKNCAdose(d_dose_met, dose ~ time | subject), + intervals = d_intervals_met, + group_ref = data.frame(Analyte = "parent") +) + +as.data.frame(pk.nca(o_data_met), filter_requested = TRUE) |> + select(Analyte, PPTESTCD, PPORRES, Analyte_ref) |> + as.data.frame() +``` + +The ratio is of the reported concentration units; a molar (molecular-weight-adjusted) metabolite ratio is not yet available. + +### Food effect: fed against fasted + +A food-effect comparison is a ratio with the fasted treatment as the reference: + +```{r} +d_conc_food <- data.frame( + subject = rep(1:2, each = 14), + treatment = rep(rep(c("fasted", "fed"), each = 7), 2), + time = rep(times_profile, 4), + conc = c(0, 4.1, 5.0, 4.2, 2.6, 1.6, 0.4, 0, 1.8, 3.3, 4.0, 2.9, 1.8, 0.5, + 0, 3.2, 4.4, 3.9, 2.5, 1.5, 0.4, 0, 1.5, 2.9, 3.6, 2.7, 1.7, 0.5) +) +d_dose_food <- data.frame( + subject = rep(1:2, each = 2), + treatment = c("fasted", "fed"), + time = 0, + dose = 100 +) + +d_intervals_food <- + data.frame(treatment = c("fasted", "fed"), start = 0, end = Inf, + cmax = TRUE, tmax = TRUE, aucinf.obs = TRUE) |> + interval_add_secondary(param = "ratio.cmax", + reference = data.frame(treatment = "fasted")) |> + interval_add_secondary(param = "ratio.aucinf.obs", + reference = data.frame(treatment = "fasted")) + +o_nca_food <- pk.nca(PKNCAdata( + PKNCAconc(d_conc_food, conc ~ time | treatment + subject), + PKNCAdose(d_dose_food, dose ~ time | treatment + subject), + intervals = d_intervals_food +)) + +as.data.frame(o_nca_food) |> + filter(grepl("^ratio", PPTESTCD)) |> + select(treatment, subject, PPTESTCD, PPORRES, treatment_ref) |> + as.data.frame() +``` + +Food delays and lowers the peak (`ratio.cmax` about 0.8) without changing total exposure (`ratio.aucinf.obs` about 0.99). These are the per-subject ratios; a formal food-effect or bioequivalence assessment (geometric mean ratio with a confidence interval) is described in the [Bioequivalence vignette](https://humanpred.github.io/pknca/articles/v50-bioequivalence.html). + +`reference` must name `start`, `end`, or grouping columns that are present in the intervals, which is why the intervals here carry a `treatment` column. + +--- + +## Exclusions cross the link + +An exclusion on a value that feeds a secondary parameter reaches the result. Below, the plasma profile has too few points after Tmax for a half-life, so `aucinf.obs` (which extrapolates with it) is excluded, and the `clr.obs` built on that AUC inherits the reason instead of becoming a number: + +```{r} +d_conc_x <- data.frame( + subject = 1, + PCSPEC = rep(c("plasma", "urine"), times = c(3, 2)), + time = c(0, 2, 4, 0, 6), + conc = c(10, 8, 7, 2, 1), + vol = c(NA, NA, NA, 0.10, 0.15) +) +d_intervals_x <- data.frame( + PCSPEC = c("plasma", "urine"), + start = 0, + end = 6, + interval_id = c("plasma_0_6", NA), + aucinf.obs = c(TRUE, FALSE), + ae = c(FALSE, TRUE), + clr.obs = c(FALSE, TRUE), + clr.obs_ref = c(NA, "plasma_0_6") +) + +as.data.frame(pk.nca(PKNCAdata( + PKNCAconc(d_conc_x, conc ~ time | PCSPEC + subject, volume = "vol"), + intervals = d_intervals_x +))) |> + filter(PPTESTCD %in% c("half.life", "aucinf.obs", "ae", "clr.obs")) |> + select(PCSPEC, PPTESTCD, PPORRES, exclude) |> + as.data.frame() +``` + +The warning comes from the half-life calculation on the plasma profile (two points after Tmax, fewer than `min.hl.points = 3`) and is the point of the example. The same reason appears in the `exclude` column of `half.life`, `aucinf.obs`, and the urine row's `clr.obs`. + +This is what post hoc division of a results table does not do: an excluded input cannot quietly become a reported clearance. Exclusions applied afterwards with `exclude()` or the `exclude_nca_*()` functions work on a finished result and do not propagate along a link. + +--- + +## Units of secondary parameters + +A secondary parameter takes one value from the requesting interval and another from the reference interval, so when units differ between groups, its units are composed from both. In this version of the renal example the urine is reported in mg/L and the plasma in ng/mL (unit columns, so the units can vary by group): + +```{r} +d_conc_u <- d_conc |> + mutate( + cu = ifelse(PCSPEC == "plasma", "ng/mL", "mg/L"), + conc = ifelse(PCSPEC == "plasma", conc * 1000, conc), + tu = "hr", + au = "mg" + ) +o_conc_u <- PKNCAconc(d_conc_u, conc ~ time | PCSPEC + subject, + volume = "vol", duration = "dur", + concu = "cu", timeu = "tu", amountu = "au") + +o_nca_u <- pk.nca(PKNCAdata(o_conc_u, intervals = d_intervals_linked |> select(-fe))) + +as.data.frame(o_nca_u) |> + filter(subject == 1) |> + select(PCSPEC, PCSPEC_ref, PPTESTCD, PPORRES, PPORRESU) |> + as.data.frame() +``` + +There is no unit argument for the collection volume: `amountu` is the unit of the amount excreted (`conc × vol`), so the volume must be recorded in units that make that product an amount in `amountu`. Here the volumes are in litres, and mg/L × L = mg. + +`clr.last` is reported in `mg/(hr*ng/mL)`: the `mg` from the urine collection that supplied the amount and the `hr*ng/mL` from the plasma profile that supplied the AUC. Its value, $26.65 / 47748 = 0.000558$, is the same number as before in different units; no value is converted. The units table that `PKNCAdata()` builds gains a `_ref` column and one row per pair of groups to make this possible: + +```{r} +o_data_u <- PKNCAdata(o_conc_u, intervals = d_intervals_linked |> select(-fe)) +o_data_u$units |> + filter(PPTESTCD %in% c("auclast", "ae", "clr.last")) +``` + +The rows with `NA` in `PCSPEC_ref` describe results that took nothing from another interval; the rest describe each pair of groups. The table is an ordinary `PKNCAdata(units = )` argument, so a row can be edited to report a standardized unit. One mg/(hr·ng/mL) is 1000 L/hr: + +```{r} +d_units_clr <- o_data_u$units +is_clr_mixed <- d_units_clr$PPTESTCD == "clr.last" & + d_units_clr$PPORRESU == "mg/(hr*ng/mL)" +d_units_clr$PPSTRESU[is_clr_mixed] <- "L/hr" +d_units_clr$conversion_factor[is_clr_mixed] <- 1000 + +pk.nca(PKNCAdata(o_conc_u, intervals = d_intervals_linked |> select(-fe), + units = d_units_clr)) |> + as.data.frame() |> + filter(subject == 1) |> + select(PCSPEC, PPTESTCD, PPORRES, PPORRESU, PPSTRES, PPSTRESU) |> + as.data.frame() +``` + +When the two sides of a ratio are convertible into each other (a `ratio.*` between analytes reported in ng/mL and mg/L), the quotient is also standardized to a `PPSTRESU` of `"fraction"` in `PPSTRES`. When they are not (a bioavailability between a dose in mg and a dose in mg/kg), the composite units stand. Nothing is set to `NA` for unit reasons. + +--- + +## Reporting secondary parameters + +- **`PPANMETH`** names the reference interval on every linked result, as "Reference interval:" followed by how it differs from the requesting interval and its times. +- **`_ref` columns** (`PCSPEC_ref`, `treatment_ref`, …) give the group the reference value came from. +- **`summary()`** summarizes a secondary parameter on the intervals that requested it; intervals that did not request it show `.`: + +```{r} +summary(o_nca_md) +``` + +- **`as.data.frame(filter_requested = TRUE)`** drops the source values calculated only to support a link. + +--- + +## Limitations + +These are documented as not yet supported in PKNCA 0.13.0: + +- Sparse concentration data (`PKNCAconc(sparse = TRUE)`) with any secondary parameter requested is an error. +- The reference for absolute bioavailability is not derived from the route of administration; give it with `group_ref`, an `f.obs_ref` pointer, or `interval_add_secondary()`. +- The CDISC `PPTESTCD` of a ratio does not depend on what the ratio compares (accumulation against metabolite ratio). +- `exclude()` and the `exclude_nca_*()` functions do not propagate across a link (see above). +- Molecular-weight adjustment of a metabolite ratio is not available. + +--- + +## Defining your own secondary parameter + +A custom parameter becomes secondary when its `formalsmap` in `add.interval.col()` wraps an argument in `pknca_ref()`, which says the argument comes from the reference interval rather than the current one (for example `formalsmap = list(auc = pknca_ref("auclast"))`). `add.interval.col()` also accepts `selection = list(secondary = TRUE)`, and the names `interval_id` and anything ending in `_ref` are reserved for the linkage columns. Arguments taken from the current interval must be listed in `depends`. See the [Custom Parameters chapter](custom-parameters.qmd) and the section [Parameters That Need Another Interval](https://humanpred.github.io/pknca/articles/v80-writing-parameter-functions.html#parameters-that-need-another-interval) of the parameter-writing vignette. + +--- + +::: {.callout-note icon=false appearance="minimal"} +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [interval_add_secondary()](https://humanpred.github.io/pknca/reference/interval_add_secondary.html) · [pknca_parameter_table()](https://humanpred.github.io/pknca/reference/pknca_parameter_table.html) · [pk.calc.f()](https://humanpred.github.io/pknca/reference/pk.calc.f.html) · [pk.calc.clr()](https://humanpred.github.io/pknca/reference/pk.calc.clr.html) · [pk.calc.ratio()](https://humanpred.github.io/pknca/reference/pk.calc.ratio.html) · [pknca_ref()](https://humanpred.github.io/pknca/reference/pknca_ref.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [as.data.frame()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) +::: diff --git a/user/urine-excretion.qmd b/user/urine-excretion.qmd index 8eb6a09..8ec163f 100644 --- a/user/urine-excretion.qmd +++ b/user/urine-excretion.qmd @@ -21,11 +21,13 @@ Urine NCA characterizes how much drug is excreted in urine over time. Key parame | `clr.pred` | ae / AUCinf.pred | Renal clearance — using predicted Clast | | `clr.last` | ae / AUClast | Renal clearance — no extrapolation | +Renal clearance needs values from two profiles (the urine amount and the plasma AUC), which makes `clr.*` a *secondary parameter*. Since PKNCA 0.13.0 it is calculated in the same `pk.nca()` call as the rest of the analysis (shown below); the [Secondary Parameters chapter](secondary-parameters.qmd) explains the linkage in general. + --- ## Data structure for urine -Urine NCA requires a **separate concentration object** for urine, with the `volume` argument to `PKNCAconc()` specifying the column name that holds the urine volume collected in each interval. +Urine NCA needs the urine volume collected in each interval, named by the `volume` argument to `PKNCAconc()`. The urine data can be a concentration object of their own, or share one with the plasma data, distinguished by a grouping column; sharing one is what lets renal clearance be calculated in the same run (see below). The key distinction from plasma NCA: urine concentrations represent **interval averages** rather than timepoint measurements. This dataset records each collection's **end** time as `time` — sufficient for amount parameters like `ae`, which only need each row to fall inside the analysis interval. PKNCA's full urine convention is `time` as the collection **start** plus a `duration` column giving the collection length; the excretion-rate section below rebuilds the data that way. @@ -90,51 +92,63 @@ o_conc_urine <- PKNCAconc( o_dose <- PKNCAdose(d_dose, dose ~ time | Subject, route = "intravascular") ``` ---- +Requesting a parameter that needs a sample volume (`ae`, `fe`, `volpk`, `clr.*`) from a `PKNCAconc()` object without `volume` is an error naming those parameters (`pknca_error_missing_conc_input`, ≥ 0.13.0); likewise the excretion-rate parameters `ermax`, `ertmax`, `ertlst`, and `erlst` need `duration`. Earlier versions added empty `volume` and `duration` columns to every concentration object and reported such parameters as not calculated. -## Separate plasma + urine workflow +--- -Renal clearance combines the urine amount excreted (Ae) with the plasma AUC. Combined plasma+urine analysis in a single `PKNCAdata` object is limited; the recommended workflow is to run plasma NCA and urine NCA separately and join the results. +## Renal clearance in one `pk.nca()` call (≥ 0.13.0) -This separate-workflow approach works reliably: +Put the plasma and urine concentrations in one data frame, distinguished by a grouping column (here `specimen`), and give the urine volume through `volume`. Plasma rows have no volume. The volume is converted to litres so that `conc × volume` (mcg/mL × L = mg) is in the same mass unit as the dose and the plasma AUC (mg·h/L), which makes `fe` a fraction and `clr.*` come out in L/h: ```{r} -# --- Plasma NCA --- -o_plasma_data <- PKNCAdata( - o_conc_plasma, o_dose, - intervals = data.frame(start = 0, end = Inf, - auclast = TRUE, aucinf.obs = TRUE), - impute = "start_conc0" +d_conc_all <- bind_rows( + d_plasma |> + transmute(Subject, specimen = "plasma", time, conc, volume_L = NA_real_), + urine_collections |> + transmute(Subject, specimen = "urine", time, conc, volume_L = volume / 1000) ) -o_nca_plasma <- pk.nca(o_plasma_data) -plasma_auc <- as.data.frame(o_nca_plasma) |> - filter(PPTESTCD %in% c("auclast", "aucinf.obs")) |> - select(Subject, PPTESTCD, PPORRES) |> - tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) +o_conc_all <- PKNCAconc(d_conc_all, conc ~ time | specimen + Subject, + volume = "volume_L") +``` -# --- Urine: compute ae manually --- -# ae = concentration (mcg/mL) × volume (mL) / 1000 → mg -ae_by_subject <- urine_collections |> - mutate(ae_interval = conc * volume / 1000) |> # mcg/mL * mL / 1000 = mg - group_by(Subject) |> - summarise(ae_total = sum(ae_interval), .groups = "drop") +The interval specification has one row per specimen. The plasma profile has no sample at the dose time, so the plasma row imputes a zero there (`impute` column, used through `PKNCAdata(impute = "impute")`); the urine row imputes nothing, because adding a concentration at the start of a urine collection is not meaningful. `interval_add_secondary()` then requests `clr.last` and `clr.obs` on the urine row and points them at the plasma row: -# --- Join and compute clr, fe --- -dose_amt <- 100 # mg +```{r} +d_intervals_renal <- + data.frame( + specimen = c("plasma", "urine"), + start = 0, + end = 24, + impute = c("start_conc0", NA), + auclast = c(TRUE, FALSE), + aucinf.obs = c(TRUE, FALSE), + ae = c(FALSE, TRUE), + fe = c(FALSE, TRUE) + ) |> + interval_add_secondary(param = "clr.last", + reference = data.frame(specimen = "plasma")) |> + interval_add_secondary(param = "clr.obs", + reference = data.frame(specimen = "plasma")) -results <- plasma_auc |> - left_join(ae_by_subject, by = "Subject") |> - mutate( - fe = ae_total / dose_amt, - clr_obs = ae_total / aucinf.obs, - clr_last = ae_total / auclast - ) +o_data_renal <- PKNCAdata(o_conc_all, o_dose, + intervals = d_intervals_renal, impute = "impute") +o_nca_renal <- pk.nca(o_data_renal) + +results <- as.data.frame(o_nca_renal) |> + filter(PPTESTCD %in% c("auclast", "aucinf.obs", "ae", "fe", "clr.obs", "clr.last")) |> + select(Subject, PPTESTCD, PPORRES) |> + tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |> + arrange(Subject) results ``` -`clr.pred` works identically using AUCinf.pred as the denominator. +The plasma AUCs and the urine amounts come from their own rows, and `clr.obs` and `clr.last` are reported on the urine row that requested them (`as.data.frame(o_nca_renal)` shows each result's `specimen`, and a `specimen_ref` column naming the plasma profile it used). Request `clr.pred` the same way to divide by AUCinf,pred. `fe` and `clr.*` are calculated even when `ae` is not requested. + +When the plasma profile needs no per-interval setting, even the plasma row and the linkage can be left out: requesting `clr.obs` on a urine interval alone makes `pk.nca()` find the plasma profile and create the interval it needs, reported with a message. That derivation, `group_ref` for choosing among several candidate profiles, and how units are composed for a renal clearance whose amount and AUC have different units are covered in the [Secondary Parameters chapter](secondary-parameters.qmd). + +A renal clearance with no AUC to divide by (no reference interval and none derivable) is `NA` with the reason in the `exclude` column. Before 0.13.0 it silently divided by zero and reported `Inf`. For a real IV analysis, back-extrapolate C0 instead — see the [Intravascular](intravascular.qmd) chapter; `conc = 0` at `t = 0` is used here only to keep the simulation short. @@ -145,7 +159,7 @@ For a real IV analysis, back-extrapolate C0 instead — see the [Intravascular]( ```{r} results |> mutate(across(where(is.numeric), \(x) round(x, 3))) |> - select(Subject, ae_total, fe, clr_obs, clr_last) |> + select(Subject, ae, fe, clr.obs, clr.last) |> knitr::kable( col.names = c("Subject", "Ae (mg)", "fe", "CLr.obs (L/h)", "CLr.last (L/h)"), caption = "Urine NCA results — simulated IV bolus (dose = 100 mg)" @@ -162,7 +176,7 @@ results |> ## Using the PKNCA `ae` parameter natively -If your data are structured with urine as a separate concentration object and `PKNCAconc(..., volume = "vol_col")`, PKNCA can compute `ae` directly: +`ae` can also be calculated from a urine-only concentration object, such as `o_conc_urine` created above with the volume in mL: ```{r} # o_conc_urine (created above) already carries the volume column @@ -181,11 +195,11 @@ as.data.frame(o_nca_ae) |> arrange(Subject) ``` -Note the units: `ae` here is in mcg (urine concentration in mcg/mL × volume in mL), so ~90,000 mcg matches the ~90 mg computed manually above. +Note the units: `ae` here is in mcg (urine concentration in mcg/mL × volume in mL), so ~90,000 mcg matches the ~90 mg of the combined analysis above, where the volume was in litres. Units can be declared with the unit arguments of `PKNCAconc()`; there is no separate volume unit, so `amountu` is the unit of `conc × volume`. --- -## New urine parameters (≥ 0.12.2) +## New urine parameters (≥ 0.13.0) ### volpk — total collection volume @@ -212,8 +226,10 @@ as.data.frame(o_nca_vol) |> | `ermax` | Maximum excretion rate (amount/time) within the interval | | `ertmax` | Midpoint collection time of the maximum excretion rate period | | `ertlst` | Midpoint collection time of the last measurable excretion rate | +| `erlst` | Last measurable (positive) excretion rate, from the collection at `ertlst` | +| `erint` | Excretion rate over the whole interval: `ae / (end - start)` | -These mirror the plasma-side `cmax` / `tmax` / `tlast` but apply to the excretion rate (amount excreted per collection period divided by collection duration). +`ermax`, `ertmax`, `ertlst`, and `erlst` mirror the plasma-side `cmax` / `tmax` / `tlast` / `clast.obs` but apply to the excretion rate of each collection (amount excreted in the collection divided by its duration). `erint` and `erlst` (≥ 0.13.0) report the CDISC parameters `ERINT` and `ERLST`. `erint` divides by the interval length rather than by the summed collection durations, so a gap between collections lowers it, and an interval ending at `Inf` gives `NA`. ```{r} # Excretion rate needs each collection's duration, and PKNCA takes urine `time` as @@ -234,18 +250,23 @@ er_interval <- data.frame( ae = TRUE, ermax = TRUE, # maximum excretion rate ertmax = TRUE, # midpoint time of the maximum-rate collection - ertlst = TRUE # midpoint time of the last measurable-rate collection + ertlst = TRUE, # midpoint time of the last measurable-rate collection + erlst = TRUE, # excretion rate of that last measurable collection + erint = TRUE # ae / (end - start) ) o_nca_er <- pk.nca(PKNCAdata(o_conc_er, o_dose, intervals = er_interval)) as.data.frame(o_nca_er) |> - filter(PPTESTCD %in% c("ae", "ermax", "ertmax", "ertlst")) |> - select(Subject, PPTESTCD, PPORRES) + filter(PPTESTCD %in% c("ae", "ermax", "ertmax", "ertlst", "erlst", "erint")) |> + select(Subject, PPTESTCD, PPORRES) |> + tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) ``` +For every subject `erint` is `ae / 24`; `ertlst` is 18 h, the midpoint of the 12–24 h collection, and `erlst` is that collection's amount divided by its 12 h duration. + ### Dose-normalized renal clearance -Dose-normalized renal clearance variants are now available in the parameter registry: +The dose-normalized renal clearances are in the parameter registry: | Parameter | Definition | |---|---| @@ -253,33 +274,23 @@ Dose-normalized renal clearance variants are now available in the parameter regi | `clr.obs.dn` | `clr.obs / dose` | | `clr.pred.dn` | `clr.pred / dose` | -Renal clearance divides the urine Ae by the plasma AUC — two different concentration objects — so a single `pk.nca()` call cannot compute `clr.*` directly. Compute each piece with `pk.nca()` and combine them, with `pk.calc.dn()` applying the dose normalization: +::: {.callout-warning} +In the PKNCA development version this book documents, requesting `clr.last.dn`, `clr.obs.dn`, or `clr.pred.dn` in an interval specification stops `pk.nca()` with the error "The secondary parameter 'clr.last.dn' has arguments not covered by formalsmap (dose)". Calculate `clr.*` in the run and divide by the dose with `pk.calc.dn()`, as below. +::: ```{r} -o_nca_ae_dn <- pk.nca(PKNCAdata(o_conc_urine, o_dose, - intervals = data.frame(start = 0, end = 24, ae = TRUE))) - -clr_dn <- plasma_auc |> # plasma pk.nca() results from the separate workflow above - left_join( - as.data.frame(o_nca_ae_dn) |> - filter(PPTESTCD == "ae") |> - select(Subject, ae = PPORRES), - by = "Subject" - ) |> +clr_dn <- results |> # one-run renal clearance results from above mutate( - ae = ae / 1000, # native ae is in mcg (mcg/mL * mL); convert to mg to match the plasma AUC - clr.last = ae / auclast, - clr.obs = ae / aucinf.obs, clr.last.dn = pk.calc.dn(clr.last, 100), # dose = 100 mg clr.obs.dn = pk.calc.dn(clr.obs, 100) - ) + ) |> + select(Subject, clr.last, clr.obs, clr.last.dn, clr.obs.dn) clr_dn ``` - --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pk.calc.dn()](https://humanpred.github.io/pknca/reference/pk.calc.dn.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [interval_add_secondary()](https://humanpred.github.io/pknca/reference/interval_add_secondary.html) · [pk.calc.ae()](https://humanpred.github.io/pknca/reference/pk.calc.ae.html) · [pk.calc.fe()](https://humanpred.github.io/pknca/reference/pk.calc.fe.html) · [pk.calc.clr()](https://humanpred.github.io/pknca/reference/pk.calc.clr.html) · [pk.calc.volpk()](https://humanpred.github.io/pknca/reference/pk.calc.volpk.html) · [pk.calc.ermax()](https://humanpred.github.io/pknca/reference/pk.calc.ermax.html) · [pk.calc.ertmax()](https://humanpred.github.io/pknca/reference/pk.calc.ertmax.html) · [pk.calc.ertlst()](https://humanpred.github.io/pknca/reference/pk.calc.ertlst.html) · [pk.calc.erlst()](https://humanpred.github.io/pknca/reference/pk.calc.erlst.html) · [pk.calc.erint()](https://humanpred.github.io/pknca/reference/pk.calc.erint.html) · [pk.calc.dn()](https://humanpred.github.io/pknca/reference/pk.calc.dn.html) ::: From f6ef23b31388d709ece0ea13b57e0f56b7306909 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 10 Oct 2026 02:16:39 +0000 Subject: [PATCH 04/12] docs: rewrite the sparse chapter and add the sparse AUC to infinity chapter Co-Authored-By: Claude Opus 5.5 --- user/sparse-uncertainty.qmd | 225 +++++++++++++++++ user/sparse.qmd | 477 +++++++++++++++++++++++------------- 2 files changed, 533 insertions(+), 169 deletions(-) create mode 100644 user/sparse-uncertainty.qmd diff --git a/user/sparse-uncertainty.qmd b/user/sparse-uncertainty.qmd new file mode 100644 index 0000000..17b4f1e --- /dev/null +++ b/user/sparse-uncertainty.qmd @@ -0,0 +1,225 @@ +--- +title: "Sparse AUC to Infinity and Its Uncertainty" +--- + +```{r setup, include=FALSE} +library(PKNCA) +library(dplyr) +library(tidyr) +conflicted::conflicts_prefer(dplyr::filter, dplyr::select, .quiet = TRUE) +``` + +## Overview + +With sparse sampling, each animal gives only one or a few samples, so the AUC is estimated from the mean concentration at each timepoint, and its standard error comes from the variability between animals at each timepoint (see [Sparse PK Sampling](sparse.qmd)). Bailer (1988) gave the estimator and its variance for the AUC from time 0 to the last sampling time with serial sacrifice. Nedelman, Gibiansky, and Lau (1995) added Satterthwaite degrees of freedom, Nedelman and Jia (1998) extended the method to designs where animals give more than one sample, and Holder (2001) gave an unbiased estimator of the covariance between timepoints. Yuan (1993) extended Bailer's method to the AUC from 0 to infinity by adding the extrapolated area C̄last/λz, treating the terminal rate constant λz as known. + +In practice, λz is estimated from the same samples, so its uncertainty adds to that of the AUC to infinity. This chapter shows how PKNCA (≥ 0.13.0) calculates the sparse `aucinf.obs` and `aumcinf.obs` with standard errors that include the uncertainty of λz, how it handles IV bolus dosing, and what a simulation study recommends. The derivations and the simulation study are in the package vignette [Sparse AUC to Infinity and the Uncertainty of lambda.z](https://humanpred.github.io/pknca/articles/v24-sparse-auc-to-infinity.html). + +--- + +## The estimators + +At each sampling time $t_i$ ($i = 0, \ldots, n$, with $t_n = t_{last}$, the last time with a positive mean), $r_i$ animals are sampled, $\bar{y}_i$ is their arithmetic mean concentration, and $r_{ij}$ animals are sampled at both $t_i$ and $t_j$. The linear trapezoidal AUC to tlast is a weighted sum of the means, so its variance follows from the covariance of the means: + +$$\widehat{AUC}_{0-t_{last}} = \sum_{i=0}^{n} w_i \bar{y}_i, +\qquad +\text{Var}\left(\widehat{AUC}_{0-t_{last}}\right) = \sum_{i}\sum_{j} w_i w_j \frac{r_{ij}\,\sigma_{ij}}{r_i r_j},$$ + +where $w_i$ are the trapezoidal weights and $\sigma_{ij}$ is the covariance of the concentrations at $t_i$ and $t_j$ within an animal (the sample variance for $i = j$ and the Holder covariance otherwise; with serial sacrifice, $r_{ij} = 0$ for $i \neq j$). + +The AUC to infinity adds the extrapolation from the mean at tlast: + +$$\widehat{AUC}_{0-\infty} = \sum_{i=0}^{n} w_i \bar{y}_i + \frac{\bar{y}_n}{\lambda_z}.$$ + +Its variance has the same form with weights $g_j$ in place of $w_j$: + +- **Yuan (1993)**, λz treated as known: $g_j = w_j + \delta_{j,n}/\lambda_z$. +- **Delta method** (PKNCA's default): λz is minus the least-squares slope of the log means at the half-life points $H$, $\hat\lambda_z = -\sum_{j \in H} c_j \log \bar{y}_j$, a smooth function of the means, so + $$g_j = w_j + \frac{\delta_{j,n}}{\lambda_z} + \frac{\bar{y}_n}{\lambda_z^2}\,\frac{c_j}{\bar{y}_j}\,\mathbb{1}_{j \in H}, + \qquad c_j = \frac{t_j - \bar{t}_H}{\sum_{k \in H}\left(t_k - \bar{t}_H\right)^2}.$$ + The last term is the uncertainty in λz. Because the gradient is taken over all of the means together, the variance includes the covariance of λz with C̄last and with the AUC to tlast: a high mean at an early half-life point raises the AUC to tlast but steepens the slope, which lowers the extrapolated area. Adding a separately estimated variance of λz to Yuan's variance would miss that. + +The AUMC to infinity is calculated the same way from the moment means $t_i \bar{y}_i$, with the extrapolation $t_{last} C_{last}/\lambda_z + C_{last}/\lambda_z^2$. The degrees of freedom are the Satterthwaite approximation of Nedelman and Jia (1998, equation 6) with the weights $g$, with or without the delta-method terms. The delta method conditions on the selected half-life points (the variability from choosing them is not included), and it is a first-order approximation, which is least accurate when λz is imprecise. + +### Relation to earlier work + +Wolfsegger and Jaki (2005) estimated the AUC from 0 to infinity in serial sacrifice designs with a delta-method variance, with λz estimated from separate terminal timepoints. They extended the estimation to serial sampling designs (Wolfsegger and Jaki 2009) and to flexible sampling designs (Jaki and Wolfsegger 2012), and implemented it in the R package PK (Jaki and Wolfsegger 2011), which estimates λz from the mean of the log concentrations at the last `n.tail` times. PKNCA applies the delta method to the λz fitted to the log of the arithmetic means at its automatically selected half-life points, which is the `lambda.z` (and `half.life`) it reports, so the extrapolation and its standard error use the same λz as the rest of the results; its confidence intervals use a *t* distribution with the Satterthwaite degrees of freedom. + +--- + +## Calculating it with PKNCA + +The example is a simulated oral serial-sacrifice study with slow elimination, so that about a fifth of the AUC is extrapolated. No animal is sampled at the dose, so a zero is imputed at time 0 (`impute = "start_conc0"`). + +```{r} +set.seed(42) + +# One-compartment oral model with slow elimination (ka and kel in 1/h, dose in mg, v in L) +pk_conc <- function(t, ka = 1.5, kel = 0.07, dose = 100, v = 50) { + (dose / v) * (ka / (ka - kel)) * (exp(-kel * t) - exp(-ka * t)) +} + +d_slow <- data.frame(time = rep(c(0.5, 1, 2, 4, 8, 12, 24), each = 4)) +d_slow$animal <- seq_len(nrow(d_slow)) +d_slow$conc <- signif(pk_conc(d_slow$time) * exp(rnorm(nrow(d_slow), sd = 0.3)), 3) + +o_conc <- PKNCAconc(d_slow, conc ~ time | animal, sparse = TRUE) +o_dose <- PKNCAdose(data.frame(dose = 100, time = 0), dose ~ time) +d_intervals <- data.frame(start = 0, end = Inf, aucinf.obs = TRUE, aumcinf.obs = TRUE, aucpext.obs = TRUE) + +o_nca_delta <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = d_intervals, impute = "start_conc0")) +summary(o_nca_delta) +``` + +As described in [Sparse PK Sampling](sparse.qmd#the-sparse-workflow), the message says that the sparse estimators use the linear trapezoidal rule whatever the `auc.method` option. The standard errors and degrees of freedom are reported as `aucinf.obs_se`, `aucinf.obs_df`, `aumcinf.obs_se`, and `aumcinf.obs_df`, and the summary shows each estimate with its standard error in brackets. `aucpext.obs` and the other parameters without a sparse estimator are calculated from the mean profile. + +### Delta method or Yuan + +The `sparse_lambda_z_se` option chooses the method: `"delta"` (the default) for the delta method or `"none"` for Yuan's method. Set it for all analyses with `PKNCA.options(sparse_lambda_z_se = "none")` or for one analysis with the `options` argument of `PKNCAdata()`: + +```{r} +o_nca_yuan <- pk.nca(PKNCAdata( + o_conc, o_dose, intervals = d_intervals, impute = "start_conc0", + options = list(sparse_lambda_z_se = "none") +)) + +d_compare <- bind_rows( + delta = as.data.frame(o_nca_delta), + yuan = as.data.frame(o_nca_yuan), + .id = "method" +) |> + filter(PPTESTCD %in% c("aucpext.obs", "aucinf.obs", "aucinf.obs_se", "aucinf.obs_df", + "aumcinf.obs", "aumcinf.obs_se", "aumcinf.obs_df")) |> + select(method, PPTESTCD, PPORRES) |> + pivot_wider(names_from = method, values_from = PPORRES) +d_compare +``` + +```{r include=FALSE} +compare_value <- function(parameter, method) { + signif(d_compare[[method]][d_compare$PPTESTCD == parameter], 3) +} +``` + +The point estimates are the same; only the standard errors and degrees of freedom differ. With `r round(compare_value("aucpext.obs", "delta"), 1)`% of the AUC extrapolated, the delta-method standard error of the AUC to infinity (`r compare_value("aucinf.obs_se", "delta")`) is larger than Yuan's (`r compare_value("aucinf.obs_se", "yuan")`), and for the AUMC, whose extrapolation depends on 1/λz², the difference is larger still (`r compare_value("aumcinf.obs_se", "delta")` against `r compare_value("aumcinf.obs_se", "yuan")`). When little of the AUC is extrapolated, the two are close. + +The method is recorded in the `PPANMETH` column: + +```{r} +bind_rows(as.data.frame(o_nca_delta), as.data.frame(o_nca_yuan)) |> + filter(PPTESTCD == "aucinf.obs_se") |> + pull(PPANMETH) +``` + +The delta method needs the log-linear half-life. With `hl_method = "tobit"` (see [Half-Life and Terminal Phase](halflife.qmd)), the delta-method standard error is not calculated: `aucinf.obs_se` and `aucinf.obs_df` are `NA`, with the `pknca_warning_sparse_lambda_z_se_hl_method` warning. Yuan's method (`sparse_lambda_z_se = "none"`) works with either half-life method. + +### A confidence interval + +```{r} +d_delta <- as.data.frame(o_nca_delta) +est <- d_delta$PPORRES[d_delta$PPTESTCD == "aucinf.obs"] +se <- d_delta$PPORRES[d_delta$PPTESTCD == "aucinf.obs_se"] +df <- d_delta$PPORRES[d_delta$PPTESTCD == "aucinf.obs_df"] +est + c(-1, 1) * qt(0.975, df) * se +``` + +--- + +## IV bolus: the back-extrapolated C0 {#iv-bolus-c0} + +After an IV bolus, the concentration at time 0 is not zero and cannot be measured, so `start_conc0` does not apply. Request the IV parameters instead: `aucivlast`, `aucivall`, `aucivinf.obs`, `aumcivlast`, `aumcivall`, and `aumcivinf.obs` have sparse estimators with `_se` and `_df` companions (≥ 0.13.0). Without a sample at time 0, C0 is back-extrapolated from the mean profile with the methods of `pk.calc.c0()`: log-linear back-extrapolation from the first two means when they decline, + +$$\hat{C}_0 = \bar{y}_1 \left(\frac{\bar{y}_1}{\bar{y}_2}\right)^{t_1/(t_2 - t_1)},$$ + +and otherwise $\bar{y}_1$. The AUC adds the trapezoid from time 0 to $t_1$, and the uncertainty of $\hat{C}_0$ enters the standard error through its gradient with respect to the first two means (the delta method). With the linear trapezoidal rule, the AUMC from 0 to $t_1$ is $t_1^2 \bar{y}_1/2$ whatever C0 is, so C0 does not affect the AUMC. As for `pk.calc.c0()`, a zero at time 0 (measured or imputed) is not used as C0. + +```{r} +set.seed(11) +iv_conc <- function(t, dose = 100, v = 10, kel = 0.2) dose / v * exp(-kel * t) + +d_iv <- data.frame(time = rep(c(0.25, 0.5, 1, 2, 4, 8, 12, 24), each = 3)) +d_iv$animal <- seq_len(nrow(d_iv)) +d_iv$conc <- signif(iv_conc(d_iv$time) * exp(rnorm(nrow(d_iv), sd = 0.2)), 3) + +o_conc_iv <- PKNCAconc(d_iv, conc ~ time | animal, sparse = TRUE) +o_dose_iv <- PKNCAdose(data.frame(dose = 100, time = 0), dose ~ time, route = "intravascular") +o_nca_iv <- pk.nca(PKNCAdata( + o_conc_iv, o_dose_iv, + intervals = data.frame(start = 0, end = Inf, c0 = TRUE, aucivlast = TRUE, + aucivinf.obs = TRUE, aumcivinf.obs = TRUE, cl.iv.obs = TRUE) +)) +summary(o_nca_iv) +``` + +The warnings are expected: `aucivlast`, `aucivinf.obs`, and `aumcivinf.obs` are registered as depending on `auclast`, `aucinf.obs`, and `aumcinf.obs`, which are calculated without C0 and so cannot start at time 0 when there is no concentration there. The IV parameters, and `cl.iv.obs` (the dose divided by `aucivinf.obs`), are calculated. `mrt.iv.obs` and `vss.iv.obs` are calculated from `aucinf.obs` and `aumcinf.obs`, so they are `NA` for these data. + +The other IV parameters (`aucivint.*`, `aucivinf.pred`, and their AUMC equivalents) are calculated from the mean profile, without a standard error. For IV bolus dosing in general, see [Intravascular Dosing](intravascular.qmd). + +--- + +## Practical recommendations {#practical-recommendations} + +The vignette's simulation study (one- and two-compartment models, oral and IV bolus dosing, serial-sacrifice and batch designs, with and without BLQ samples, and fast and slow elimination) supports these recommendations; see [Summary and recommendations](https://humanpred.github.io/pknca/articles/v24-sparse-auc-to-infinity.html#summary-and-recommendations) in the vignette for the numbers. + +- **Use the delta-method standard error for the AUC to infinity (the default).** When little of the AUC is extrapolated, Yuan's standard error and the delta method both cover close to the nominal 95%. When about a fifth is extrapolated, Yuan's intervals are too narrow, and the delta method is closer to nominal, though still a few points short. +- **Give the AUC a concentration at time 0.** Without one, the sparse AUC is `NA`. After an extravascular dose, impute zero with `impute = "start_conc0"`; after an IV bolus, request the IV parameters. +- **Treat the AUMC, and the MRT and Vss calculated from it, with caution.** The AUMC to infinity was biased low in most scenarios, and its confidence intervals under-covered in every scenario, even with the delta method, most when elimination was slow. +- **The half-life of the mean profile is not the typical animal's half-life.** It was too long when the terminal phase was well sampled and too short when the profile was cut short by BLQ samples or by a two-compartment distribution. Sample long enough to see at least three terminal half-lives. +- **Cmax of the mean profile reads high with few animals per timepoint**, because it is the maximum of noisy means near a broad peak. +- **The bootstrap gives intervals for any parameter, but they are too narrow with few animals per timepoint.** With three or four animals per timepoint, prefer the analytical standard errors for the AUCs, and use `sparse_bootstrap()` for parameters and comparisons that have none, such as Cmax and ratios between groups. With about 20 or more animals per timepoint, the bootstrap standard error was accurate and its intervals matched the delta method's. +- **With many animals, the bias of the sparse estimate dominates.** BLQ samples counted as zero in the late means, and the extrapolation of a mean of animals with different elimination rates, biased the AUC to infinity low whatever the number of animals and whichever half-life was used, so with many animals every confidence interval missed the truth more often than its nominal level. +- **Geometric-mean profiles were evaluated and not adopted.** They were similarly accurate and as precise or modestly more precise, but they estimate a different quantity (the typical animal rather than the population mean curve), need a rule for BLQ samples that drives their results, and fall outside the variance methods for sparse AUCs, which are for arithmetic means. +- **The Tobit half-life was evaluated with BLQ samples and not adopted for the sparse AUC.** The Tobit fit of the mean profile gave nearly the same AUC to infinity as the log-linear fit and has no delta-method standard error. A (mixed-effects) Tobit fit of the individual samples was slightly less biased and more precise, but adding its variance to Yuan's covered less well than the delta method with slow elimination, and neither Tobit fit removed the bias that BLQ samples cause. + +### Bootstrap and the delta method on the same data + +`sparse_bootstrap()` refits λz in every replicate, so it includes the uncertainty of λz too (see [Bootstrap for any parameter](sparse.qmd#bootstrap-for-any-parameter)). Its default is `n_boot = 200` replicates; this example uses 50 to render quickly, so its standard error is itself imprecise: + +```{r} +o_nca_boot <- pk.nca(PKNCAdata( + sparse_bootstrap(o_conc, n_boot = 50, seed = 20261012), o_dose, + intervals = data.frame(start = 0, end = Inf, aucinf.obs = TRUE), + impute = "start_conc0", options = list(progress = FALSE) +)) +d_boot <- as.data.frame(o_nca_boot) |> + filter(bootstrap != "original", PPTESTCD == "aucinf.obs") + +data.frame( + method = c("Yuan", "delta", "bootstrap (50 replicates)"), + aucinf.obs_se = c(compare_value("aucinf.obs_se", "yuan"), + compare_value("aucinf.obs_se", "delta"), + signif(sd(d_boot$PPORRES), 3)) +) +``` + +With 4 animals per timepoint, resampling understates the variance of each mean by the factor 3/4, which is part of why the bootstrap intervals were too narrow in the simulation. + +--- + +## References + +Bailer AJ. Testing for the equality of area under the curves when using destructive measurement techniques. *J Pharmacokinet Biopharm*. 1988;16(3):303-309. + +Holder DJ. Comments on Nedelman and Jia's extension of Satterthwaite's approximation applied to pharmacokinetics. *J Biopharm Stat*. 2001;11(1-2):75-79. + +Jaki T, Wolfsegger MJ. Estimation of pharmacokinetic parameters with the R package PK. *Pharm Stat*. 2011;10(3):284-288. + +Jaki T, Wolfsegger MJ. Non-compartmental estimation of pharmacokinetic parameters for flexible sampling designs. *Stat Med*. 2012;31(11-12):1059-1073. + +Nedelman JR, Gibiansky E, Lau DTW. Applying Bailer's method for AUC confidence intervals to sparse sampling. *Pharm Res*. 1995;12(1):124-128. + +Nedelman JR, Jia X. An extension of Satterthwaite's approximation applied to pharmacokinetics. *J Biopharm Stat*. 1998;8(2):317-328. + +Shen M, Machado SG. Bioequivalence evaluation of sparse sampling pharmacokinetics data using bootstrap resampling method. *J Biopharm Stat*. 2017;27(2):257-264. + +Wolfsegger MJ, Jaki T. Estimation of AUC from 0 to infinity in serial sacrifice designs. *J Pharmacokinet Pharmacodyn*. 2005;32(5-6):757-766. + +Wolfsegger MJ, Jaki T. Non-compartmental estimation of pharmacokinetic parameters in serial sampling designs. *J Pharmacokinet Pharmacodyn*. 2009;36(5):479-494. + +Yuan J. Estimation of variance for AUC in animal studies. *J Pharm Sci*. 1993;82(7):761-763. + +--- + +::: {.callout-note icon=false appearance="minimal"} +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [summary.PKNCAresults()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [pk.calc.aucinf.obs_sparse()](https://humanpred.github.io/pknca/reference/pk.calc.aucinf.obs_sparse.html) · [pk.calc.aumcinf.obs_sparse()](https://humanpred.github.io/pknca/reference/pk.calc.aucinf.obs_sparse.html) · [pk.calc.aucivinf.obs_sparse()](https://humanpred.github.io/pknca/reference/pk.calc.aucivlast_sparse.html) · [pk.calc.c0()](https://humanpred.github.io/pknca/reference/pk.calc.c0.html) · [sparse_bootstrap()](https://humanpred.github.io/pknca/reference/sparse_bootstrap.html) +::: diff --git a/user/sparse.qmd b/user/sparse.qmd index aaad0c1..84a1e58 100644 --- a/user/sparse.qmd +++ b/user/sparse.qmd @@ -11,282 +11,421 @@ conflicted::conflicts_prefer(dplyr::filter, dplyr::select, .quiet = TRUE) ## What is sparse PK? -In **dense PK**, each subject contributes a full concentration-time profile (multiple samples per subject). In **sparse PK**, each subject contributes only one or a few samples, and the population-level profile is reconstructed by pooling across subjects. +In **dense PK**, each subject contributes a full concentration-time profile. In **sparse PK**, each subject (usually an animal) contributes only one or a few samples, so there is no individual profile to analyze. Instead, the samples of all animals in a group are pooled into one profile, and the parameters describe that group. -Sparse sampling is common in: +Holder et al. (1999) describe three sampling designs: -- **Preclinical toxicokinetic studies** — serial sacrifice designs where each animal is sampled at only one timepoint -- **Pediatric or special population studies** — where blood volume limits the number of draws per patient -- **Large observational studies** — with opportunistic sampling +- **Serial sacrifice** (destructive sampling): each animal is sampled once, typically at necropsy, with a few animals at each timepoint. This is the usual toxicokinetic design in rodents. +- **Batch**: animals are divided into batches, and each animal in a batch is sampled at the same subset of the timepoints, so each animal gives two or three samples but no animal gives the whole profile. +- **Complete**: every animal is sampled at every timepoint. Such data can be analyzed as dense data, one animal at a time, or pooled as sparse data. -PKNCA handles sparse PK by computing a **mean concentration-time profile** across all subjects at each timepoint, then running NCA on that mean profile, along with a standard error estimate from the Nedelman & Jia (1998) method. See also the package vignette [Sparse NCA Calculations](https://humanpred.github.io/pknca/articles/v04-sparse.html). +PKNCA handles all three with the same code: give `sparse = TRUE` to `PKNCAconc()` and request the parameters by their usual names. Two things happen behind that flag: + +1. The AUC and AUMC parameters that have a sparse estimator (`auclast`, `aucall`, `aucinf.obs`, `aumclast`, `aumcall`, `aumcinf.obs`, and after an IV bolus the `auciv*` and `aumciv*` equivalents) are calculated from the pooled samples with the Bailer (1988) point estimate and the Nedelman–Jia/Holder standard error. Each reports a standard error and degrees of freedom alongside it, with the suffixes `_se` and `_df` (for example, `auclast_se` and `auclast_df`) (≥ 0.13.0). +2. Every other parameter (Cmax, Tmax, half-life, and so on) is calculated from the **arithmetic-mean profile** of the animals in the group. + +See also the package vignette [Sparse NCA Calculations](https://humanpred.github.io/pknca/articles/v04-sparse.html). The AUC to infinity, its standard error with the uncertainty of λz, and IV bolus dosing are covered in the next chapter, [Sparse AUC to Infinity and Its Uncertainty](sparse-uncertainty.qmd). --- ## Dataset: serial sacrifice design -We construct a synthetic dataset following the Holder et al. (1999) design: -7 nominal timepoints (including pre-dose t = 0), 3 subjects sampled at each timepoint (21 subjects total, 1 sample each). +The example is a simulated oral study: 7 timepoints with 4 animals sacrificed at each (28 animals, one sample each). As is usual for sacrificial designs, no animal is sampled at the time of the dose. ```{r} set.seed(42) -# True PK parameters (oral, 1-compartment) -ka <- 1.5 # h⁻¹ absorption rate -kel <- 0.25 # h⁻¹ elimination rate -dose <- 100 # mg -V <- 50 # L - -pk_conc <- function(t, ka, kel, dose, V) { - (dose / V) * (ka / (ka - kel)) * (exp(-kel * t) - exp(-ka * t)) +# One-compartment oral model (ka and kel in 1/h, dose in mg, v in L) +pk_conc <- function(t, ka = 1.5, kel = 0.15, dose = 100, v = 50) { + (dose / v) * (ka / (ka - kel)) * (exp(-kel * t) - exp(-ka * t)) } -timepoints <- c(0, 0.5, 1, 2, 4, 8, 24) # include t=0 (pre-dose, conc=0) -n_per_time <- 3 - -d_sparse <- lapply(timepoints, function(t) { - true_c <- pk_conc(t, ka, kel, dose, V) - data.frame( - time = t, - conc = if (t == 0) rep(0, n_per_time) - else pmax(0, rnorm(n_per_time, mean = true_c, sd = true_c * 0.25)), - Subject = paste0("S", sprintf("%02d", which(timepoints == t) * 10 + seq_len(n_per_time))) - ) -}) |> bind_rows() +d_serial <- data.frame(time = rep(c(0.5, 1, 2, 4, 8, 12, 24), each = 4)) +# Every animal needs its own identifier, even with one sample per animal +d_serial$animal <- seq_len(nrow(d_serial)) +d_serial$conc <- signif(pk_conc(d_serial$time) * exp(rnorm(nrow(d_serial), sd = 0.25)), 3) -head(d_sparse, 12) +head(d_serial, 8) ``` ```{r} -ggplot(d_sparse, aes(x = time, y = conc)) + - geom_point(aes(colour = Subject), size = 3, show.legend = FALSE) + +ggplot(d_serial, aes(x = time, y = conc)) + + geom_point(size = 2, colour = "grey50") + stat_summary(fun = mean, geom = "line", linewidth = 1, colour = "black") + stat_summary(fun = mean, geom = "point", size = 4, colour = "black", shape = 18) + labs( - title = "Sparse PK — individual samples (colored) and mean profile (black)", + title = "Serial sacrifice: individual samples (grey) and mean profile (black)", x = "Time (h)", y = "Concentration (mg/L)" ) + theme_minimal() ``` +Subject (animal) identifiers are required even when every animal gives one sample, because which animal contributed which samples changes the standard error. With one sample per animal, a sequential number is enough, as above. + --- ## The sparse workflow -The main differences from standard NCA are `sparse = TRUE` in `PKNCAconc()` and requesting the `sparse_`-prefixed interval parameters. - ```{r} -# Step 1: concentration object — flag as sparse -o_conc <- PKNCAconc(d_sparse, conc ~ time | Subject, sparse = TRUE) +# Concentration object: flag the data as sparse +o_conc <- PKNCAconc(d_serial, conc ~ time | animal, sparse = TRUE) -# Step 2: dose object — one row per subject (all received the same dose at t=0) -d_dose <- data.frame(Subject = unique(d_sparse$Subject), dose = dose, time = 0) -o_dose <- PKNCAdose(d_dose, dose ~ time | Subject, route = "extravascular") +# Dose object: one row with no groups applies to every animal +d_dose <- data.frame(dose = 100, time = 0) +o_dose <- PKNCAdose(d_dose, dose ~ time) -# Step 3: data object with sparse interval +# Data object: the usual parameter names, and a zero imputed at time 0 o_data <- PKNCAdata( o_conc, o_dose, intervals = data.frame( - start = 0, - end = Inf, - sparse_auclast = TRUE - ) - # no imputation needed — t=0 pre-dose samples are in the dataset + start = 0, end = 24, + auclast = TRUE, aumclast = TRUE, cmax = TRUE, tmax = TRUE, half.life = TRUE + ), + impute = "start_conc0" ) -# Step 4: run o_nca <- pk.nca(o_data) -as.data.frame(o_nca) +as.data.frame(o_nca) |> + select(PPTESTCD, PPORRES) ``` -PKNCA returns three values for sparse AUC: +There is one row per parameter for the group, not one per animal. `auclast_se`, `auclast_df`, `aumclast_se`, and `aumclast_df` are reported although they were not requested, because the sparse estimator returns all three together. They can also be requested on their own; requesting them for dense data is an error ("This parameter is only calculated for sparse PK"). -| Parameter | Meaning | -|---|---| -| `sparse_auclast` | Mean AUC from 0 to last measurable time (Nedelman & Jia method) | -| `sparse_auc_se` | Standard error of the AUC estimate | -| `sparse_auc_df` | Degrees of freedom for confidence interval construction | - ---- +`pk.nca()` prints a message that the `auc.method` option does not apply to the sparse estimators. The sparse variance is derived for a fixed-weight linear combination of the means, so the sparse estimators always use the linear trapezoidal rule, whatever `auc.method` says (its default is `"lin up/log down"`). Parameters calculated from the mean profile do follow `auc.method`. -## How PKNCA computes sparse AUC +### A concentration at time 0 -1. At each timepoint, compute the **mean concentration** across subjects (pooling all subjects sampled at that time) -2. Apply the **linear** trapezoidal rule to the mean profile to get AUClast (sparse AUC uses linear weights — see `sparse_auc_weight_linear` — as required by the Nedelman–Jia variance) -3. Estimate **variance** using the Nedelman & Jia (1998) formula, accounting for the covariance between AUC trapezoids via the Holder (2001) method (full citations are in the vignette) -4. Return AUClast ± SE and degrees of freedom +As for any AUC in PKNCA, the sparse AUC needs a concentration at the start of the interval, measured or imputed. Without one, the AUC and its standard error and degrees of freedom are `NA`: ```{r} -# Inspect the mean profile PKNCA uses internally -mean_profile <- d_sparse |> - group_by(time) |> - summarise(mean_conc = mean(conc), n = n(), se = sd(conc) / sqrt(n()), .groups = "drop") +o_nca_no_c0 <- pk.nca(PKNCAdata( + o_conc, o_dose, + intervals = data.frame(start = 0, end = 24, auclast = TRUE) +)) +as.data.frame(o_nca_no_c0) |> + select(PPTESTCD, PPORRES) +``` -mean_profile +The warning ("Requesting an AUC range starting (0) before the first measurement (0.5) is not allowed") explains the `NA`. After an extravascular dose, impute a zero with `impute = "start_conc0"`, as in the first example (≥ 0.13.0; see [Concentration Imputation](imputation.qmd)). The imputed zero is known rather than measured, so it has weight in the estimate but adds nothing to the variance or the degrees of freedom. A pre-dose sample measured as zero works the same way. Other imputations (such as `start_predose`) give estimates whose uncertainty the sparse estimators cannot include, so when they would add a concentration to the pooled samples, `pk.nca()` stops with the `pknca_error_sparse_impute` error. After an IV bolus, request the IV parameters instead; they back-extrapolate C0 from the mean profile (see [Sparse AUC to Infinity and Its Uncertainty](sparse-uncertainty.qmd#iv-bolus-c0)). + +`pknca_interval_table()` (≥ 0.13.0) has a `sparse_single_dose` preset that requests a single-dose parameter set with the `start_conc0` imputation: + +```{r} +d_preset <- pknca_interval_table(0, Inf, preset = "sparse_single_dose") +names(d_preset)[vapply(d_preset, isTRUE, logical(1))] +d_preset$impute ``` +See [Interval Selection and Parameter Catalog](intervals.qmd) for interval specifications in general. + --- -## BLQ handling in sparse PK +## The mean profile and BLQ samples + +The mean profile is the arithmetic mean of the samples at each timepoint, with one rule for samples below the limit of quantification (BLQ, coded as 0): if **strictly more than 50%** of the samples at a timepoint are BLQ, the mean at that timepoint is set to 0. At exactly 50% BLQ, the mean is calculated normally, with the BLQ values as 0. This method (`"arithmetic mean, <=50% BLQ"`) is not a user-facing option. -`sparse = TRUE` affects how below-the-limit-of-quantification (BLQ) values (coded as 0) are treated at each timepoint before averaging. The default BLQ treatment is controlled by `PKNCA.options("conc.blq")` and applies **before** the mean is computed. +To see the rule, censor the serial data at an LLOQ of 0.06 mg/L, which makes 3 of the 4 samples at 24 h BLQ. `sparse_mean()` calculates the mean profile from an `as_sparse_pk()` object: -By default the mean profile uses the arithmetic mean with up-to-50%-BLQ handling (`"arithmetic mean, <=50% BLQ"`; see `?sparse_mean`); the method is not a user-facing option. The two internal methods: +```{r} +d_blq <- d_serial |> + mutate(lloq = 0.06, conc = ifelse(conc < lloq, 0, conc)) -- **`"arithmetic mean"`** — simple mean of all values including BLQs as 0 -- **`"arithmetic mean, <=50% BLQ"`** — if **strictly more than 50%** of subjects at a timepoint are BLQ, the mean concentration at that timepoint is set to 0 (at exactly 50% BLQ the mean is not zeroed) +sp <- sparse_mean(as_sparse_pk(conc = d_blq$conc, time = d_blq$time, subject = d_blq$animal)) +data.frame( + time = vapply(sp, `[[`, numeric(1), "time"), + n_blq = vapply(sp, function(x) sum(x$conc == 0), numeric(1)), + mean = vapply(sp, `[[`, numeric(1), "mean") +) +``` -The second method is conservative: timepoints with majority-BLQ results don't pull the mean above zero. +The 24 h mean is zero although one animal had a measurable concentration, so tlast becomes 12 h. The sparse AUClast stops at tlast, and the times after tlast do not contribute to its standard error or degrees of freedom either (≥ 0.13.0). The `conc.blq` option applies as for dense data (see [Utility Functions](utilities.qmd)), and per-run `options` in `PKNCAdata()` reach the sparse estimators the same way as global `PKNCA.options()` settings. Missing (`NA`) concentrations are dropped. --- -## Serial sacrifice vs. repeated sampling +## Confidence intervals -How subjects are grouped for sparse calculations is described in [the vignette's grouping section](https://humanpred.github.io/pknca/articles/v04-sparse.html#how-subjects-are-grouped-for-sparse-calculations). +The standard error and degrees of freedom give a *t*-based confidence interval: ```{r} -# Serial sacrifice: each subject contributes EXACTLY 1 sample -d_serial <- d_sparse # our example above — each subject is at one timepoint only - -# Mixed design: some subjects contribute multiple samples -# (PKNCA handles this too — subjects can appear at multiple times) -d_mixed <- bind_rows( - d_sparse, - data.frame(time = 4, conc = 0.8, Subject = "S11"), # S11 (t=0 in d_sparse) now has 3 samples: 0, 4, 8 - data.frame(time = 8, conc = 0.3, Subject = "S11") -) +d_res <- as.data.frame(o_nca) +auc <- d_res$PPORRES[d_res$PPTESTCD == "auclast"] +se <- d_res$PPORRES[d_res$PPTESTCD == "auclast_se"] +df <- d_res$PPORRES[d_res$PPTESTCD == "auclast_df"] -d_dose_mixed <- data.frame(Subject = unique(d_mixed$Subject), dose = dose, time = 0) -o_dose_mixed <- PKNCAdose(d_dose_mixed, dose ~ time | Subject, route = "extravascular") -o_conc_mixed <- PKNCAconc(d_mixed, conc ~ time | Subject, sparse = TRUE) -o_data_mixed <- PKNCAdata( - o_conc_mixed, o_dose_mixed, - intervals = data.frame(start = 0, end = Inf, sparse_auclast = TRUE) -) -o_nca_mixed <- pk.nca(o_data_mixed) -as.data.frame(o_nca_mixed) +ci <- auc + c(-1, 1) * qt(0.975, df) * se +sprintf("AUClast = %.2f, 95%% CI: %.2f to %.2f (df = %.1f)", auc, ci[1], ci[2], df) ``` -> **Limitation:** `sparse_auc_df` is `NA` when any subject contributes more than one sample per interval. This is a known current limitation of the Nedelman & Jia variance estimation in PKNCA. +With serial sacrifice, the degrees of freedom are the Satterthwaite approximation of Nedelman, Gibiansky, and Lau (1995). They are not the number of animals minus one: here, with `r nrow(d_serial)` animals, `auclast_df` is `r round(df, 1)`. They are `NA` when a timepoint has a single animal, because its variance cannot be estimated. --- -## Constructing a confidence interval +## The summary shows the estimate and its standard error -With AUC, SE, and df you can construct a t-based confidence interval: +```{r} +summary(o_nca) +``` + +In `summary()`, a sparse AUC or AUMC is shown as the estimate with its standard error in brackets (≥ 0.13.0); the caption says "estimate and standard error", and `auclast_se` gets no column of its own. (In PKNCA 0.12.1, the single sparse estimate was described as a geometric mean with a geometric coefficient of variation.) Each standard error describes one estimate, so a summary row that would combine more than one sparse estimate (for example, after `drop_group` removes the group that separates them) is the `pknca_error_summary_multiple_spread` error. + +This pairing is set with the `spread_for` argument of `PKNCA.set.summary()` (≥ 0.13.0): the summary instructions registered for `auclast_se` have `spread_for = "auclast"`, so `auclast` is summarized with the `point` function applied to its values and the `spread` function applied to the `auclast_se` values. The same argument lets a custom parameter be summarized with the spread from another parameter. See [Post-Processing Results](postprocessing.qmd) for summary settings in general. + +--- + +## Derived parameters + +Because the sparse estimate is stored under the standard parameter name, every parameter derived from an AUC picks it up by name (≥ 0.13.0): `cl.last` divides the dose by the sparse `auclast`, `mrt.last` divides `aumclast` by `auclast`, `cl.obs` divides the dose by the sparse `aucinf.obs`, and so on down the usual derived graph. ```{r} -res <- as.data.frame(o_nca) |> - tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) - -auc <- res$sparse_auclast -se <- res$sparse_auc_se -df <- res$sparse_auc_df - -if (!is.na(df) && !is.na(se)) { - ci_lo <- auc - qt(0.975, df) * se - ci_hi <- auc + qt(0.975, df) * se - cat(sprintf("AUClast = %.2f 95%% CI: [%.2f, %.2f]\n", auc, ci_lo, ci_hi)) -} else { - cat(sprintf("AUClast = %.2f SE = %.2f (df unavailable for CI)\n", auc, se)) -} +o_nca_derived <- pk.nca(PKNCAdata( + o_conc, o_dose, + intervals = data.frame( + start = 0, end = Inf, + aumclast = TRUE, cl.last = TRUE, mrt.last = TRUE, kel.last = TRUE, + vss.last = TRUE, vz.last = TRUE, lambda.z = TRUE, + cl.obs = TRUE, mrt.obs = TRUE, vz.obs = TRUE + ), + impute = "start_conc0" +)) + +as.data.frame(o_nca_derived) |> + filter(PPTESTCD %in% c("auclast", "aumclast", "aucinf.obs", "aumcinf.obs", "lambda.z", + "cl.last", "mrt.last", "kel.last", "vss.last", "vz.last", + "cl.obs", "mrt.obs", "vz.obs")) |> + select(PPTESTCD, PPORRES) ``` +Points to note: + +- `kel.last`, like the rest of PKNCA's `kel` family, is `1/MRT`, not the terminal rate constant. Request `lambda.z` for that. +- `vz.last` is the clearance divided by `lambda.z` fitted to the mean profile, the standard toxicokinetic approach. It is `NA` when the mean profile has too few points after its peak to fit λz. +- The derived parameters have no `_se` or `_df` companions. For their uncertainty, use the [bootstrap](#bootstrap-for-any-parameter). +- With extravascular data such as these, the clearance and volume values are apparent (CL/F, V/F). + --- -## Key differences from dense NCA +## Half-life of the mean profile -| Aspect | Dense PK | Sparse PK | -|---|---|---| -| `PKNCAconc` flag | `sparse = FALSE` (default) | `sparse = TRUE` | -| Profile per subject | Full individual profile | 1–few samples only | -| NCA run on | Each subject's own profile | Pooled mean profile | -| Result | One row per subject per parameter | One row per parameter (population level) | -| Half-life | ✓ (per subject) | ✗ (not available) | -| AUClast | ✓ | ✓ (`sparse_auclast`) | -| SE of AUC | ✗ | ✓ (`sparse_auc_se`) | -| Interval col name | `auclast` | `sparse_auclast` | +The half-life is fitted to the mean profile, with the same automatic point selection as for dense data (see [Half-Life and Terminal Phase](halflife.qmd)). It describes the population mean curve, which is not the half-life of a typical animal: the late means are dominated by the animals that eliminate slowly, and BLQ samples counted as zero pull the late means down (see the [recommendations](sparse-uncertainty.qmd#practical-recommendations)). + +The Tobit half-life (`hl_method = "tobit"`) works for sparse data (≥ 0.13.0). Give the LLOQ with the `lloq` argument of `PKNCAconc()`; the mean profile carries the median LLOQ of the samples at each timepoint, so a timepoint whose mean is set to zero is treated as censored at the LLOQ rather than dropped: + +```{r} +o_conc_blq <- PKNCAconc(d_blq, conc ~ time | animal, sparse = TRUE, lloq = "lloq") +d_hl_interval <- data.frame(start = 0, end = Inf, half.life = TRUE) + +d_hl <- bind_rows( + `log-linear` = as.data.frame(pk.nca(PKNCAdata(o_conc_blq, o_dose, intervals = d_hl_interval))), + tobit = as.data.frame(pk.nca(PKNCAdata( + o_conc_blq, o_dose, intervals = d_hl_interval, + options = list(hl_method = "tobit") + ))), + .id = "hl_method" +) +d_hl |> + filter(PPTESTCD %in% c("half.life", "lambda.z.time.last", "lambda.z.n.points", "lambda.z.n.points_blq")) |> + select(hl_method, PPTESTCD, PPORRES) |> + tidyr::pivot_wider(names_from = hl_method, values_from = PPORRES) +``` + +The log-linear fit stops at the 12 h tlast; the Tobit fit also uses the censored 24 h mean. The delta-method standard error of the AUC to infinity needs the log-linear half-life: with `hl_method = "tobit"`, `aucinf.obs_se` and `aucinf.obs_df` are `NA` with the `pknca_warning_sparse_lambda_z_se_hl_method` warning, unless `sparse_lambda_z_se = "none"` (see the [next chapter](sparse-uncertainty.qmd)). --- -## Available sparse parameters +## Batch designs + +In a batch design, each animal gives several samples. The example has three batches of 4 animals, each batch sampled at two or three of the timepoints: ```{r} -cols <- get.interval.cols() -sparse_params <- Filter(function(x) isTRUE(x$sparse), cols) -data.frame( - parameter = names(sparse_params), - description = sapply(sparse_params, `[[`, "desc") -) |> knitr::kable() +set.seed(7) +d_batch_times <- data.frame(batch = c(1, 1, 1, 2, 2, 3, 3), time = c(0.5, 4, 24, 1, 8, 2, 12)) +d_batch <- merge(data.frame(batch = rep(1:3, each = 4), animal = 1:12), d_batch_times) +d_batch$conc <- signif(pk_conc(d_batch$time) * exp(rnorm(nrow(d_batch), sd = 0.25)), 3) + +# Number of animals sampled at each time, by batch +table(d_batch$batch, d_batch$time) +``` + +The code is the same as for serial sacrifice: + +```{r} +o_conc_batch <- PKNCAconc(d_batch, conc ~ time | animal, sparse = TRUE) +o_nca_batch <- pk.nca(PKNCAdata( + o_conc_batch, o_dose, + intervals = data.frame(start = 0, end = 24, auclast = TRUE, aumclast = TRUE), + impute = "start_conc0" +)) +as.data.frame(o_nca_batch) |> + select(PPTESTCD, PPORRES) ``` +Samples from the same animal are correlated. The standard error includes their covariance with the unbiased estimator of Holder (2001), and the degrees of freedom are the Satterthwaite approximation of Nedelman and Jia (1998, equation 6). Before PKNCA 0.13.0, the degrees of freedom were `NA` with a warning whenever an animal gave more than one sample; they are now calculated, and they match the PK package for batch designs and the published example of Nedelman and Jia (1998). + --- -## Sparse AUMC and derived parameters (≥ 0.12.2) +## How animals are grouped -PKNCA ≥ 0.12.2 adds sparse AUMC (area under the first-moment curve) and five new derived parameters, enabling MRT, clearance, and volume estimation from sparse data. +Sparse parameters are calculated from the pooled samples of every animal in the same group. The groups are the grouping variables on the right of `|` in the `PKNCAconc()` formula **with the subject removed**. With `conc ~ time | animal`, removing `animal` leaves no grouping variables, so all of the data form one group. With `conc ~ time | study + treatment + animal`, the animals are pooled within each combination of `study` and `treatment`. The subject does not have to be in the formula: `PKNCAconc(d, conc ~ time | treatment, subject = "animal", sparse = TRUE)` pools by `treatment`. (Before PKNCA 0.13.0, such data were rejected as duplicates when different animals shared a sampling time.) -### Sparse AUMC +All animals in a group must share the same dosing; if they do not, PKNCA stops with an error naming the group. See [How Subjects Are Grouped for Sparse Calculations](https://humanpred.github.io/pknca/articles/v04-sparse.html#how-subjects-are-grouped-for-sparse-calculations) in the vignette. -| Parameter | Description | -|---|---| -| `sparse_aumclast` | AUMC from the pooled mean sparse profile (0 → last measurable) | -| `sparse_aumc_se` | Standard error of the sparse AUMC estimate | -| `sparse_aumc_df` | Degrees of freedom for the AUMC variance estimate | +--- -Requesting `sparse_aumclast = TRUE` automatically computes `sparse_auclast`, `sparse_auc_se`, `sparse_auc_df`, `sparse_aumc_se`, and `sparse_aumc_df` as dependencies. +## Bootstrap for any parameter + +The standard errors above exist only for the AUC and AUMC families. `sparse_bootstrap()` (≥ 0.13.0) gives uncertainty for every parameter, including Cmax, the half-life, the derived parameters, and ratios between groups. It implements the stratified nonparametric bootstrap of Shen and Machado (2017): animals are resampled with replacement within each set of sampling times (each timepoint with serial sacrifice, each batch in a batch design), keeping each animal's samples together, and every replicate is calculated exactly as the original data are. It returns a sparse `PKNCAconc` object with the original data and the replicates as a new group column (`bootstrap` by default, with the values `"original"` and `"bootstrap1"`, `"bootstrap2"`, ...), and it stores the random seed so that the replicates can be reproduced. + +The default is `n_boot = 200` replicates, enough for the mean and standard deviation (Takemoto et al. 2006); percentile intervals need more to be stable. To render quickly, this example uses 50: ```{r} -sparse_aumc_interval <- data.frame( - start = 0, - end = Inf, - sparse_aumclast = TRUE # AUC and SE are computed automatically as dependencies +o_conc_boot <- sparse_bootstrap(o_conc, n_boot = 50, seed = 20261010) +o_data_boot <- PKNCAdata( + o_conc_boot, o_dose, + intervals = data.frame(start = 0, end = 24, auclast = TRUE, cmax = TRUE, tmax = TRUE, half.life = TRUE), + impute = "start_conc0", + # Many replicate groups would otherwise show a progress bar + options = list(progress = FALSE) ) +o_nca_boot <- pk.nca(o_data_boot) +summary(o_nca_boot) +``` -o_nca_sparse_aumc <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = sparse_aumc_interval)) +`pk.nca()` returns a `PKNCAresults_sparse_bootstrap` object. Its `summary()` treats the replicates the way the summary of dense data treats subjects, with each parameter's usual summary statistics (here, for example, the geometric mean and geometric CV of the replicates' AUClast). `N` is the number of animals in the study, and the caption gives the number of replicates and the seed. `as.data.frame()` has every replicate, including the `"original"` one with the estimates, so the bootstrap standard error is the standard deviation of the replicates: -as.data.frame(o_nca_sparse_aumc) |> - select(PPTESTCD, PPORRES) +```{r} +d_boot <- as.data.frame(o_nca_boot) +d_boot_original <- d_boot |> + filter(bootstrap == "original") |> + select(PPTESTCD, original = PPORRES) +d_boot |> + filter(bootstrap != "original", PPTESTCD %in% c("auclast", "cmax", "half.life")) |> + group_by(PPTESTCD) |> + summarise( + bootstrap_se = sd(PPORRES), + lower_95 = quantile(PPORRES, 0.025), + upper_95 = quantile(PPORRES, 0.975) + ) |> + left_join(d_boot_original, by = "PPTESTCD") ``` -### Sparse-derived PK parameters +The bootstrap standard error of `auclast` (`r signif(sd(d_boot$PPORRES[d_boot$bootstrap != "original" & d_boot$PPTESTCD == "auclast"]), 3)`) is smaller than the analytical `auclast_se` (`r signif(d_res$PPORRES[d_res$PPTESTCD == "auclast_se"], 3)`). Resampling *n* animals understates the variance of their mean by the factor (*n* − 1)/*n* (3/4 with 4 animals per timepoint), so with few animals per timepoint the bootstrap intervals are too narrow. Prefer the analytical standard errors for the AUCs, and use the bootstrap for parameters and comparisons that have none; see the [recommendations](sparse-uncertainty.qmd#practical-recommendations). -Once sparse AUC and AUMC are available, PKNCA can compute additional population-level parameters: +### Comparing groups with `be_assess()` -| Parameter | Description | -|---|---| -| `mrt.sparse.last` | Mean residence time from sparse sampling | -| `cl.sparse.last` | Clearance: dose / sparse AUClast | -| `kel.sparse.last` | Elimination rate constant: 1 / MRT_sparse | -| `vss.sparse.last` | Steady-state volume of distribution from sparse sampling | -| `vz.sparse.last` | Terminal volume of distribution from sparse sampling | +`be_assess()` accepts bootstrap results. The ratio of the test and reference estimates (from the `"original"` replicate) is the point estimate, and its confidence interval is the percentile interval of the replicate ratios (90% by default), judged against the acceptance limits; only the `"ABE"` and `"descriptive"` frameworks apply, because a sparse bootstrap has no within-subject variability. In this parallel design, the animals of each formulation are resampled independently: ```{r} -sparse_full_interval <- data.frame( - start = 0, - end = Inf, - sparse_aumclast = TRUE, # also pulls in sparse_auclast, SE, df - mrt.sparse.last = TRUE, - cl.sparse.last = TRUE, - kel.sparse.last = TRUE, - vss.sparse.last = TRUE, - vz.sparse.last = TRUE +set.seed(5) +d_two <- bind_rows( + mutate(d_serial, formulation = "reference"), + mutate(d_serial, formulation = "test", animal = animal + 100, + conc = signif(conc * exp(rnorm(n(), mean = log(0.9), sd = 0.1)), 3)) ) +o_conc_two <- PKNCAconc(d_two, conc ~ time | formulation + animal, sparse = TRUE, + concu = "mg/L", timeu = "h") +o_dose_two <- PKNCAdose(data.frame(formulation = c("reference", "test"), dose = 100, time = 0), + dose ~ time | formulation, doseu = "mg") +o_nca_two <- pk.nca(PKNCAdata( + sparse_bootstrap(o_conc_two, n_boot = 50, seed = 20261011), o_dose_two, + intervals = data.frame(start = 0, end = 24, auclast = TRUE, cmax = TRUE), + impute = "start_conc0", options = list(progress = FALSE) +)) +o_be <- be_assess(o_nca_two, reference_col = "formulation", reference_value = "reference", + endpoints = c("auclast", "cmax")) +o_be +``` -o_nca_sparse_full <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = sparse_full_interval)) +The test/reference ratio of AUClast is `r sprintf("%.1f", o_be$gmr_percent[o_be$endpoint == "auclast"])`% with a 90% interval of `r sprintf("%.1f", o_be$ci_lower[o_be$endpoint == "auclast"])`% to `r sprintf("%.1f", o_be$ci_upper[o_be$endpoint == "auclast"])`%, within the 80% to 125% limits; the Cmax interval (`r sprintf("%.1f", o_be$ci_lower[o_be$endpoint == "cmax"])`% to `r sprintf("%.1f", o_be$ci_upper[o_be$endpoint == "cmax"])`%) is not. `n` counts the animals of both formulations. -as.data.frame(o_nca_sparse_full) |> - filter(PPTESTCD %in% c("sparse_auclast", "sparse_aumclast", - "mrt.sparse.last", "cl.sparse.last", "kel.sparse.last", - "vss.sparse.last", "vz.sparse.last")) |> - select(PPTESTCD, PPORRES) +For a crossover, where each animal receives every treatment, give the treatment column as `paired` to `sparse_bootstrap()` (for example, `paired = "treatment"`), so that each replicate draws the same animals for every treatment. Analytes (the groups after `/` in the formula) are always resampled together. See the [sparse sampling section](bioequivalence.qmd#be-from-sparse-sampling-the-bootstrap-route) of the [Bioequivalence](bioequivalence.qmd) chapter for a paired example and the regulatory context, and the [Bootstrap](https://humanpred.github.io/pknca/articles/v04-sparse.html#bootstrap) section of the vignette. + +--- + +## Sparse parameters + +The parameters with a sparse estimator (registered with `FUN_sparse` in `add.interval.col()`), and the sparse-only parameters (which need `sparse = TRUE`), are listed by the parameter registry. The deprecated names (below) are left out: + +```{r} +cols <- get.interval.cols() +deprecated <- c("sparse_auclast", "sparse_auc_se", "sparse_auc_df", "sparse_aumclast", + "sparse_aumc_se", "sparse_aumc_df", "cl.sparse.last", "mrt.sparse.last", + "kel.sparse.last", "vss.sparse.last", "vz.sparse.last") +has_sparse_fun <- vapply(cols, function(x) !is.na(x$FUN_sparse), logical(1)) +d_sparse_estimators <- data.frame( + parameter = names(cols)[has_sparse_fun], + FUN_sparse = vapply(cols[has_sparse_fun], `[[`, character(1), "FUN_sparse"), + description = vapply(cols[has_sparse_fun], `[[`, character(1), "desc") +) +d_sparse_estimators |> + filter(!parameter %in% deprecated) |> + knitr::kable(row.names = FALSE) + +# Sparse-only parameters: the _se and _df companions +d_param <- pknca_parameter_table() +setdiff(d_param$parameter[d_param$sparse], deprecated) ``` -> `vss.sparse.last` (= CL × MRT) and `vz.sparse.last` (= CL / kel) are derived entirely from the sparse AUC and AUMC — no terminal-phase (λz) fit is needed. Because `kel.sparse.last` is 1/MRT rather than a λz estimate, `vz.sparse.last` is numerically equal to `vss.sparse.last`. With extravascular data like this chapter's, the clearance and volume values are apparent (CL/F, V/F) estimates. +Every other parameter is calculated from the arithmetic-mean profile. + +--- + +## Deprecated parameter names + +Sparse calculations used to be requested through a parallel set of names. All eleven still calculate and give the values they always gave, but each warns (once per session) and **will be an error in the next minor release of PKNCA** (≥ 0.13.0). Only the first three were in a release (0.12.1 and earlier). + +| Deprecated | Use instead | +|---|---| +| `sparse_auclast` | `auclast` | +| `sparse_auc_se` | `auclast_se` | +| `sparse_auc_df` | `auclast_df` | +| `sparse_aumclast` | `aumclast` | +| `sparse_aumc_se` | `aumclast_se` | +| `sparse_aumc_df` | `aumclast_df` | +| `cl.sparse.last` | `cl.last` | +| `mrt.sparse.last` | `mrt.last` | +| `kel.sparse.last` | `kel.last` | +| `vss.sparse.last` | `vss.last` | +| `vz.sparse.last` | `vz.last` | + +Every replacement except the last gives the same number as the name it replaces. `vz.sparse.last` divided the clearance by `1/MRT`, which made it numerically identical to Vss; `vz.last` divides by the λz of the mean profile, so re-run any comparison that used `vz.sparse.last`. + +The functions behind the sparse estimators (`pk.calc.sparse_auc()`, `pk.calc.sparse_auclast()`, `pk.calc.sparse_aumc()`, `pk.calc.sparse_aumclast()`, `as_sparse_pk()`, `sparse_mean()`, `var_sparse_auc()`, `var_sparse_aumc()`, and `cov_holder()`) are not deprecated and remain available for calculations outside `pk.nca()`. + +--- + +## Key differences from dense NCA + +| Aspect | Dense PK | Sparse PK | +|---|---|---| +| `PKNCAconc()` flag | `sparse = FALSE` (default) | `sparse = TRUE` | +| Samples per subject | Full individual profile | One or a few | +| Results | One row per subject per parameter | One row per group per parameter | +| AUC and AUMC (`auclast`, `aucinf.obs`, ...) | Trapezoidal rule per subject, `auc.method` | Sparse estimator, linear trapezoidal only | +| Standard error and df | Not available | `_se` and `_df` companions | +| Other parameters (Cmax, half-life, ...) | Per subject | From the arithmetic-mean profile | +| Concentration at time 0 | Measured or any imputation | Measured, or a zero (`start_conc0`); C0 back-extrapolated for IV parameters | +| Uncertainty of derived parameters | Between-subject summary statistics | `sparse_bootstrap()` | + +--- + +## References + +Bailer AJ. Testing for the equality of area under the curves when using destructive measurement techniques. *J Pharmacokinet Biopharm*. 1988;16(3):303-309. + +Holder DJ, Hsuan F, Dixit R, Soper K. A method for estimating and testing area under the curve in serial sacrifice, batch, and complete data designs. *J Biopharm Stat*. 1999;9(3):451-464. + +Holder DJ. Comments on Nedelman and Jia's extension of Satterthwaite's approximation applied to pharmacokinetics. *J Biopharm Stat*. 2001;11(1-2):75-79. + +Nedelman JR, Gibiansky E, Lau DTW. Applying Bailer's method for AUC confidence intervals to sparse sampling. *Pharm Res*. 1995;12(1):124-128. + +Nedelman JR, Jia X. An extension of Satterthwaite's approximation applied to pharmacokinetics. *J Biopharm Stat*. 1998;8(2):317-328. + +Shen M, Machado SG. Bioequivalence evaluation of sparse sampling pharmacokinetics data using bootstrap resampling method. *J Biopharm Stat*. 2017;27(2):257-264. +Takemoto S, Yamaoka K, Nishikawa M, Takakura Y. Histogram analysis of pharmacokinetic parameters by bootstrap resampling from one-point sampling data in animal experiments. *Drug Metab Pharmacokinet*. 2006;21(6):458-464. --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [sparse_mean()](https://humanpred.github.io/pknca/reference/sparse_mean.html) · [sparse_auc_weight_linear()](https://humanpred.github.io/pknca/reference/sparse_auc_weight_linear.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [summary.PKNCAresults()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [pknca_interval_table()](https://humanpred.github.io/pknca/reference/pknca_interval_table.html) · [as_sparse_pk()](https://humanpred.github.io/pknca/reference/as_sparse_pk.html) · [sparse_mean()](https://humanpred.github.io/pknca/reference/sparse_mean.html) · [pk.calc.auclast_sparse()](https://humanpred.github.io/pknca/reference/pk.calc.auclast_sparse.html) · [pk.calc.sparse_auc()](https://humanpred.github.io/pknca/reference/pk.calc.sparse_auc.html) · [pk.calc.sparse_aumc()](https://humanpred.github.io/pknca/reference/pk.calc.sparse_aumc.html) · [var_sparse_auc()](https://humanpred.github.io/pknca/reference/var_sparse_auc.html) · [var_sparse_aumc()](https://humanpred.github.io/pknca/reference/var_sparse_aumc.html) · [cov_holder()](https://humanpred.github.io/pknca/reference/cov_holder.html) · [sparse_bootstrap()](https://humanpred.github.io/pknca/reference/sparse_bootstrap.html) · [summary.PKNCAresults_sparse_bootstrap()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults_sparse_bootstrap.html) · [be_assess()](https://humanpred.github.io/pknca/reference/be_assess.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [pknca_parameter_table()](https://humanpred.github.io/pknca/reference/pknca_parameter_table.html) ::: From 830d14c9950dbae7eec0ab3500724736baa07dee Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 10 Oct 2026 02:16:39 +0000 Subject: [PATCH 05/12] docs: add the bioequivalence chapter Co-Authored-By: Claude Opus 5.5 --- user/bioequivalence.qmd | 474 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 user/bioequivalence.qmd diff --git a/user/bioequivalence.qmd b/user/bioequivalence.qmd new file mode 100644 index 0000000..176f24a --- /dev/null +++ b/user/bioequivalence.qmd @@ -0,0 +1,474 @@ +--- +title: "Bioequivalence" +--- + +```{r setup, include=FALSE} +library(PKNCA) +``` + +Once NCA parameters such as C~max~ and AUC are calculated, a bioequivalence (BE) study compares a test formulation with a reference formulation using inferential statistics on those parameters. PKNCA (≥ 0.13.0) carries the analysis from `pk.nca()` results through to the regulatory decision: `be_assess()` takes a `PKNCAresults` object (or a tidy long data frame), a regulatory framework, and the reference formulation, and returns the geometric mean ratio (GMR), its confidence interval, the within-subject variability, the acceptance limits or criterion, and the pass/fail decision for each endpoint. `be_compare()` judges the same study under several frameworks side by side. + +This chapter goes from simulated concentrations to a BE table, then covers the regulatory frameworks, replicate and parallel designs, unequal variances, covariates, descriptive (food-effect and drug-drug interaction) comparisons, and BE from sparse sampling. See also the package vignette [Bioequivalence Calculations](https://humanpred.github.io/pknca/articles/v50-bioequivalence.html). + +--- + +## Required packages + +The average-BE mixed model is fit with [`lme4`](https://cran.r-project.org/package=lme4) and [`lmerTest`](https://cran.r-project.org/package=lmerTest) (for Satterthwaite degrees of freedom), and the least-squares means come from [`emmeans`](https://cran.r-project.org/package=emmeans). These are *Suggested* packages of PKNCA, so install them before using the BE functions: + +```{r install, eval=FALSE} +install.packages(c("lme4", "lmerTest", "emmeans")) +``` + +PKNCA itself still requires only R 4.1, but `lme4` depends on `Matrix`, and the current `Matrix` needs R 4.4, so in practice the BE functions need R ≥ 4.4. The `nlme` models used for unequal variances need no extra installation (`nlme` is a PKNCA dependency). All regulatory constants and criteria are built into PKNCA; neither `PowerTOST` nor `replicateBE` is required. + +--- + +## The calculation path + +`be_assess()` and `be_compare()` are thin verbs over one calculation path, coordinated by `be_fit_models()`. Every stage is exported, so each can be called on its own: + +| Stage | Function | What it does | +|---|---|---| +| 1 | `be_dataset()` | Validates the data, takes the value from `PPSTRES` (or `PPORRES`) and its units, drops excluded and non-positive values, detects the subject, sequence, and period columns, and makes the reference the first treatment level | +| 2 | `be_design()` | Classifies the design (`"parallel"`, `"2x2x2"`, `"full_replicate"`, `"partial_replicate"`, or `"other"`) and reports which frameworks it supports | +| 3 | `be_fit_model_single()` | Fits the model for one endpoint (the only place models are fit) | +| 4 | `be_extract_param()` | Extracts the geometric means, the GMR and its confidence interval, and the within-formulation standard deviations | +| 5 | `be_table()` | Applies the regulatory limits or criterion and decides pass/fail | + +The supporting functions `be_regulator()` (the constants of each framework), `be_expand_limits()` (reference-scaled acceptance limits), and `be_within_var()` (within-subject standard deviations) are also exported. + +--- + +## From concentrations to a BE table: a 2x2 crossover + +The classic BE design is a two-sequence, two-period crossover: each subject receives both the test (T) and reference (R) formulation in a randomized order (sequence RT or TR). We simulate 24 subjects from a one-compartment model with first-order absorption. Between-subject variability is on clearance, there is about 10% within-subject variability, and the test formulation has 5% lower bioavailability than the reference. + +```{r sim-2x2} +set.seed(20261010) +d_subjects <- data.frame( + subject = 1:24, + sequence = rep(c("RT", "TR"), 12), + cl_subject = 5 * exp(rnorm(24, sd = 0.25)) +) +d_periods <- merge(d_subjects, data.frame(period = 1:2)) +d_periods$formulation <- substr(d_periods$sequence, d_periods$period, d_periods$period) +d_periods$f_rel <- ifelse(d_periods$formulation == "T", 0.95, 1) +d_periods$cl <- d_periods$cl_subject * exp(rnorm(nrow(d_periods), sd = 0.1)) + +timepoints <- c(0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24) +d_conc <- merge(d_periods, data.frame(time = timepoints)) +d_conc <- d_conc[order(d_conc$subject, d_conc$period, d_conc$time), ] +dose <- 100 # mg +v <- 50 # L +ka <- 1.1 # 1/hr +ke <- d_conc$cl / v +d_conc$conc <- + d_conc$f_rel * dose * ka / (v * (ka - ke)) * (exp(-ke * d_conc$time) - exp(-ka * d_conc$time)) * + exp(rnorm(nrow(d_conc), sd = 0.05)) +d_conc <- d_conc[, c("subject", "sequence", "period", "formulation", "time", "conc")] +rownames(d_conc) <- NULL + +d_dose <- unique(d_conc[, c("subject", "sequence", "period", "formulation")]) +d_dose$dose <- dose +d_dose$time <- 0 + +head(d_conc) +``` + +The crossover structure goes into the grouping formula: sequence, period, and formulation are grouping variables, and the subject is the last one. Giving units means the BE table reports them. + +For the intervals, the `"bioequivalence"` preset of `pknca_interval_table()` (≥ 0.13.0) requests the usual BE parameters for a single extravascular dose: C~max~, T~max~, AUC~last~, AUC~all~, AUC~∞,obs~, and supporting parameters such as C~last~ and the half-life. + +```{r nca-2x2} +pknca_presets()$bioequivalence + +o_conc <- PKNCAconc( + d_conc, conc ~ time | sequence + period + formulation + subject, + concu = "mg/L", timeu = "hr" +) +o_dose <- PKNCAdose(d_dose, dose ~ time | sequence + period + formulation + subject, doseu = "mg") +d_intervals <- pknca_interval_table(start = 0, end = Inf, preset = "bioequivalence") +o_data <- PKNCAdata(o_conc, o_dose, intervals = d_intervals) +o_nca <- pk.nca(o_data) +``` + +### Classifying the design + +`be_dataset()` prepares the results for modeling: it standardizes the modeling columns (`.subject`, `.sequence`, `.period`, `.trt`, `.logval`) and makes the reference the first treatment level. The subject column comes from the `PKNCAconc` object, and `period` and `sequence` are found by name (pass `subject`, `sequence`, and `period` when your columns use other names). `be_design()` then classifies the design from one row per subject and period, so give it the rows of a single endpoint: + +```{r be-design-2x2} +o_be_dataset <- be_dataset( + o_nca, reference_col = "formulation", reference_value = "R", endpoints = "cmax" +) +o_be_dataset +d_be_cmax <- o_be_dataset$data[o_be_dataset$data$PPTESTCD == "cmax", ] + +o_be_design <- be_design( + d_be_cmax, + subject = o_be_dataset$columns$subject, + sequence = o_be_dataset$columns$sequence, + period = o_be_dataset$columns$period, + treatment = o_be_dataset$columns$treatment, + reference_value = "R" +) +o_be_design +``` + +`be_dataset()` keeps every parameter row of the results (here all the parameters of the preset); the endpoint filter is applied when each endpoint is fit. + +::: {.callout-warning} +In the PKNCA development version documented here, `be_assess()` and `be_compare()` classify the design from all the rows of `be_dataset()`, which include every calculated parameter (or every `PPTESTCD` of a long data frame). A 2x2 crossover with several calculated parameters is then reported as `"full_replicate"`, and a parallel study with several parameters stops with an `lme4` error. Passing the design from `be_design()` on a single endpoint, as `design = o_be_design` below, gives the correct classification. +::: + +### The BE assessment + +`be_assess()` takes the `PKNCAresults` object, the column that identifies the formulation (`reference_col`), the value of that column that is the reference (`reference_value`), the endpoints, and the framework. A 2x2 crossover does not replicate the reference, so average bioequivalence (`regulator = "ABE"`, the default) is the framework it supports. + +```{r be-2x2} +o_be <- be_assess( + o_nca, + reference_col = "formulation", + reference_value = "R", + endpoints = c("cmax", "auclast", "aucinf.obs"), + regulator = "ABE", + design = o_be_design +) +o_be +``` + +The model was selected automatically: a crossover uses the mixed model (`model_type = "lmer"`) + +$$\log(\text{value}) = \text{sequence} + \text{period} + \text{formulation} + (1 \mid \text{subject})$$ + +fit with `lmerTest::lmer()`, and the confidence interval is the exponentiated difference in least-squares means between test and reference. The GMRs are between about 90% and 95% (the simulated bioavailability ratio is 95%), and every 90% confidence interval lies within 80.00-125.00%, so all three endpoints pass. + +`summary()` gives the short decision table, and the `caption` attribute is a one-sentence methods statement suitable for a report: + +```{r be-2x2-summary} +summary(o_be) +attr(o_be, "caption") +``` + +The columns of `as.data.frame(o_be)` are: + +| Column | Meaning | +|---|---| +| `endpoint`, `test`, `n`, `design` | Endpoint, test formulation, number of subjects, and design | +| `units` | Units of the endpoint, from the `PKNCAresults` object (omitted, with a warning, when there are no units) | +| `gm_reference`, `gm_test` (and `_lower`, `_upper`) | Geometric means with their confidence intervals, on the measurement scale | +| `gmr_percent`, `ci_lower`, `ci_upper` | GMR (test/reference) and its confidence interval, in percent | +| `cvwr_percent`, `cvwt_percent`, `swr` | Within-reference and within-test CV, and the within-reference SD (used for reference scaling; `NA` for unscaled ABE) | +| `limit_lower`, `limit_upper`, `criterion` | Acceptance limits (limit-based frameworks) or the linearized bound (FDA RSABE) | +| `regulator`, `model_type`, `pass` | Framework, model, and decision | + +The `gm_*` intervals are marginal least-squares-mean intervals, so they include between-subject variability and are wider than the within-subject interval of the ratio. The confidence level is `1 - alpha`; the default `alpha = 0.10` gives the 90% interval that BE regulations use. + +::: {.callout-tip} +Always give `endpoints`. The default, `c("cmax", "aucinf.obs", "aucinf.pred", "auclast")`, includes `aucinf.pred`, which is not calculated unless requested, and missing endpoints are skipped with a `pknca_warning_be_missing_endpoints` warning. Time and rate parameters (T~max~, half-life, λ~z~) are not log-normal exposure metrics: requesting them gives `pknca_warning_be_nonlognormal_endpoint`, because they need non-parametric or Fieller-type methods instead. +::: + +### Running the remaining stages one at a time + +The fitting, extraction, and decision stages can also be called directly, which is useful for inspecting the fitted model. Continuing with the C~max~ rows from `be_dataset()`: + +```{r be-stages-fit} +o_fit <- be_fit_model_single(d_be_cmax, model_type = "lmer", scaling = FALSE) +d_params <- be_extract_param(o_fit, d_be_cmax) +d_params$endpoint <- "cmax" +be_table(d_params, regulator = "ABE", design = o_be_design$design, model_type = "lmer")[ + , c("endpoint", "test", "gmr_percent", "ci_lower", "ci_upper", "pass") +] +``` + +The C~max~ row matches the first row of `o_be` above. `o_fit$model` is the fitted `lmerTest` model, so the usual model diagnostics are available. + +--- + +## Regulatory frameworks + +`be_regulator()` returns the constants of each framework, and it is the single source of these values for every decision: + +| `regulator` | Framework | Design required | Auto `model_type` | +|---|---|---|---| +| `"ABE"` | Average BE: 90% CI within 80.00-125.00% | test and reference | `"lmer"` (crossover, replicate) or `"anova"` (parallel) | +| `"EMA"` | Average BE with expanding limits (ABEL), k = 0.76, cap at CV~wR~ 50% | replicated reference | `"lmer"` | +| `"HC"` | Health Canada ABEL, k = 0.76, cap at CV~wR~ 57.382% (upper limit 150%) | replicated reference | `"lmer"` | +| `"GCC"` | Gulf Cooperation Council: fixed 75.00-133.33% when CV~wR~ > 30% | replicated reference | `"lmer"` | +| `"FDA"` | FDA reference-scaled average BE (RSABE), linearized criterion, scaling when s~wR~ ≥ 0.294 | replicated reference | `"isc"` | +| `"NTID"` | FDA narrow therapeutic index: scaled bound, conventional 90% CI, and upper 90% bound of s~wT~/s~wR~ ≤ 2.5 | full replicate | `"isc"` | +| `"HVNTID"` | FDA highly variable NTID: conventional 90% CI and the s~wT~/s~wR~ bound | full replicate | `"isc"` | +| `"descriptive"` | No decision: estimates as for `"ABE"`, no limits or pass/fail | test and reference | as for `"ABE"` | + +All decision frameworks also require the point estimate to fall within 80.00-125.00%. The FDA frameworks use intra-subject contrasts (`"isc"`) for the GMR, as the guidances specify, regardless of the design. + +```{r regulator} +be_regulator("EMA") +be_regulator("FDA") +``` + +`be_expand_limits()` gives the acceptance limits of the ABEL frameworks for a within-reference standard deviation s~wR~ (on the log scale). Below CV~wR~ = 30% they are the conventional 80.00-125.00%; above it EMA and Health Canada widen with s~wR~ up to their caps, while the GCC switches to a fixed band: + +```{r expand-limits} +cvwr <- c(0.25, 0.35, 0.45, 0.50, 0.60) +swr <- sqrt(log(cvwr^2 + 1)) +d_limits <- data.frame(cvwr_percent = 100 * cvwr, swr = round(swr, 4)) +for (regulator in c("EMA", "HC", "GCC")) { + limits <- vapply(swr, be_expand_limits, numeric(2), regulator = regulator) + d_limits[[regulator]] <- sprintf("%.2f-%.2f", limits["lower", ], limits["upper", ]) +} +d_limits +``` + +Both EMA and Health Canada use k = 0.76, so their limits agree up to CV~wR~ = 50%. Above that the EMA limits stay at their cap of 69.84-143.19%, while the Health Canada limits keep widening to their cap at CV~wR~ = 57.382%; at 60% both are capped (66.67-150.00% for Health Canada). The GCC band does not depend on CV~wR~ once it exceeds 30%. + +--- + +## Replicate designs and reference scaling + +Highly variable drugs (within-subject CV above 30%) rarely meet the fixed 80.00-125.00% limits, so the scaled frameworks widen the limits (or scale the criterion) by the within-reference variability. Estimating that variability needs the reference given more than once: a replicate design. + +`be_assess()` also accepts a tidy long data frame in the layout of `as.data.frame()` on PKNCA results: a `PPTESTCD` column of parameter names, a `PPORRES` (or `PPSTRES`) column of values, an optional `PPORRESU` (or `PPSTRESU`) column of units, and the subject, sequence, period, and treatment columns. Here we simulate the AUC~last~ of a four-period full replicate design (sequences TRTR and RTRT) with a within-reference CV of 45% and a within-test CV of 40%. + +```{r sim-replicate} +set.seed(20251015) +d_rep_subjects <- data.frame( + subject = 1:24, + sequence = rep(c("TRTR", "RTRT"), 12), + eta = rnorm(24, sd = 0.4) +) +d_rep <- merge(d_rep_subjects, data.frame(period = 1:4)) +d_rep <- d_rep[order(d_rep$subject, d_rep$period), ] +d_rep$treatment <- substr(d_rep$sequence, d_rep$period, d_rep$period) +sw <- ifelse(d_rep$treatment == "R", sqrt(log(0.45^2 + 1)), sqrt(log(0.40^2 + 1))) +d_rep$PPTESTCD <- "auclast" +d_rep$PPORRES <- + exp(log(100) + ifelse(d_rep$treatment == "T", log(1.05), 0) + d_rep$eta + rnorm(nrow(d_rep), sd = sw)) +d_rep$PPORRESU <- "hr*ng/mL" +d_rep$eta <- NULL + +be_design(d_rep, "subject", "sequence", "period", "treatment", reference_value = "R") +``` + +`be_within_var()` estimates the within-subject standard deviations for one endpoint. Its default `model_type = "anova"` is the regulatory ANOVA estimator on each formulation's replicates (it works for full and partial replicates); `model_type = "nlme"` fits one mixed model with treatment-specific residual variances and needs a full replicate. + +```{r within-var} +be_within_var( + d_rep, value = "PPORRES", subject = "subject", period = "period", + treatment = "treatment", reference_value = "R" +) +``` + +Under the EMA framework the limits widen with the estimated s~wR~. The estimated CV~wR~ (52.29%) is above the EMA cap of 50%, so the limits are the capped 69.84-143.19%: + +```{r be-ema} +o_be_ema <- be_assess( + d_rep, reference_col = "treatment", reference_value = "R", + endpoints = "auclast", regulator = "EMA" +) +o_be_ema +``` + +The FDA RSABE framework reports the linearized (Howe/Hyslop) upper bound in `criterion` instead of limits; it passes when the bound is at most zero (and the point estimate is within 80.00-125.00%): + +```{r be-fda} +o_be_fda <- be_assess( + d_rep, reference_col = "treatment", reference_value = "R", + endpoints = "auclast", regulator = "FDA" +) +as.data.frame(o_be_fda)[, c("gmr_percent", "ci_lower", "ci_upper", "cvwr_percent", "swr", "criterion", "pass")] +``` + +### Comparing frameworks + +`be_compare()` runs the same study under several frameworks at once (the default is `c("ABE", "EMA", "HC", "GCC", "FDA")`), and `summary()` gives the endpoint-by-regulator pass/fail grid: + +```{r be-compare} +o_be_compare <- be_compare( + d_rep, reference_col = "treatment", reference_value = "R", + endpoints = "auclast", + regulators = c("ABE", "EMA", "HC", "GCC", "FDA", "NTID", "HVNTID") +) +o_be_compare +summary(o_be_compare) +``` + +Every framework passes for these data, but the limits differ: the Health Canada limits (68.82-145.30%) are wider than the capped EMA limits, and the GCC uses its fixed 75.00-133.33%. The FDA family reports the intra-subject-contrast interval (82.46-106.24%) rather than the mixed-model interval (81.78-107.12%). With a GMR or variability nearer the limits, the decision can differ between frameworks for the same data. A framework that the design cannot support is skipped with a `pknca_warning_be_skipped_framework` warning (or, with `be_assess()`, stops with `pknca_error_be_infeasible_design`). For example, the 2x2 crossover from the start of the chapter cannot support EMA scaling: + +```{r be-compare-skip} +be_compare( + o_nca, reference_col = "formulation", reference_value = "R", + endpoints = "cmax", regulators = c("ABE", "EMA"), design = o_be_design +) +``` + +PKNCA assesses a completed study; power and sample-size planning for these designs is covered by the [`PowerTOST`](https://cran.r-project.org/package=PowerTOST) package. + +--- + +## Parallel designs + +In a parallel design each subject receives one formulation, so there is no within-subject comparison. `be_assess()` detects one observation per subject and fits a fixed-effects ANOVA (`model_type = "anova"`) without a subject term. A `period` column is still required (here every subject is in period 1), and `sequence` may be absent. + +```{r sim-parallel} +set.seed(20261015) +d_parallel <- data.frame( + subject = 1:40, + period = 1, + treatment = rep(c("R", "T"), each = 20), + PPTESTCD = "cmax", + PPORRESU = "ng/mL" +) +d_parallel$PPORRES <- + exp(log(50) + ifelse(d_parallel$treatment == "T", log(1.02), 0) + + rnorm(40, sd = ifelse(d_parallel$treatment == "T", 0.35, 0.2))) + +o_be_parallel <- be_assess( + d_parallel, reference_col = "treatment", reference_value = "R", endpoints = "cmax" +) +summary(o_be_parallel) +``` + +The confidence interval is wide because between-subject variability is part of every comparison in a parallel design. + +--- + +## Unequal variances, covariates, and descriptive comparisons + +Three options of `be_assess()`, `be_compare()`, and `be_fit_models()` (all ≥ 0.13.0) extend the same path beyond the standard BE decision. + +**`heteroscedastic = TRUE`** estimates a separate residual variance for each treatment. Crossover and replicate studies use `nlme::lme()` with `varIdent(~ 1 | treatment)` (`model_type = "nlme"`), and parallel studies use `nlme::gls()` with the same variance structure and Satterthwaite degrees of freedom (`model_type = "gls"`). The simulated parallel study above has a more variable test arm (SD 0.35 on the log scale versus 0.2): + +```{r heteroscedastic-parallel} +o_be_parallel_het <- be_assess( + d_parallel, reference_col = "treatment", reference_value = "R", endpoints = "cmax", + heteroscedastic = TRUE +) +summary(o_be_parallel_het) +``` + +The point estimate is unchanged. With equal arm sizes the standard error of the ratio is also the same as in the pooled-variance ANOVA; only the Satterthwaite degrees of freedom are lower, so the interval is slightly wider. With unequal arm sizes the standard error changes as well. + +`lme4::lmer()` fits a single residual variance, so `heteroscedastic = TRUE` with `model_type = "lmer"` is an error (`pknca_error_be_heteroscedastic_lmer`), as are `"nlme"` for a parallel design and `"gls"` for repeated measures. Without reference scaling, the `nlme` model accepts non-replicated crossovers and several test formulations; with reference scaling it needs a fully replicated design. + +**`covariates`** names columns added to every model as additive fixed effects (numeric columns as linear terms, character or factor columns as factors). The least-squares means are then at the mean of numeric covariates. Covariates must not be missing in any analyzed row, and missing, constant, or aliased covariates are errors. For a `PKNCAresults` object they must be columns of `as.data.frame()` of the results, which means grouping columns. The intra-subject contrasts of the FDA frameworks and the within-subject variances for reference scaling do not use covariates (the FDA frameworks warn with `pknca_warning_be_covariates_isc`). + +**`regulator = "descriptive"`** estimates the geometric means, their ratio, and its confidence interval exactly as `"ABE"` does, but applies no acceptance limits and reports no `pass` column. It is meant for comparisons that are estimated rather than judged, such as food-effect and drug-drug interaction studies. + +As an example, a fed/fasted food-effect crossover where the fed state is more variable is summarized descriptively, with treatment-specific variances and body weight as a covariate: + +```{r food-effect} +set.seed(20260927) +d_food_subjects <- data.frame( + subject = 1:20, + sequence = rep(c("FastedFed", "FedFasted"), 10), + weight = round(rnorm(20, mean = 75, sd = 12)), + eta = rnorm(20, sd = 0.3) +) +d_food <- merge(d_food_subjects, data.frame(period = 1:2)) +d_food$state <- ifelse( + (d_food$sequence == "FastedFed") == (d_food$period == 1), "Fasted", "Fed" +) +d_food$PPTESTCD <- "cmax" +d_food$PPORRESU <- "ng/mL" +d_food$PPORRES <- + exp(log(50) + ifelse(d_food$state == "Fed", log(1.4), 0) + 0.01 * (d_food$weight - 75) + + d_food$eta + rnorm(nrow(d_food), sd = ifelse(d_food$state == "Fed", 0.35, 0.15))) + +o_food <- be_assess( + d_food, reference_col = "state", reference_value = "Fasted", endpoints = "cmax", + regulator = "descriptive", heteroscedastic = TRUE, covariates = "weight" +) +o_food +``` + +The table has no `limit_lower`, `limit_upper`, `criterion`, or `pass` columns, and its caption records the model and covariate and states that no regulatory decision was applied. + +--- + +## BE from sparse sampling: the bootstrap route + +With sparse sampling (for example, serial sacrifice in toxicokinetic studies) there is one AUC estimate per treatment, not one per subject, so the mixed models above do not apply. `sparse_bootstrap()` (≥ 0.13.0) resamples the animals with the stratified nonparametric bootstrap of Shen and Machado (2017), and `be_assess()` on the bootstrap results gives the ratio of the test and reference estimates with the percentile interval of the replicate ratios (`model_type = "bootstrap"`). See [Sparse PK Sampling](sparse.qmd) for the sparse methods and `sparse_bootstrap()` itself. + +In this crossover each animal receives both treatments and is sampled at the same time after each, so an animal's two concentrations are correlated (they share an animal effect). Giving the treatment column as `paired` keeps each animal's samples from both treatments together in every replicate: + +```{r sparse-bootstrap} +set.seed(20261012) +d_sparse <- data.frame(time = rep(c(0, 0.5, 1, 2, 4, 8, 24), each = 4)) +d_sparse$animal <- seq_len(nrow(d_sparse)) +eta_animal <- rnorm(nrow(d_sparse), sd = 0.2) +conc_mean <- 10 * (exp(-0.15 * d_sparse$time) - exp(-1.5 * d_sparse$time)) +d_sparse_xover <- rbind( + data.frame(d_sparse, treatment = "R", + conc = conc_mean * exp(eta_animal + rnorm(nrow(d_sparse), sd = 0.05))), + data.frame(d_sparse, treatment = "T", + conc = 1.05 * conc_mean * exp(eta_animal + rnorm(nrow(d_sparse), sd = 0.05))) +) + +o_conc_sparse <- PKNCAconc( + d_sparse_xover, conc ~ time | treatment + animal, sparse = TRUE, + concu = "ng/mL", timeu = "hr" +) +o_conc_boot <- sparse_bootstrap(o_conc_sparse, n_boot = 300, seed = 20261012, paired = "treatment") +o_nca_boot <- pk.nca(PKNCAdata( + o_conc_boot, + intervals = data.frame(start = 0, end = 24, auclast = TRUE, cmax = TRUE), + # The sparse AUC uses the linear trapezoidal rule + options = list(progress = FALSE, auc.method = "linear") +)) +o_be_boot <- be_assess( + o_nca_boot, reference_col = "treatment", reference_value = "R", + endpoints = c("auclast", "cmax") +) +o_be_boot +``` + +The `gm_*` columns are the estimates of each treatment from the original data with their percentile intervals, `n` is the number of animals, and the caption records the number of replicates. The ratios (104.20% for AUC~last~ and 101.68% for C~max~; the simulated ratio is 105%) have narrow percentile intervals because pairing cancels the animal effect. + +Without `paired`, the treatments are resampled independently, as for a parallel design. The animal effect then no longer cancels, and the intervals are much wider: + +```{r sparse-bootstrap-unpaired} +o_nca_boot_unpaired <- pk.nca(PKNCAdata( + sparse_bootstrap(o_conc_sparse, n_boot = 300, seed = 20261012), + intervals = data.frame(start = 0, end = 24, auclast = TRUE, cmax = TRUE), + options = list(progress = FALSE, auc.method = "linear") +)) +summary(be_assess( + o_nca_boot_unpaired, reference_col = "treatment", reference_value = "R", + endpoints = c("auclast", "cmax") +)) +``` + +`alpha` sets the confidence level of the percentile intervals as usual; for example, `alpha = 0.05` gives the 2.5th and 97.5th percentiles (a 95% interval): + +```{r sparse-bootstrap-95} +summary(be_assess( + o_nca_boot, reference_col = "treatment", reference_value = "R", + endpoints = c("auclast", "cmax"), alpha = 0.05 +)) +``` + +The AUC~last~ interval widens, but the C~max~ interval is the same as the 90% interval. C~max~ of a sparse profile is the maximum of the mean concentrations, so in this small study its replicate ratios take only a few distinct values, and the 2.5th and 5th (and the 95th and 97.5th) percentiles fall on the same value. + +The bootstrap route has its own rules: + +* There is no within-subject variance, so only `"ABE"` and `"descriptive"` apply; a scaled framework stops with `pknca_error_be_bootstrap_scaled`, and `design` and `heteroscedastic` cannot be given. +* The results may have no groups other than `reference_col` (and the replicate column); filter other groups first. +* The default of 200 replicates is enough for means and standard deviations; percentile intervals need more replicates to be stable, so use more than the 300 shown here (which keep this page quick to render) for a reported analysis. + +--- + +## Reporting checklist + +1. **Framework and model**: the `regulator` and `model_type` columns, and the `caption` attribute as the methods statement. +2. **GMR, confidence interval, within-subject CV, limits or criterion, decision**: one row per endpoint and test formulation in `as.data.frame(be_assess(...))`. +3. **Cross-regulator comparison**: `summary(be_compare(...))`. +4. **Individual data**: `as.data.frame()` of the `PKNCAresults` object for listings and figures. + +The bootstrap method is from Shen M, Machado SG (2017). Bioequivalence evaluation of sparse sampling pharmacokinetics data using bootstrap resampling method. *Journal of Biopharmaceutical Statistics*, 27(2):257-264. + +--- + +::: {.callout-note icon=false appearance="minimal"} +**pkgdown reference:** [be_assess()](https://humanpred.github.io/pknca/reference/be_assess.html) · [be_compare()](https://humanpred.github.io/pknca/reference/be_compare.html) · [be_fit_models()](https://humanpred.github.io/pknca/reference/be_fit_models.html) · [be_dataset()](https://humanpred.github.io/pknca/reference/be_dataset.html) · [be_design()](https://humanpred.github.io/pknca/reference/be_design.html) · [be_fit_model_single()](https://humanpred.github.io/pknca/reference/be_fit_model_single.html) · [be_extract_param()](https://humanpred.github.io/pknca/reference/be_extract_param.html) · [be_table()](https://humanpred.github.io/pknca/reference/be_table.html) · [be_regulator()](https://humanpred.github.io/pknca/reference/be_regulator.html) · [be_expand_limits()](https://humanpred.github.io/pknca/reference/be_expand_limits.html) · [be_within_var()](https://humanpred.github.io/pknca/reference/be_within_var.html) · [pknca_presets()](https://humanpred.github.io/pknca/reference/pknca_presets.html) · [pknca_interval_table()](https://humanpred.github.io/pknca/reference/pknca_interval_table.html) · [sparse_bootstrap()](https://humanpred.github.io/pknca/reference/sparse_bootstrap.html) · [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) +::: From 76a8d71d42e5a87f17e92ca6b5d3d8c521dd294d Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 10 Oct 2026 02:16:39 +0000 Subject: [PATCH 06/12] docs: add data quality and date-time chapters; update regulatory, post-processing, and units Co-Authored-By: Claude Opus 5.5 --- user/data-quality.qmd | 292 ++++++++++++++++++++++++++++++++++++++++ user/datetime.qmd | 235 ++++++++++++++++++++++++++++++++ user/postprocessing.qmd | 110 +++++++++++++-- user/regulatory.qmd | 133 +++++++++++++++--- user/units.qmd | 80 +++++++++-- 5 files changed, 813 insertions(+), 37 deletions(-) create mode 100644 user/data-quality.qmd create mode 100644 user/datetime.qmd diff --git a/user/data-quality.qmd b/user/data-quality.qmd new file mode 100644 index 0000000..f699eab --- /dev/null +++ b/user/data-quality.qmd @@ -0,0 +1,292 @@ +--- +title: "Data Quality Checks" +--- + +```{r setup, include=FALSE} +library(PKNCA) +library(dplyr) +conflicted::conflicts_prefer(dplyr::filter, dplyr::select, .quiet = TRUE) +``` + +PKNCA checks data quality at three points in an analysis: + +1. **When the objects are built.** `PKNCAconc()`, `PKNCAdose()`, and `PKNCAdata()` stop with a classed error when the input cannot give correct results: duplicated rows, an invalid limit of quantification, or a malformed half-life point-selection column. Applications can run the same checks before building any object with `assert_conc_time()` and `assert_impute_method()`. +2. **Before calculation.** `pknca_missing_samples()` lists the samples that each subject is missing from the nominal sampling schedule of its group. +3. **After calculation.** The `exclude_nca_*()` rules flag results that should not be reported; `exclude_nca_tmax_coverage()` flags subjects whose samples miss the usual Tmax of their group, and `pknca_exclude_rules()` lists every rule. + +This chapter covers the checks themselves. Applying and summarizing exclusions is covered in the [Post-Processing chapter](postprocessing.qmd) and the package's [Post-Processing vignette](https://humanpred.github.io/pknca/articles/v07-post-processing.html). + +--- + +## Example data + +The examples use `datasets::Theoph` with a nominal time column added. The Theoph `Subject` column is an ordered factor whose levels are not in numeric order, so it is converted to integer to keep the listings sorted by subject number. + +```{r} +nominal_times <- c(0, 0.25, 0.5, 1, 2, 3.5, 5, 7, 9, 12, 24) +d_conc <- as.data.frame(Theoph) |> + mutate(Subject = as.integer(as.character(Subject))) |> + group_by(Subject) |> + mutate(time_nominal = nominal_times[row_number()]) |> + ungroup() |> + as.data.frame() +d_dose <- d_conc |> + group_by(Subject) |> + summarise(dose = Dose[1] * Wt[1], time = 0, .groups = "drop") + +head(d_conc[, c("Subject", "Time", "time_nominal", "conc")], 11) +``` + +--- + +## Checks when building PKNCA objects + +### Duplicate rows + +Two concentration rows for the same group at the same time cannot both be used. `PKNCAconc()` stops with the classed error `pknca_error_duplicate_rows`, and (≥ 0.13.0) the error carries the duplicated row numbers in its `rows` field, so a data-cleaning script can locate them without parsing the message: + +```{r} +d_dup <- rbind(d_conc, d_conc[3, ]) +err <- tryCatch( + PKNCAconc(d_dup, conc ~ Time | Subject), + pknca_error_duplicate_rows = function(e) e +) +conditionMessage(err) +err$rows +``` + +Concentration data do not need to be sorted by time: `pk.nca()` sorts each group's data by time before calculating (≥ 0.13.0; before, unsorted data could stop `pk.nca()` with an error). + +### Limit of quantification (≥ 0.13.0) + +The `lloq` argument of `PKNCAconc()` (used by the Tobit half-life; see the [Half-Life chapter](halflife.qmd)) is checked when the object is built. Every non-missing value must be numeric, finite, and greater than zero; anything else is the `pknca_error_conc_invalid_lloq` error naming the invalid values: + +```{r} +tryCatch( + PKNCAconc(d_conc, conc ~ Time | Subject, lloq = 0), + pknca_error_conc_invalid_lloq = function(e) conditionMessage(e) +) +``` + +Missing values are allowed. A column that is entirely `NA` of any class (for example, the logical `NA` column of a file with no LLOQ recorded) is accepted and stored as numeric `NA`: + +```{r} +d_no_lloq <- d_conc |> mutate(lloq = NA) +o_conc_no_lloq <- PKNCAconc(d_no_lloq, conc ~ Time | Subject, lloq = "lloq") +class(o_conc_no_lloq$data$lloq) +``` + +### Half-life point-selection columns (≥ 0.13.0) + +The `exclude_half.life` and `include_half.life` columns of `PKNCAconc()` must be logical (`TRUE`, `FALSE`, or `NA`), and the named column must exist. Before 0.13.0, a character column such as `"yes"`/`"no"` or a misspelled column name was accepted silently and selected nothing; both are now errors: + +```{r} +d_hl <- d_conc |> mutate(hl_exclude = ifelse(Time > 20, "yes", "no")) +tryCatch( + PKNCAconc(d_hl, conc ~ Time | Subject, exclude_half.life = "hl_exclude"), + error = function(e) conditionMessage(e) +) +``` + +A single interval also cannot use both `include_half.life` and `exclude_half.life`; `pk.nca()` stops with an error when it reaches such an interval. The [Half-Life chapter](halflife.qmd) covers manual point selection. + +### Checking data before building objects (≥ 0.13.0) + +Applications that accept user data (Shiny apps, pipelines) can validate it before building PKNCA objects with the exported checkers. `assert_conc_time()` checks one profile's concentrations and times; it returns `NULL` invisibly when they are valid and stops with an informative error otherwise: + +```{r} +assert_conc_time(conc = c(0, 2, 1), time = c(0, 1, 2)) + +tryCatch( + assert_conc_time(conc = c(0, 2, 1), time = c(0, 2, 1)), + error = function(e) conditionMessage(e) +) + +# Unsorted times are accepted when sorting is not required +assert_conc_time(conc = c(0, 2, 1), time = c(0, 2, 1), sorted_time = FALSE) +``` + +`assert_impute_method()` resolves an imputation specification the way `PKNCAdata()` and `pk.nca()` do (a string of methods, or the name of a column of the intervals) and checks that every named method exists. It returns the resolved imputation strings invisibly: + +```{r} +assert_impute_method("start_predose,start_conc0") + +tryCatch( + assert_impute_method("start_predoes"), + pknca_error_impute_funs_not_found = function(e) conditionMessage(e) +) + +# A column of the intervals that holds per-interval methods +d_intervals <- data.frame(start = 0, end = 24, method = "start_conc0") +print(assert_impute_method("method", intervals = d_intervals)) +``` + +`PKNCA_impute_fun_list()` gives the imputation function names a specification resolves to, and `pknca_impute_methods()` lists the available methods; both are covered in the [Concentration Imputation chapter](imputation.qmd). + +--- + +## Missing samples from the nominal schedule (≥ 0.13.0) + +`pknca_missing_samples()` compares each subject's samples with the nominal sampling schedule of its group and lists the nominal times that have no usable concentration. It needs the `time.nominal` argument of `PKNCAconc()`, and it accepts a `PKNCAconc`, `PKNCAdata`, or `PKNCAresults` object. + +To demonstrate, three problems are introduced into the example data: subject 2 has no row at 9 hours, subject 5 has an `NA` concentration at 1 hour, and subject 7's 3.5-hour sample is excluded through the `exclude` column of `PKNCAconc()`: + +```{r} +d_qc <- d_conc |> + filter(!(Subject == 2 & time_nominal == 9)) |> + mutate( + conc = ifelse(Subject == 5 & time_nominal == 1, NA, conc), + exclude_reason = ifelse(Subject == 7 & time_nominal == 3.5, "hemolyzed", NA_character_) + ) +o_conc_qc <- PKNCAconc(d_qc, conc ~ Time | Subject, + time.nominal = "time_nominal", exclude = "exclude_reason") + +pknca_missing_samples(o_conc_qc) +``` + +Each missing time has one of three reasons: + +| `reason` | Meaning | +|---|---| +| `"no row"` | The subject has no row at that nominal time. | +| `"NA concentration"` | Every row of the subject at that nominal time has an `NA` concentration. | +| `"excluded"` | The subject has a concentration at that time, but every row with one is excluded (with the `exclude` column of `PKNCAconc()` or with `exclude()`), so the calculations do not use it. | + +How the schedule is defined matters when reading the output: + +- **The schedule of a group** is every nominal time that has a row in the concentration data of that group, where the group is every grouping variable except the subject (study, treatment, analyte, and so on). A time that no subject has a row for is not part of the schedule. Recording a missed sample as a row with an `NA` concentration keeps the time in the schedule even when every subject missed it. +- **A BLQ sample (zero concentration) is usable** and is never reported. +- **Rows with an `NA` nominal time** (unscheduled samples) neither add a time to the schedule nor fill one. +- **Nominal times must share one origin per subject** (for example, the time since the first dose). When a subject's nominal times restart at each dose while its actual times increase, that group is not reported and a `pknca_warning_missing_samples_nominal_restart` warning names it. +- **Sparse data** have no per-subject schedule; only existing rows are checked (reasons `"NA concentration"` and `"excluded"`), with a message saying so. + +The result is an ordinary data frame with one row per missing sample, sorted by group, subject, and nominal time, and with no rows when nothing is missing, so it can be written straight to a data-review listing. + +--- + +## Tmax coverage (≥ 0.13.0) + +A subject with no samples near the time of the peak has a Cmax and Tmax that describe its sampling rather than its pharmacokinetics. `exclude_nca_tmax_coverage()` is an exclusion rule, used with `exclude()` like the other `exclude_nca_*()` rules, that compares each subject's samples with the Tmax values of its summary group using Tukey's fences: + +- From the first and third quartiles (Q1, Q3) of the Tmax values of every subject in the group (the subject being judged included), the **inner fences** are Q1 − `k_warn` × IQR and Q3 + `k_warn` × IQR, and the **outer fences** use `k_exclude` instead. The defaults are `k_warn = 1.5` and `k_exclude = 3`. The ranges never start before the interval start. +- A subject with **no sample within the outer fences** is excluded: every result of that interval gets an exclusion reason giving the outer range and the subject's nearest sample. +- A subject with a sample within the outer fences but **none within the inner fences** is not excluded, but gets the classed warning `pknca_warning_tmax_coverage_outlier`. +- With nominal times, a subject with a sample within the inner fences that is **missing some of the group's nominal times within them** gets the classed message `pknca_message_tmax_coverage_partial`, since its Cmax and Tmax may be unreliable. + +The group is the summary group of `summary()`: every grouping column except the subject, plus the interval start and end. A sample at the interval start (predose) counts only after an intravascular bolus. With nominal times (`time.nominal` in `PKNCAconc()`), samples and Tmax values are placed by their nominal times; otherwise by their actual times. + +### Example + +Three subjects are given sampling gaps: subject 12 has no samples from 0.25 through 5 hours (its nearest post-dose sample is at 7 hours), subject 10 has none from 0.25 through 3.5 hours (nearest at 5 hours), and subject 6 is missing only its 1-hour sample. + +```{r} +d_gaps <- d_conc |> + filter( + !(Subject == 12 & time_nominal > 0 & time_nominal <= 5), + !(Subject == 10 & time_nominal > 0 & time_nominal <= 3.5), + !(Subject == 6 & time_nominal == 1) + ) +o_conc_gaps <- PKNCAconc(d_gaps, conc ~ Time | Subject, time.nominal = "time_nominal") +o_dose <- PKNCAdose(d_dose, dose ~ time | Subject) +o_nca_gaps <- pk.nca(PKNCAdata( + o_conc_gaps, o_dose, + intervals = data.frame(start = 0, end = Inf, cmax = TRUE, tmax = TRUE, auclast = TRUE) +)) +``` + +The fences come from the nominal times at which each subject's Tmax was observed: + +```{r} +d_tmax_nominal <- as.data.frame(o_nca_gaps) |> + filter(PPTESTCD == "tmax") |> + left_join(d_gaps |> select(Subject, Time, time_nominal), + by = c("Subject", "PPORRES" = "Time")) +d_tmax_nominal |> select(Subject, tmax = PPORRES, time_nominal) + +q <- quantile(d_tmax_nominal$time_nominal, c(0.25, 0.75)) +iqr <- unname(q[2] - q[1]) +c(inner_upper = unname(q[2]) + 1.5 * iqr, outer_upper = unname(q[2]) + 3 * iqr) +``` + +The lower fences are below the interval start, so the inner range is 0 to 4.44 hours and the outer range is 0 to 6.5 hours. Applying the rule: + +```{r} +o_nca_cov <- exclude(o_nca_gaps, FUN = exclude_nca_tmax_coverage()) +``` + +The output above is the intended result of the rule, not a problem with the example: + +- Subject 6 gets the `pknca_message_tmax_coverage_partial` message: it has samples within the inner range but is missing the 1-hour sample. +- Subject 10 gets the `pknca_warning_tmax_coverage_outlier` warning: its nearest sample (5 hours) is outside the inner range but inside the outer range, so it is flagged for review and kept. +- Subject 12 has no sample within the outer range (its nearest is at 7 hours), so all of its results are excluded: + +```{r} +as.data.frame(o_nca_cov) |> + filter(!is.na(exclude)) |> + select(Subject, PPTESTCD, PPORRES, exclude) +``` + +Because the exclusion is recorded in the `exclude` column, `summary()` drops subject 12 from every statistic (`n=11`): + +```{r} +summary(o_nca_cov) +``` + +### Collecting the flags in a pipeline + +The warning and message are classed and carry fields (`group`, `tmax_range`, and `nearest_sample` for the warning; `group`, `tmax_range`, `time_nominal_missing`, and `reason` for the message), so an automated pipeline can collect them into a review table with `withCallingHandlers()` instead of printing them: + +```{r} +flags <- new.env(parent = emptyenv()) +flags$rows <- list() +o_nca_cov2 <- withCallingHandlers( + exclude(o_nca_gaps, FUN = exclude_nca_tmax_coverage()), + pknca_warning_tmax_coverage_outlier = function(w) { + flags$rows[[length(flags$rows) + 1]] <- + data.frame(w$group, flag = "outside inner fences", detail = paste("nearest sample", w$nearest_sample)) + invokeRestart("muffleWarning") + }, + pknca_message_tmax_coverage_partial = function(m) { + flags$rows[[length(flags$rows) + 1]] <- + data.frame(m$group, flag = "partial coverage", + detail = paste("missing", paste(m$time_nominal_missing, collapse = ", "))) + invokeRestart("muffleMessage") + } +) +bind_rows(flags$rows) +``` + +### Groups that are not checked + +- A group with fewer than `min_subjects` subjects with a Tmax (default 4) is not checked, with the `pknca_message_tmax_coverage_few_subjects` message; quartiles of fewer than four values describe the spread poorly. +- Sparse data have one Tmax per group (from the mean profile), so they are not checked, with the `pknca_message_tmax_coverage_sparse` message. +- When the nominal times disagree with the interval (for example, nominal times that restart at each dose while the interval is in time since first dose), the group is not checked, with the `pknca_warning_tmax_coverage_no_nominal` warning. + +--- + +## Listing the exclusion rules (≥ 0.13.0) + +`pknca_exclude_rules()` lists every registered `exclude_nca_*()` rule with its description, its arguments and their defaults, the `PKNCA.options()` entries it uses, and the parameters it can exclude: + +```{r} +rules <- pknca_exclude_rules() +rules |> select(rule, description) |> knitr::kable() +``` + +The `arguments`, `options`, and `affected_parameters` columns are list columns: + +```{r} +rules$arguments[[which(rules$rule == "exclude_nca_tmax_coverage")]] |> select(argument, default) +rules$options[[which(rules$rule == "exclude_nca_span.ratio")]] +span_affected <- rules$affected_parameters[[which(rules$rule == "exclude_nca_span.ratio")]] +length(span_affected) +c("lambda.z", "half.life", "aucinf.obs", "cl.obs", "cmax") %in% span_affected +``` + +The `affected_parameters` column shows why rule-based exclusion propagates: the span-ratio rule can exclude `r length(span_affected)` parameters, the whole λz family and every parameter calculated from it (`aucinf.obs`, `cl.obs`, and so on), but not parameters such as `cmax` that do not depend on λz. The rules with no default for a required argument (`exclude_nca_by_param()`, `exclude_nca_count_conc_measured()`) have `callable_with_defaults` `FALSE` and `NULL` in the last two columns. The [Post-Processing chapter](postprocessing.qmd) has a table of the rules and examples of chaining them. + +--- + +::: {.callout-note icon=false appearance="minimal"} +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [assert_conc_time()](https://humanpred.github.io/pknca/reference/assert_conc_time.html) · [assert_impute_method()](https://humanpred.github.io/pknca/reference/assert_impute_method.html) · [pknca_missing_samples()](https://humanpred.github.io/pknca/reference/pknca_missing_samples.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_tmax_coverage()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [pknca_exclude_rules()](https://humanpred.github.io/pknca/reference/pknca_exclude_rules.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) +::: diff --git a/user/datetime.qmd b/user/datetime.qmd new file mode 100644 index 0000000..2da8dec --- /dev/null +++ b/user/datetime.qmd @@ -0,0 +1,235 @@ +--- +title: "Date-Time Data and Time References" +--- + +```{r setup, include=FALSE} +library(PKNCA) +library(dplyr) +conflicted::conflicts_prefer(dplyr::filter, dplyr::select, .quiet = TRUE) +``` + +Clinical data usually record sample and dose times as dates and clock times (SDTM `PCDTC` and `EXSTDTC`), not as hours since the first dose. Starting with PKNCA 0.13.0, concentration and dose times may be date-times (`POSIXct`) or dates (`Date`), and PKNCA does the conversion to elapsed time itself (≥ 0.13.0): + +- `PKNCAconc()`, `PKNCAdose()`, and `PKNCAdata()` keep the date-times as they are. +- `pk.nca()` converts them to numeric time relative to a **time reference** for each group: the first included dose, or the first concentration when a group has no dose. +- The numeric time is in the analysis time unit: `timeu_pref` of `PKNCAconc()` when given, otherwise `timeu`, otherwise hours. +- The results keep the reference of each group, and the CDISC output reports each row's reference date-time (`PPRFTDTC`) and its interval relative to the dose that starts it (`PPSTINT`, `PPENINT`, `PPTPTREF`). + +The rest of the CDISC output (parameter codes, `PPANMETH`, `PPGRPID`) is covered in the [Regulatory and CDISC Context chapter](regulatory.qmd), and units in the [Units Assignment and Conversion chapter](units.qmd). + +--- + +## Single-dose example with date-times + +The Theoph study recorded times as hours after the dose. To show date-time input, each subject is given a dose at 08:00 on a different day, and the sample times are built from it: + +```{r} +d_conc <- as.data.frame(Theoph) |> + mutate( + Subject = as.integer(as.character(Subject)), + dose_dtc = as.POSIXct("2026-03-02 08:00", tz = "UTC") + as.difftime(Subject - 1, units = "days"), + conc_dtc = dose_dtc + as.difftime(Time, units = "hours") + ) +d_dose <- d_conc |> + group_by(Subject) |> + summarise(dose = Dose[1] * Wt[1], dose_dtc = dose_dtc[1], .groups = "drop") + +head(d_conc |> select(Subject, Time, conc_dtc, conc), 4) +head(d_dose, 3) +``` + +The date-time columns are used in the formulas exactly as numeric times would be: + +```{r} +o_conc <- PKNCAconc(d_conc, conc ~ conc_dtc | Subject, concu = "mg/L", timeu = "hr") +o_dose <- PKNCAdose(d_dose, dose ~ dose_dtc | Subject, doseu = "mg") +o_data <- PKNCAdata(o_conc, o_dose) +o_nca <- pk.nca(o_data) +``` + +`PKNCAdata()` generated the intervals in the usual way (see the [Automatic Intervals and Dosing Regimens chapter](auto-intervals.qmd)). The results carry the converted data that `pk.nca()` used, with the time reference of each group in `o_nca$data$time_reference`: + +```{r} +head(o_nca$data$time_reference, 3) +``` + +The `time_reference_type` column says whether each reference is the `"first_dose"` or the `"first_conc"`. In the converted concentration data, the date-time column now holds hours since the reference, and the results are identical to an analysis of the numeric `Time` column: + +```{r} +head(o_nca$data$conc$data |> select(Subject, conc_dtc, Time, conc), 4) + +as.data.frame(o_nca) |> + filter(Subject == 1, PPTESTCD %in% c("cmax", "tmax", "auclast")) |> + select(Subject, start, end, PPTESTCD, PPORRES, PPORRESU) +``` + +--- + +## How the time reference is chosen + +- **First dose within the shared groups.** The reference is the first included (not excluded) dose within each combination of the grouping variables shared by the concentration and dose formulas. With `dose ~ time | Part + Subject`, each subject's first dose in each study part (or period, for a crossover) is time 0; with `dose ~ time | Subject`, each subject's first dose of the study is time 0. The dose formula must include the subject for dense data, so that one reference is never shared by several subjects. +- **No dose: first concentration.** A subject without an included dose time uses its first included concentration as the reference, with the `pknca_warning_datetime_first_conc_reference` warning. Without any dosing data, every reference is the first concentration (within each combination of the concentration grouping variables to the left of any `/`, so analytes share their subject's reference), without a warning. +- **Sparse data** use one reference per group, because every subject in a sparse group shares the group's dosing. +- **Dates** (`Date`) are taken as 08:00 on that date, with the `pknca_warning_date_assumed_time` warning. + +The warning for a subject without a dose looks like this (subject 3's dose row is removed): + +```{r} +o_nca_no_dose_3 <- pk.nca(PKNCAdata( + PKNCAconc(d_conc, conc ~ conc_dtc | Subject), + PKNCAdose(d_dose |> filter(Subject != 3), dose ~ dose_dtc | Subject), + intervals = data.frame(start = 0, end = 24, cmax = TRUE) +)) +o_nca_no_dose_3$data$time_reference |> filter(Subject %in% 2:4) +``` + +The warning is expected here: subject 3's times are now relative to its first sample rather than to a dose, and its `time_reference_type` is `"first_conc"`. + +### Inputs that are errors + +Concentration and dose times must both be numeric or both date-times, and date-times must share one time zone (UTC offset): + +```{r} +tryCatch( + PKNCAdata(o_conc, PKNCAdose(d_dose |> mutate(time = 0), dose ~ time | Subject)), + error = function(e) conditionMessage(e) +) +``` + +Time differences are elapsed time, so a change to or from daylight saving time is handled correctly when the time zone is a named zone (such as `"America/New_York"`) rather than a fixed offset. A dose given at 08:00 every day across the spring change is 23 elapsed hours after the previous one, which is what PKNCA uses. + +--- + +## The analysis time unit + +Date-times have no unit of their own, so `pk.nca()` converts them directly into the analysis time unit: `timeu_pref` when it is given to `PKNCAconc()`, otherwise `timeu`, otherwise hours. Numeric durations (such as a urine collection `duration`) are taken to be in that unit, and `difftime` durations are converted to it. + +With `timeu_pref = "day"`, the converted times, the intervals, and every time-based result are in days, and `PPORRES` is already in days: + +```{r} +o_conc_day <- PKNCAconc(d_conc, conc ~ conc_dtc | Subject, + concu = "mg/L", timeu = "hr", timeu_pref = "day") +o_nca_day <- pk.nca(PKNCAdata( + o_conc_day, o_dose, + intervals = data.frame(start = 0, end = 1, cmax = TRUE, tmax = TRUE, auclast = TRUE) +)) +as.data.frame(o_nca_day) |> + filter(Subject == 1) |> + select(start, end, PPTESTCD, PPORRES, PPORRESU) +``` + +This differs from numeric input. With numeric times, the times are already in `timeu`, so the results are calculated in `timeu` and `timeu_pref` converts them afterward into `PPSTRES`/`PPSTRESU` (see the [Units chapter](units.qmd)). + +`pknca_difftime_to_unit()` (≥ 0.13.0) is the conversion that `pk.nca()` applies, exported for preparing data yourself, for example a collection duration computed as the difference of two date-times: + +```{r} +collection_start <- as.POSIXct("2026-03-02 08:00", tz = "UTC") +collection_end <- as.POSIXct("2026-03-02 20:30", tz = "UTC") +pknca_difftime_to_unit(collection_end - collection_start, unit = "hr") +pknca_difftime_to_unit(as.difftime(36, units = "hours"), unit = "day") +``` + +It accepts only a `difftime` (`pknca_error_difftime_not_difftime` otherwise, since a plain number has no unit to convert from); give a number its unit first with `as.difftime()`. Hours need no conversion; any other unit needs the units package. + +--- + +## Multiple-dose example + +The remaining sections use a small multiple-dose study: two subjects receive 100 mg once daily for 5 days, subject 1 at 08:00 and subject 2 at 09:00, with intensive sampling after the first and fifth doses. The concentrations come from a one-compartment model with first-order absorption, summed over the doses. + +```{r} +first_dose <- as.POSIXct("2026-03-02 08:00", tz = "UTC") +d_dose_md <- expand.grid(Subject = 1:2, day = 0:4) |> + mutate(dose = 100, + dose_dtc = first_dose + as.difftime(day, units = "days") + + as.difftime(Subject - 1, units = "hours")) |> + select(Subject, dose, dose_dtc) |> + arrange(Subject, dose_dtc) + +profile_hours <- c(0, 0.5, 1, 2, 4, 8, 12, 24) +dose_hours <- c(0, 24, 48, 72, 96) +d_conc_md <- expand.grid(Subject = 1:2, hours = c(profile_hours, 96 + profile_hours)) |> + mutate( + conc_dtc = first_dose + as.difftime(hours + Subject - 1, units = "hours"), + conc = round(sapply(hours, function(t) { + t_dose <- t - dose_hours[dose_hours <= t] + sum(10 * (exp(-0.1 * t_dose) - exp(-1.5 * t_dose))) + }), 3) + ) |> + select(Subject, conc_dtc, conc) |> + arrange(Subject, conc_dtc) + +o_conc_md <- PKNCAconc(d_conc_md, conc ~ conc_dtc | Subject, concu = "ng/mL", timeu = "hr") +o_dose_md <- PKNCAdose(d_dose_md, dose ~ dose_dtc | Subject, doseu = "mg") +``` + +--- + +## Intervals given as date-times (≥ 0.13.0) + +With date-time data (see also the date-time section of the [Interval Selection and Parameter Catalog chapter](intervals.qmd)), manually specified intervals may be numeric times relative to the time reference (in the analysis time unit) or date-times. A date-time interval is converted relative to the reference of the group it applies to, when `pk.nca()` runs. Here each subject's interval is its fifth dosing interval, given by clock time: + +```{r} +d_intervals_dtc <- data.frame( + Subject = 1:2, + start = d_dose_md$dose_dtc[c(5, 10)], + end = d_dose_md$dose_dtc[c(5, 10)] + as.difftime(24, units = "hours"), + cmax = TRUE, auclast = TRUE +) +d_intervals_dtc[, c("Subject", "start", "end")] + +o_nca_dtc <- pk.nca(PKNCAdata(o_conc_md, o_dose_md, intervals = d_intervals_dtc)) +o_nca_dtc$data$intervals |> select(Subject, start, end, interval_time_kind) +``` + +Both windows convert to 96 to 120 hours, and the converted intervals are marked `interval_time_kind = "datetime"` (numeric rows added later are `"relative"`). + +A row that does not name every reference group (here, a row without `Subject`) applies to every matching group and becomes one row per group, because one clock-time window is a different relative window for each subject. An `end` of `Inf` stays infinite: + +```{r} +o_nca_shared <- pk.nca(PKNCAdata( + o_conc_md, o_dose_md, + intervals = data.frame(start = as.POSIXct("2026-03-06 09:00", tz = "UTC"), end = Inf, cmax = TRUE) +)) +o_nca_shared$data$intervals |> select(Subject, start, end, interval_time_kind) +``` + +The 09:00 window starts at subject 2's fifth dose (96 hours) but one hour after subject 1's (97 hours). The conversion gives only the window: a window that starts before a subject's first measurement still needs an imputation rule (`impute`; see the [Concentration Imputation chapter](imputation.qmd)) for a concentration at its start. Date-time bounds mixed with finite numeric bounds, an infinite start, date-time intervals for numeric data, and a different time zone are errors, checked by `PKNCAdata()` and `set_intervals()` (through `assert_intervals()`). + +--- + +## Time references in CDISC output (≥ 0.13.0) + +`as.data.frame(out_format = "cdisc")` gives every result row its time point reference as SDTMIG 3.4 defines it: + +| Column | Content | +|---|---| +| `PPTPTREF` | The reference: `"LAST DOSE PRIOR TO INTERVAL"` (the last included dose at or before the interval start for that subject and group), or `"FIRST OBSERVATION"` for a subject with no included dose | +| `PPSTINT` | Interval start as an ISO 8601 duration relative to that reference | +| `PPENINT` | Interval end as an ISO 8601 duration relative to that reference; `NA` for an interval to infinity | +| `PPRFTDTC` | The reference's date-time (only when the concentration and dose times were date-times) | + +These columns are added for **every** parameter, not only interval-based ones. Three intervals show how they are derived: the first dosing interval, the fifth dosing interval, and an interval from 100 hours to infinity: + +```{r} +o_nca_md <- pk.nca(PKNCAdata( + o_conc_md, o_dose_md, + intervals = data.frame(start = c(0, 96, 100), end = c(24, 120, Inf), cmax = TRUE) +)) +as.data.frame(o_nca_md, out_format = "cdisc") |> + select(Subject, start, end, PPTESTCD, PPORRES, PPSTINT, PPENINT, PPTPTREF, PPRFTDTC) +``` + +- The steady-state interval (96 to 120 hours) reads `"PT0H"` to `"PT24H"`, relative to the fifth dose, and its `PPRFTDTC` is that dose's date-time. `PPRFTDTC` therefore differs between the intervals of one subject. +- The interval starting at 100 hours is relative to the last dose before it (the dose at 96 hours), so it reads `"PT4H"`, and its `PPENINT` is `NA` because it ends at infinity. +- The `start` and `end` columns stay relative to the subject's first dose. + +The durations are in the preferred time unit (`timeu_pref`) when it is given, otherwise in `timeu`; with `timeu_pref = "day"` the first interval reads `"P0D"` to `"P1D"`. Durations are never in scientific notation, and an interval starting before its reference (possible with a first-observation reference) has a negative duration such as `"-PT0.5H"`. `PPSTINT`, `PPENINT`, and `PPTPTREF` are `NA` when a subject has doses but none at or before the interval start. PKNCA is the source of truth for these columns, so downstream SDTM writers need not recompute them. + +With numeric times, the same columns are filled except `PPRFTDTC`, which needs a date-time. + +--- + +::: {.callout-note icon=false appearance="minimal"} +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame.PKNCAresults()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [pknca_difftime_to_unit()](https://humanpred.github.io/pknca/reference/pknca_difftime_to_unit.html) · [set_intervals()](https://humanpred.github.io/pknca/reference/set_intervals.html) · [assert_intervals()](https://humanpred.github.io/pknca/reference/assert_intervals.html) +::: diff --git a/user/postprocessing.qmd b/user/postprocessing.qmd index 8b28f14..1e144f8 100644 --- a/user/postprocessing.qmd +++ b/user/postprocessing.qmd @@ -18,7 +18,9 @@ After `pk.nca()` you may need to: 3. **Normalize** parameters by dose (built-in `.dn` variants) 4. **Summarize** with custom statistics, respecting exclusions 5. **Report** with the `business.*` summary helper functions -6. **Inspect** which concentrations each λz regression used +6. **Inspect** which concentrations each λz regression used, and the fitted line + +Data-quality checks that run before or alongside exclusion (missing samples, Tmax coverage, input validation) are covered in the [Data Quality Checks chapter](data-quality.qmd). Most of this operates on the `PKNCAresults` object without rerunning the analysis (requesting `.dn` parameters is the exception — they are computed by `pk.nca()`). The package's own [Post-Processing vignette](https://humanpred.github.io/pknca/articles/v07-post-processing.html) walks the same workflow. @@ -95,6 +97,8 @@ as.data.frame(o_nca_excl) |> select(subject, PPTESTCD, PPORRES, exclude) ``` +The function given as `FUN` (next section) is called once per group of results and must return either one value or one value per result row of the group; anything else is the `pknca_error_exclude_fun_length` error (≥ 0.13.0). A warning that the function signals reaches the caller with its class and fields intact (≥ 0.13.0), so it can be caught with `withCallingHandlers()`. + ### Rule-based exclusion functions PKNCA provides ready-made rule functions to pass to `exclude(FUN=...)`. Unlike a manual `mask`, rule-based exclusion propagates: each rule excludes the parameters it affects (for the half-life rules, the whole λz family) *and* the parameters that depend on them, such as `aucinf.obs` and `aucpext.obs`. The built-in rules also supply their own reason text, which overrides the `reason` argument in the `exclude` column. @@ -108,7 +112,10 @@ PKNCA provides ready-made rule functions to pass to `exclude(FUN=...)`. Unlike a | `exclude_nca_tmax_0()` | Tmax at or before 0 (shortcut for `exclude_nca_tmax_early(0)`) | | `exclude_nca_tmax_early(tmax_early = 0)` | Tmax at or before a threshold time (`tmax_early`, default 0) | | `exclude_nca_count_conc_measured(min_count, exclude_param_pattern = ...)` | fewer than `min_count` measured concentrations; excludes the AUC-family parameters matching `exclude_param_pattern` | -| `exclude_nca_by_param(parameter, min_thr = NULL, max_thr = NULL, affected_parameters = parameter)` (≥ 0.12.2) | the value of `parameter` falls outside `[min_thr, max_thr]`; excludes `affected_parameters` (default: `parameter` itself) | +| `exclude_nca_by_param(parameter, min_thr = NULL, max_thr = NULL, affected_parameters = parameter)` (≥ 0.12.1) | the value of `parameter` falls outside `[min_thr, max_thr]`; excludes `affected_parameters` (default: `parameter` itself) | +| `exclude_nca_tmax_coverage(min_subjects = 4, k_warn = 1.5, k_exclude = 3)` (≥ 0.13.0) | the subject has no sample within Tukey's outer fences around its group's Tmax values; excludes every result of the interval (subjects outside only the inner fences get a warning) | + +`pknca_exclude_rules()` (≥ 0.13.0) lists these rules with their arguments, defaults, the options they use, and the parameters each can exclude. The Tmax-coverage rule and the rule listing are demonstrated in the [Data Quality Checks chapter](data-quality.qmd). ```{r} # Exclude half-lives where span ratio < 2 @@ -173,7 +180,7 @@ data.frame( ) |> knitr::kable() ``` -Each `.dn` parameter is computed as `parameter / dose` where `dose` is the dose from the `PKNCAdose` object for that interval. In PKNCA ≥ 0.12.2 this includes renal clearance variants (`clr.last.dn`, `clr.obs.dn`, `clr.pred.dn`). +Each `.dn` parameter is computed as `parameter / dose` where `dose` is the dose from the `PKNCAdose` object for that interval. In PKNCA ≥ 0.13.0 this includes renal clearance variants (`clr.last.dn`, `clr.obs.dn`, `clr.pred.dn`). Request the `.dn` variants in the interval like any other parameter: @@ -205,9 +212,9 @@ as.data.frame(o_nca_dn) |> arrange(subject) ``` -### Custom normalization with `normalize()` (≥ 0.12.2) +### Custom normalization with `normalize()` (≥ 0.13.0) -For normalization by columns other than dose (e.g. body weight, body surface area), PKNCA 0.12.2 adds `normalize()`. You supply a `norm_table` data frame containing the grouping columns, a `normalization` value, and a `unit` for each group: +For normalization by columns other than dose (e.g. body weight, body surface area), PKNCA 0.13.0 adds `normalize()`. You supply a `norm_table` data frame containing the grouping columns, a `normalization` value, and a `unit` for each group. With grouped data, every group of the results must be in `norm_table`, so no result rows are dropped silently; with ungrouped data, `norm_table` must have exactly one row. ```{r} # Normalize AUClast and Cmax by body weight @@ -232,7 +239,72 @@ df_bw_norm |> > `normalize()` returns **only** the normalized rows, under new parameter names — `auclast` becomes `auclast.bw`, `cmax` becomes `cmax.bw`. To keep both original and normalized values, bind the normalized rows onto the original results: `bind_rows(df_results, df_bw_norm)`. -`normalize_by_col()` (≥ 0.12.2) is the low-level internal helper that pulls normalization values from a column already present in the `PKNCAconc` data frame, rather than from a separate table. +`normalize()` also accepts the `PKNCAresults` object itself. It then adds the normalized rows to the results and returns a `PKNCAresults` object holding both the original and the normalized parameters (marked as modified; see the provenance note in the [Regulatory chapter](regulatory.qmd)). + +### Normalizing by a column of the concentration data (≥ 0.13.0) + +When the normalizing value is already a column of the concentration data, `normalize_by_col()` builds the normalization table from it. It takes a `PKNCAresults` object, the column name, and the unit (a fixed string or the name of a unit column), and returns a `PKNCAresults` object with the normalized parameters added. The Theoph concentration data carry body weight in `Wt`: + +```{r} +o_nca_bw <- normalize_by_col( + o_nca, + col = "Wt", + unit = "kg", + parameters = c("auclast", "cmax"), + suffix = ".bw" +) + +as.data.frame(o_nca_bw) |> + filter(subject %in% c("1", "2"), PPTESTCD %in% c("auclast", "auclast.bw")) |> + select(subject, PPTESTCD, PPORRES) |> + arrange(subject, PPTESTCD) +``` + +Subject 1 weighs 79.6 kg, so its `auclast.bw` is its `auclast` divided by 79.6. The column must have one value per concentration group; a column whose value changes within a group (for example, a weight recorded at several visits within one profile) is the `pknca_error_norm_multiple_values` error. + +--- + +## Filtering and grouping results + +dplyr verbs work on `PKNCAresults` objects. `filter()` with expressions that use only group columns (the columns after the `|` in the `PKNCAconc()` formula) keeps the concentration data, dose data, and intervals in `$data` consistent with the remaining results (≥ 0.13.0); filters on other result columns (`PPTESTCD`, `PPORRES`, `exclude`) filter only the result table: + +```{r} +o_nca_12 <- filter(o_nca, subject %in% c("1", "2")) +nrow(as.data.frame(o_nca_12)) +unique(as.character(o_nca_12$data$conc$data$subject)) +``` + +`group_vars()` on a `PKNCAresults` object includes the interval `start` and `end` with the group columns (≥ 0.13.0), so code that groups or joins results by `group_vars()` also matches on the interval: + +```{r} +group_vars(o_nca) +``` + +A results object changed by `filter()`, `mutate()`, `exclude()`, or `normalize()` no longer passes `checkProvenance()`; see the [Regulatory chapter](regulatory.qmd#provenance-of-results). + +--- + +## Controlling the summary table + +`summary()` arguments control which groups and parameters appear and how gaps are shown: + +| Argument | Default | Effect | +|---|---|---| +| `drop_group` | the subject column | Group columns summarized over (removed from the rows of the table) | +| `drop_param` | none | Parameters left out of the table | +| `summarize_n` | `NA` (add `N` when there is a subject column) | Add a column `N` with the number of subjects | +| `not_requested` | `"."` | Text for a parameter not requested in an interval | +| `not_calculated` | `"NC"` | Text for a requested parameter whose summary is `NA` | + +```{r} +summary(o_nca, drop_param = c("adj.r.squared", "span.ratio"), summarize_n = FALSE) +``` + +When the summary shows the not-calculated text, the caption explains it (≥ 0.13.0). Here, a span-ratio threshold of 20 excludes every half-life, so the λz-dependent parameters are not calculated: + +```{r} +summary(exclude(o_nca, FUN = exclude_nca_span.ratio(20))) +``` --- @@ -261,6 +333,8 @@ PKNCA.set.summary( summary(o_nca) ``` +`PKNCA.set.summary()` also has a `spread_for` argument (≥ 0.13.0) that summarizes one parameter with the spread taken from another parameter's results; PKNCA uses it to show a sparse AUClast as the estimate with its standard error (see the [Sparse NCA chapter](sparse.qmd)). `PKNCA.set.summary(reset = TRUE)` removes every summary instruction and, since that can break `summary()`, warns (≥ 0.13.0); restore individual parameters as below instead. + Restore the defaults — `business.geomean`/`business.geocv` for `auclast` and `business.median`/`business.range` for `tmax` (the `business.*` helpers are covered in the next section): @@ -311,6 +385,12 @@ data.frame( ) ``` +The `business.*` functions return `NA` for an empty input (≥ 0.13.0), rather than an error, so a summary group with no included values gives `NC` instead of stopping: + +```{r} +business.geomean(numeric(0)) +``` + Use these as `point` and `spread` arguments to `PKNCA.set.summary()` — rerunning `summary()` shows the new `cmax` presentation: ```{r} @@ -329,7 +409,7 @@ summary(o_nca) ## Inspecting the terminal regression window -`get_halflife_points()` returns a logical vector aligned with the original concentration data — `TRUE` if that observation was used in the λz regression, `FALSE` if it was available but excluded, `NA` if half-life was not computed for that interval. In PKNCA ≥ 0.12.2 it also accepts a `PKNCAdata` object directly and handles `start ≠ 0` intervals correctly. For how PKNCA selects the regression points, see the [Half-Life Calculation vignette](https://humanpred.github.io/pknca/articles/v06-half-life-calculation.html). +`get_halflife_points()` returns a logical vector aligned with the original concentration data — `TRUE` if that observation was used in the λz regression, `FALSE` if it was available but excluded, `NA` if half-life was not computed for that interval. In PKNCA ≥ 0.13.0 it also accepts a `PKNCAdata` object directly, handles `start ≠ 0` intervals correctly, and sets points outside any interval to `NA`. For how PKNCA selects the regression points, see the [Half-Life Calculation vignette](https://humanpred.github.io/pknca/articles/v06-half-life-calculation.html). Because the example analysis requested `half.life`, `o_nca` already carries the λz fits: @@ -340,10 +420,18 @@ table(hl_used, useNA = "always") ``` ```{r} -# >= 0.12.2: a PKNCAdata object is accepted directly (no need to run pk.nca first) +# >= 0.13.0: a PKNCAdata object is accepted directly (no need to run pk.nca first) head(get_halflife_points(o_data)) ``` +`get_halflife_fit()` (≥ 0.13.0) returns the fitted terminal line of each interval (intercept and slope on the natural-log scale, and the first and last times used), and `get_halflife_curve()` (≥ 0.13.0) returns predicted concentrations along it, by default `n = 50` points from the first time used, extrapolated to the end of the interval's data (`extrapolate_later = TRUE`): + +```{r} +get_halflife_fit(o_nca) |> filter(subject == "1") +d_hl_curve <- get_halflife_curve(o_nca) +head(d_hl_curve |> filter(subject == "1"), 3) +``` + ```{r} # Attach to concentration data for plotting d_conc_annotated <- d_conc |> mutate(in_lambda_z = hl_used) @@ -351,12 +439,14 @@ subj1_conc <- d_conc_annotated |> filter(subject == "1") ggplot(subj1_conc, aes(x = time, y = conc, colour = in_lambda_z)) + geom_line(colour = "grey70") + + geom_line(data = d_hl_curve |> filter(subject == "1"), colour = "firebrick", + linetype = "dashed") + geom_point(size = 3) + scale_colour_manual(values = c("FALSE" = "grey50", "TRUE" = "firebrick"), labels = c("not used", "used in lambda.z"), name = NULL, na.value = "grey80") + scale_y_log10() + - labs(title = "Subject 1 - points used in lambda.z regression (red)", + labs(title = "Subject 1 - points used in lambda.z regression (red) and fitted line", x = "Time (h)", y = "Concentration (mg/L) [log scale]") + theme_minimal() ``` @@ -370,5 +460,5 @@ Imputation is a pre-calculation step, not a post-processing step. It is specifie --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_*()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [exclude_nca_by_param()](https://humanpred.github.io/pknca/reference/exclude_nca_by_param.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [normalize()](https://humanpred.github.io/pknca/reference/normalize.html) · [normalize_by_col()](https://humanpred.github.io/pknca/reference/normalize_by_col.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [business.*()](https://humanpred.github.io/pknca/reference/business.mean.html) · [get_halflife_points()](https://humanpred.github.io/pknca/reference/get_halflife_points.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_*()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [exclude_nca_by_param()](https://humanpred.github.io/pknca/reference/exclude_nca_by_param.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [normalize()](https://humanpred.github.io/pknca/reference/normalize.html) · [normalize_by_col()](https://humanpred.github.io/pknca/reference/normalize_by_col.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [business.*()](https://humanpred.github.io/pknca/reference/business.mean.html) · [get_halflife_points()](https://humanpred.github.io/pknca/reference/get_halflife_points.html) · [get_halflife_fit()](https://humanpred.github.io/pknca/reference/get_halflife_fit.html) · [get_halflife_curve()](https://humanpred.github.io/pknca/reference/get_halflife_curve.html) · [exclude_nca_tmax_coverage()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [pknca_exclude_rules()](https://humanpred.github.io/pknca/reference/pknca_exclude_rules.html) · [filter()](https://humanpred.github.io/pknca/reference/filter.PKNCAresults.html) · [checkProvenance()](https://humanpred.github.io/pknca/reference/checkProvenance.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) ::: diff --git a/user/regulatory.qmd b/user/regulatory.qmd index d7d243a..8d7c7a9 100644 --- a/user/regulatory.qmd +++ b/user/regulatory.qmd @@ -8,19 +8,19 @@ library(dplyr) conflicted::conflicts_prefer(dplyr::filter, dplyr::select, .quiet = TRUE) ``` -PKNCA does not build a complete submission dataset for you, but it comes close: its tidy, long-format result table maps directly onto the CDISC PP (Pharmacokinetic Parameters) domain, and as of version 0.12.2 `as.data.frame()` can translate parameter codes into CDISC standard codes directly (see the CDISC output format section below). This page documents that mapping and describes how to prepare outputs for regulatory submissions. For PKNCA's regulator-facing overview, see [PKNCA – an R package for noncompartmental analysis](https://humanpred.github.io/pknca/articles/v31-FDA-introduction.html). +PKNCA does not build a complete submission dataset for you, but it comes close: its tidy, long-format result table maps directly onto the CDISC PP (Pharmacokinetic Parameters) domain, and as of version 0.13.0 `as.data.frame(out_format = "cdisc")` translates parameter codes into CDISC standard codes and adds the SDTM time point reference, method, and group identifier columns (see the CDISC output format section below). This page documents that mapping and describes how to prepare outputs for regulatory submissions. For PKNCA's regulator-facing overview, see [PKNCA – an R package for noncompartmental analysis](https://humanpred.github.io/pknca/articles/v31-FDA-introduction.html). ```{r build-result, include=FALSE} data("Theoph") -d_conc <- PKNCAconc(Theoph, conc ~ Time | Subject) -d_dose <- PKNCAdose( +o_conc <- PKNCAconc(Theoph, conc ~ Time | Subject) +o_dose <- PKNCAdose( Theoph |> group_by(Subject) |> summarise(Dose = Dose[1] * Wt[1], .groups = "drop") |> mutate(Time = 0), Dose ~ Time | Subject ) -d_data <- PKNCAdata(d_conc, d_dose) -o_nca <- pk.nca(d_data) +o_data <- PKNCAdata(o_conc, o_dose) +o_nca <- pk.nca(o_data) ``` --- @@ -38,14 +38,15 @@ The columns map to CDISC PP variables as follows: | PKNCA column | CDISC PP variable | Notes | |--------------|-------------------|--------------------------------------------------------| -| `PPTESTCD` | `PPTESTCD` | Parameter short name; identical names by convention | +| `PPTESTCD` | `PPTESTCD` | PKNCA parameter name (`auclast`); translated to the CDISC code (`AUCLST`) by `out_format = "cdisc"` | | `PPORRES` | `PPORRES` | Original numeric result (character in CDISC; numeric here) | -| `start` | — | Interval start time; useful for `PPRFTDTC` derivation | -| `end` | — | Interval end time | +| `PPANMETH` | `PPANMETH` | Analysis method (≥ 0.13.0): imputation and the AUC and half-life methods used | +| `start` | — | Interval start, relative to the first dose; `out_format = "cdisc"` adds `PPSTINT` relative to the dose that starts the interval | +| `end` | — | Interval end, relative to the first dose; `out_format = "cdisc"` adds `PPENINT` | | `exclude` | `PPSTAT` / `PPREASND` (or a supplemental qualifier) | Non-`NA` string gives the exclusion reason | | Grouping columns (`Subject`, etc.) | `USUBJID`, `PPCAT` | Pass through unchanged | -Units (`PPORRESU`, `PPSTRESU`) are available when you configure a units table via `pknca_units_table()` and pass it to `PKNCAdata()`. Without a units table, `PPORRES` carries the raw numeric value and unit derivation must be done externally. For the full units workflow, see the [Units chapter](units.qmd) and [Unit Assignment and Conversion with PKNCA](https://humanpred.github.io/pknca/articles/v07-unit-conversion.html). +Units (`PPORRESU`, `PPSTRESU`) are available when the data have units: either unit arguments (`concu`, `timeu`, `doseu`, ...) given to `PKNCAconc()` and `PKNCAdose()`, from which `PKNCAdata()` builds the units table automatically (≥ 0.13.0), or a table from `pknca_units_table()` passed to `PKNCAdata()`. Without units, `PPORRES` carries the raw numeric value and unit derivation must be done externally. For the full units workflow, see the [Units chapter](units.qmd) and [Unit Assignment and Conversion with PKNCA](https://humanpred.github.io/pknca/articles/v07-unit-conversion.html). ```{r result-head} head(df, 10) @@ -80,7 +81,7 @@ All grouping variables will appear as columns in the long-format result, ready t ## Extracting results for submission -`as.data.frame(o_nca)` returns the individual (subject-level) long-format table. This is the primary output for PP domain construction. The `PPTESTCD` and `PPORRES` columns are already CDISC-named, so no renaming is needed: +`as.data.frame(o_nca)` returns the individual (subject-level) long-format table. This is the primary output for PP domain construction. The `PPTESTCD` and `PPORRES` column names are already the CDISC variable names, so no renaming is needed; the `PPTESTCD` values are PKNCA's parameter names until they are translated with `out_format = "cdisc"` (next section): ```{r extract-individual} pp_domain_draft <- as.data.frame(o_nca) @@ -96,19 +97,99 @@ The result is in tidy long format: one row per subject per parameter per interva --- -## CDISC output format (≥ 0.12.2) +## CDISC output format (≥ 0.13.0) -The long-format extraction above keeps PKNCA's own parameter codes in `PPTESTCD` — `auclast`, `cmax`, `lambda.z`, and so on. For submission datasets, `as.data.frame()` can translate those codes for you: `out_format = "cdisc"` replaces each `PPTESTCD` with its CDISC submission code (`auclast` becomes `AUCLST`, `clast.obs` becomes `CLST`, `lambda.z` becomes `LAMZ`) and inserts a `PPTEST` column carrying the standard parameter label. Route-dependent parameters resolve to the correct code based on the route recorded in the dose data. +The long-format extraction above keeps PKNCA's own parameter codes in `PPTESTCD` — `auclast`, `cmax`, `lambda.z`, and so on. For submission datasets, `as.data.frame(out_format = "cdisc")` translates them and adds the SDTM PP columns that PKNCA can fill: + +- `PPTESTCD` holds the CDISC submission code (`auclast` becomes `AUCLST`, `clast.obs` becomes `CLST`, `lambda.z` becomes `LAMZ`) and a new `PPTEST` column holds the standard parameter name. +- A new `pknca_parameter` column, placed just before `PPTESTCD`, keeps the original PKNCA parameter name. +- `PPSTINT`, `PPENINT`, `PPTPTREF`, and (for date-time data) `PPRFTDTC` give every row its time point reference. +- `PPGRPID` gives the record group identifier. ```{r cdisc-format} -as.data.frame(o_nca, out_format = "cdisc") |> - select(Subject, PPTESTCD, PPTEST, PPORRES) |> +df_cdisc <- as.data.frame(o_nca, out_format = "cdisc") +names(df_cdisc) + +df_cdisc |> + select(Subject, pknca_parameter, PPTESTCD, PPTEST, PPORRES) |> head(8) ``` -When an interval-based parameter is requested (for example `aucint.last`, whose CDISC code `AUCINT` contains "INT"), the CDISC output also gains `PPSTINT` and `PPENINT` columns giving the interval start and end as ISO 8601 durations relative to the last dose time (e.g. `PT0H`, `PT12H`). +### Parameter codes and the `pknca_parameter` column + +The translation is many-to-one. CDISC has one "AUC from T1 to T2" code regardless of how the region after Tlast is handled, so `aucint.last` and `aucint.inf.obs` both become `AUCINT`. The `pknca_parameter` column is then the only column that identifies which PKNCA calculation produced a row, and `PPANMETH` records the method. The column name is lowercase so that it cannot be mistaken for an SDTM PP variable; drop it before submission. + +```{r cdisc-many-to-one} +o_nca_aucint <- pk.nca(PKNCAdata( + o_conc, o_dose, + intervals = data.frame(start = 0, end = 48, aucint.last = TRUE, aucint.inf.obs = TRUE) +)) +as.data.frame(o_nca_aucint, out_format = "cdisc", filter_requested = TRUE) |> + filter(Subject == "1") |> + select(pknca_parameter, PPTESTCD, PPORRES, PPANMETH) +``` + +Route-dependent parameters resolve to the code for the route recorded in the dose data: `cl.obs` is `CLFO` ("Total CL Obs by F") after an extravascular dose and `CLO` ("Total CL Obs") after an intravascular one. The same applies to the volume and MRT parameters. + +`pknca_cdisc_codes()` (≥ 0.13.0) lists every registered code, one row per route or dense/sparse variant, generated from the parameter registry at call time (so it includes parameters registered by other packages): + +```{r cdisc-codes} +d_codes <- pknca_cdisc_codes() +nrow(d_codes) +d_codes |> + filter(parameter %in% c("auclast", "aucint.last", "aucint.inf.obs", "cl.obs", "count_conc")) +``` + +The `in_ct` column says whether each code is a term of the CDISC PKPARMCD codelist. It is checked against the cdiscdata package (a suggested dependency, ≥ 0.2.0); when cdiscdata is not installed, `in_ct` is `NA` for every row, with a message. Every `"common"` tier parameter has a real PKPARMCD code except `count_conc`, `sparse_auc_se`, and `cl.int.inf.obs`, which have no CDISC PP code; a parameter with no CDISC equivalent keeps a sponsor-defined code. + +### The method column (`PPANMETH`) + +Every `pk.nca()` result has a `PPANMETH` column (≥ 0.13.0) describing the analysis methods used for that parameter: the imputation, the AUC integration method, and, for `aucint` and `aumcint` parameters, whether the calculation was dose-aware and how the region after Tlast was handled (in the example above, `Extrapolation: AUClast` for `aucint.last`, which stops at Tlast, and `Extrapolation: half-life` for `aucint.inf.obs`). A parameter with no method of its own, such as `cmax`, shows only the imputation, or nothing when no imputation was used. When a calculation function sets no method at run time, `PPANMETH` falls back to the parameter's registered `formula_note`. + +### Time point references + +Every row of the CDISC output, not only interval-based parameters, gets `PPSTINT` and `PPENINT` (the interval start and end as ISO 8601 durations, such as `"PT0H"` and `"PT24H"`) relative to the reference named in `PPTPTREF`. The reference is the last dose at or before the interval start (`"LAST DOSE PRIOR TO INTERVAL"`), or the first concentration for a subject without a dose (`"FIRST OBSERVATION"`). `PPENINT` is `NA` for an interval to infinity, as in this single-dose analysis: + +```{r cdisc-time} +df_cdisc |> + select(Subject, start, end, PPTESTCD, PPSTINT, PPENINT, PPTPTREF) |> + head(3) +``` + +When the concentration and dose times are date-times, `PPRFTDTC` gives the reference's date-time, so it no longer has to be derived outside PKNCA. The [Date-Time Data and Time References chapter](datetime.qmd) shows these columns for a multiple-dose study with date-time data. -Custom parameters registered with `add.interval.col()` can declare their own CDISC codes through its `pptestcd_cdisc` and `pptest_cdisc` arguments; a parameter without a declared code keeps its PKNCA name in the CDISC output. See the [Writing Custom Parameter Functions chapter](custom-parameters.qmd). +### Record group identifier (`PPGRPID`) + +`PPGRPID` identifies the rows of one interval: `"..I"`, built from the grouping columns named in `grpid_cols` and the number of the row's interval within each combination of the subject, the analyte, and those columns. Without `grpid_cols` it is just the interval number (`"I01"` above). `grpid_cols` is a named character vector of `PKNCAconc()` grouping columns (other than the subject and analyte), each with the text written before its value; `grpid_numeric` names the columns whose values are whole numbers, such as the period, to be written as that number. Both can be given to `PKNCAdata()`, as the default for every output of one analysis, or to `as.data.frame()`. + +For a two-period crossover (built here by repeating the Theoph data as a second period), with two intervals per period: + +```{r cdisc-grpid} +d_conc_xover <- bind_rows( + as.data.frame(Theoph) |> mutate(Period = 1), + as.data.frame(Theoph) |> mutate(Period = 2) +) +d_dose_xover <- d_conc_xover |> + group_by(Period, Subject) |> + summarise(Dose = Dose[1] * Wt[1], Time = 0, .groups = "drop") + +o_data_xover <- PKNCAdata( + PKNCAconc(d_conc_xover, conc ~ Time | Period + Subject), + PKNCAdose(d_dose_xover, Dose ~ Time | Period + Subject), + intervals = data.frame(start = 0, end = c(24, Inf), auclast = TRUE), + grpid_cols = c(Period = "P"), + grpid_numeric = "Period" +) +as.data.frame(pk.nca(o_data_xover), out_format = "cdisc") |> + filter(Subject == "1") |> + select(Period, Subject, start, end, PPTESTCD, PPSTINT, PPENINT, PPGRPID) +``` + +Within each subject and period, the intervals are numbered from 1 by start and then end, and every parameter of one interval shares the number. The number is written with at least two digits (more when needed) so that the text sorts in time order. `grpid_cols = character()` in `as.data.frame()` gives the interval-only identifier regardless of the `PKNCAdata()` default. + +### Codes for custom parameters + +Custom parameters registered with `add.interval.col()` declare their CDISC codes through its `pptestcd_cdisc` and `pptest_cdisc` arguments. Each is a character string; a list with one element named `route` holding a named list of codes per route (for route-dependent parameters such as CL, VZ, MRT, and VSS); or a list of exactly the elements `dense` and `sparse`. A parameter without a declared code keeps its PKNCA name in the CDISC output. See the [Writing Custom Parameter Functions chapter](custom-parameters.qmd). --- @@ -124,7 +205,7 @@ table(is.na(df$exclude)) df |> filter(!is.na(exclude)) |> head() ``` -Exclusions are set programmatically via the `exclude_nca_*` family of functions (e.g. `exclude_nca_max.aucinf.pext()`, `exclude_nca_min.hl.r.squared()`), or manually via `exclude()`. The rule functions supply their own exclusion reason text (e.g. `exclude_nca_min.hl.r.squared(0.9)` records `r.squared < 0.9`), so no `reason=` argument is needed. For regulatory submissions, include all rows in the PP domain — both included and excluded. Exclusion is typically represented via `PPSTAT`/`PPREASND` (status and reason not done) or a supplemental qualifier; PKNCA's `exclude` column provides the source text for whichever convention your submission uses. Exclusion workflows are covered in the [Post-Processing chapter](postprocessing.qmd) and [vignette](https://humanpred.github.io/pknca/articles/v07-post-processing.html). +Exclusions are set programmatically via the `exclude_nca_*` family of functions (e.g. `exclude_nca_max.aucinf.pext()`, `exclude_nca_min.hl.r.squared()`), or manually via `exclude()`. The rule functions supply their own exclusion reason text (e.g. `exclude_nca_min.hl.r.squared(0.9)` records `r.squared < 0.9`), so no `reason=` argument is needed. For regulatory submissions, include all rows in the PP domain — both included and excluded. Exclusion is typically represented via `PPSTAT`/`PPREASND` (status and reason not done) or a supplemental qualifier; PKNCA's `exclude` column provides the source text for whichever convention your submission uses. Exclusion workflows are covered in the [Post-Processing chapter](postprocessing.qmd) and [vignette](https://humanpred.github.io/pknca/articles/v07-post-processing.html), and data-quality rules such as Tmax coverage and missing samples in the [Data Quality Checks chapter](data-quality.qmd). ```{r exclude-nca-example} # Apply standard exclusion rules and inspect excluded rows @@ -189,9 +270,25 @@ renv::restore() Together, `PKNCA.options()`, `sessionInfo()`, and `renv` provide the three layers of reproducibility documentation expected for regulatory NCA submissions. +### Provenance of results + +PKNCA objects carry a provenance record (a hash of the object, the creation time, and the R and PKNCA versions), and `checkProvenance()` confirms that an object is unchanged since PKNCA created it: + +```{r provenance} +checkProvenance(o_nca) +``` + +A `PKNCAresults` object changed after the run by `filter()`, `mutate()`, `group_by()`, `ungroup()`, the joins, `exclude()`, or `normalize()` no longer vouches for itself (≥ 0.13.0): its provenance becomes a marker such as `"filtered from "` and `checkProvenance()` returns `FALSE`. A call that leaves the object identical keeps its provenance. + +```{r provenance-modified} +checkProvenance(o_nca_excl) +``` + +For a submission, keep the unmodified `PKNCAresults` object with the analysis outputs, so that the reported numbers can be traced to an intact run, and record the exclusions applied to it in the analysis script. + --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame.PKNCAresults()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_*()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame.PKNCAresults()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [pknca_cdisc_codes()](https://humanpred.github.io/pknca/reference/pknca_cdisc_codes.html) · [add.interval.col()](https://humanpred.github.io/pknca/reference/add.interval.col.html) · [checkProvenance()](https://humanpred.github.io/pknca/reference/checkProvenance.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_*()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) ::: diff --git a/user/units.qmd b/user/units.qmd index e3c1978..a88af94 100644 --- a/user/units.qmd +++ b/user/units.qmd @@ -87,7 +87,7 @@ o_conc_col <- PKNCAconc(Theoph_units, conc ~ Time | Subject, This function builds a complete mapping from every NCA parameter to its reporting unit, derived from the four base units you supply. -In PKNCA ≥ 0.12.2, `pknca_units_table()` is an **S3 generic**. The most useful new method is `pknca_units_table.PKNCAdata()`, which extracts units directly from a fully assembled `PKNCAdata` object — useful when units are already embedded in `PKNCAconc` and `PKNCAdose`: +In PKNCA ≥ 0.13.0, `pknca_units_table()` is an **S3 generic**. The most useful new method is `pknca_units_table.PKNCAdata()`, which extracts units directly from a fully assembled `PKNCAdata` object — useful when units are already embedded in `PKNCAconc` and `PKNCAdose`: ```{r} # Units already embedded in the data objects @@ -104,20 +104,56 @@ o_dose_u <- PKNCAdose( ) o_data_u <- PKNCAdata(o_conc_u, o_dose_u) -# Extract units from the PKNCAdata object (≥ 0.12.2) +# Extract units from the PKNCAdata object (≥ 0.13.0) u_from_data <- pknca_units_table(o_data_u) head(u_from_data, 6) ``` +### Built automatically by `PKNCAdata()` (≥ 0.13.0) + +You rarely need to call the `PKNCAdata` method yourself: when `PKNCAdata()` is called without a `units` argument, it builds the same table from the unit arguments of the `PKNCAconc` and `PKNCAdose` objects and stores it in the object. Every result of `pk.nca()` then has a `PPORRESU` column: + +```{r} +identical(o_data_u$units, u_from_data) + +as.data.frame(pk.nca(o_data_u)) |> + filter(subject == "1", PPTESTCD %in% c("auclast", "cmax", "tmax")) |> + select(PPTESTCD, PPORRES, PPORRESU) +``` + +When the unit arguments name columns of the data, the table is stratified by the groups, so that each analyte or specimen can have its own units. A unit column must have one value within each concentration group; a value that changes within a group (including `NA` for some rows) is the `pknca_error_units_nonuniform_groups` error naming the groups: + +```{r} +Theoph_mixed <- Theoph_df |> mutate(conc_unit = ifelse(Time > 10, "ng/mL", "mg/L")) +tryCatch( + PKNCAdata(PKNCAconc(Theoph_mixed, conc ~ Time | Subject, concu = "conc_unit", timeu = "h"), + o_dose), + # Show the first lines of the message, which lists every group + error = function(e) cat(head(strsplit(conditionMessage(e), "\n")[[1]], 3), sep = "\n") +) +``` + +Because the table is built from whatever units were given, give all of the base units that the requested parameters need. With only `timeu` given, the table has units for times but not for concentrations, and `pk.nca()` stops for the concentration-based parameters (see [Allowing partial units](#partial-units) below): + +```{r} +o_data_timeu_only <- PKNCAdata( + PKNCAconc(Theoph_df, conc ~ Time | Subject, timeu = "h"), + PKNCAdose(d_dose, amt ~ Time | Subject), + intervals = data.frame(start = 0, end = Inf, cmax = TRUE, tmax = TRUE) +) +tryCatch(pk.nca(o_data_timeu_only), error = function(e) cat(conditionMessage(e), "\n")) +``` + ### Arguments (default method) ```{r} args(getS3method("pknca_units_table", "default")) ``` -All four of `concu`, `doseu`, `amountu`, `timeu` are required. PKNCA derives -composite units automatically (e.g. `auclast` gets `h*mg/L` from `timeu="h"` -and `concu="mg/L"`). +PKNCA derives composite units automatically (e.g. `auclast` gets `h*mg/L` +from `timeu="h"` and `concu="mg/L"`). Each base unit is optional: a parameter +whose unit needs a base unit that was not given (for example, `ae` without +`amountu`) gets an `NA` unit in the table. ### Output columns @@ -263,7 +299,7 @@ as.data.frame(res_custom) |> --- -## Allowing partial units (≥ 0.12.0) +## Allowing partial units (≥ 0.12.0) {#partial-units} `pknca_units_table()` always returns a complete table, but a hand-edited or reused units table can end up missing entries for some of the parameters an @@ -279,7 +315,7 @@ o_data_partial <- PKNCAdata(o_conc2, o_dose2, units = u_partial) # The missing entries are an error when the calculation runs tryCatch( pk.nca(o_data_partial), - error = function(e) cat("Error:", conditionMessage(e), "\n") + error = function(e) conditionMessage(e) ) ``` @@ -309,22 +345,48 @@ theirs. --- +## The time unit and the dosing interval (≥ 0.13.0) + +The time unit of the concentration data (`timeu` in `PKNCAconc()`) is not only a label. `PKNCAdata()` uses it when it detects the dosing interval (tau) for automatically generated intervals, and `pk.nca()` uses it for the tau of multiple-dose parameters: dose times are compared with nominal dosing intervals (every 4 hours to every 12 weeks) in that unit. **Times without a unit are taken to be hours.** Data recorded in minutes or days without saying so are therefore compared with intervals in hours. For once-daily doses recorded in minutes, the 1440-minute spacing is read as 1440 hours and snapped to the nearest nominal interval, every 8 weeks: + +```{r} +dose_times_min <- c(0, 1440, 2880, 4320) +find.dose.regimen(dose_times_min)[, c("interval", "label")] +find.dose.regimen(dose_times_min, timeu = "min")[, c("interval", "label")] +``` + +Give `timeu` to `PKNCAconc()` whenever the times are not in hours (together with the other base units; see above). An interval that matches no nominal interval gives the `pknca_warning_tau_not_nominal` warning, and without the units package a `timeu` other than hours gives the `pknca_message_tau_units_fallback` message and leaves the interval to the dose times. Interval and regimen detection are covered in the [Automatic Intervals and Dosing Regimens chapter](auto-intervals.qmd). + +With date-time (`POSIXct`) concentration and dose times, the analysis unit is `timeu_pref` when given, otherwise `timeu`, otherwise hours, and the times are converted directly into it; see the [Date-Time Data and Time References chapter](datetime.qmd). + +--- + +## Units of secondary parameters (≥ 0.13.0) + +A secondary parameter, such as a renal clearance or a metabolite ratio, is calculated from values of two different profiles, and its unit is composed from the units of the values it was actually calculated from. A renal clearance dividing a urine amount in `mg` by a plasma AUC in `hr*ng/mL` is reported in `mg/(hr*ng/mL)`, not in units built from the urine group alone. The composition happens in the units table, which gains `_ref` columns and one row per pair of groups, and results gain the same columns naming the group each secondary value took its reference from. + +No value is converted or made `NA` for unit reasons. Where the two sides of a quotient are convertible (a `ratio.*` of two analytes reported in `ng/mL` and `mg/L`), the raw quotient is standardized through `PPSTRES` to a `PPSTRESU` of `"fraction"`; where they are not (a bioavailability between a dose in `mg` and a dose in `mg/kg`), the composite units stand. An analysis with such a quotient therefore gains `PPSTRES` and `PPSTRESU` columns, which equal `PPORRES` and `PPORRESU` for every other parameter. Worked examples are in the [Secondary Parameters chapter](secondary-parameters.qmd). + +--- + ## Summary | Goal | How | |------|-----| | Assign units at data creation | `concu`, `timeu`, `doseu` args in `PKNCAconc()` / `PKNCAdose()` | | Build a complete unit table | `pknca_units_table(concu, doseu, amountu, timeu)` | +| Build the units table from the unit arguments (≥ 0.13.0) | Automatic in `PKNCAdata()` when `units` is not given | | Pass units to the analysis | `PKNCAdata(..., units = u)` | | Convert to preferred output units | Add `concu_pref`, `timeu_pref`, etc. to `pknca_units_table()` | | Custom / non-metric conversions | `conversions` argument in `pknca_units_table()` | | Inspect units in results | `as.data.frame(res)$PPORRESU` | -| Extract units from a PKNCAdata object (≥ 0.12.2) | `pknca_units_table(o_data)` | +| Extract units from a PKNCAdata object (≥ 0.13.0) | `pknca_units_table(o_data)` | +| Match dosing intervals in the right unit (≥ 0.13.0) | `timeu` in `PKNCAconc()` (unitless times are hours) | | Allow some parameters without units (≥ 0.12.0) | `PKNCA.options(allow_partial_missing_units = TRUE)` | --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pknca_units_table()](https://humanpred.github.io/pknca/reference/pknca_units_table.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pknca_units_table()](https://humanpred.github.io/pknca/reference/pknca_units_table.html) · [find.dose.regimen()](https://humanpred.github.io/pknca/reference/find.dose.regimen.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) ::: From 0bbcca7fca4680dcf749056bc095a13b3648568d Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 10 Oct 2026 02:16:39 +0000 Subject: [PATCH 07/12] docs: update half-life, AUC, interpolation, utilities, custom parameters, validation, and developer chapters Co-Authored-By: Claude Opus 5.5 --- dev/architecture.qmd | 103 ++++++++++---- dev/function-deps.qmd | 21 ++- user/auc-methods.qmd | 130 ++++++++++++++---- user/custom-parameters.qmd | 219 +++++++++++++++++++++++------- user/dose-aware-interpolation.qmd | 43 +++++- user/halflife.qmd | 150 +++++++++++++++----- user/utilities.qmd | 93 +++++++++++-- user/validation.qmd | 30 +++- 8 files changed, 636 insertions(+), 153 deletions(-) diff --git a/dev/architecture.qmd b/dev/architecture.qmd index 9a26712..eda34f1 100644 --- a/dev/architecture.qmd +++ b/dev/architecture.qmd @@ -19,27 +19,32 @@ classDiagram formula: conc~time|groups subject: column name sparse: logical - units: concu/timeu - exclude / exclude_half.life + units: concu/amountu/timeu + exclude / exclude_half.life / include_half.life + lloq, time.nominal + volume, duration (only if given) } class PKNCAdose { data: data.frame formula: dose~time|groups route: intravas./extravas. duration / rate - units: doseu/timeu + units: doseu + time.nominal } class PKNCAdata { conc: PKNCAconc dose: PKNCAdose intervals: data.frame - impute: method string + impute: method string or column units: pknca_units_table() options: named list + group_ref, grpid_cols } class PKNCAresults { - result: data.frame + result: data.frame incl. PPANMETH data: PKNCAdata + exclude: column name } PKNCAconc --> PKNCAdata @@ -47,6 +52,29 @@ classDiagram PKNCAdata --> PKNCAresults ``` +## How a calculation runs + +`PKNCAdata()` assembles the inputs and `pk.nca()` does the work. At a high level (function names are from `R/class-PKNCAdata.R` and `R/pk.calc.all.R`): + +```{mermaid} +flowchart TD + A["PKNCAdata()"] --> A1["Intervals: given, or generated per group by
choose.auc.intervals() (auto.interval.method)"] + A1 --> A2["set_intervals() / check.interval.specification()
validate the intervals"] + A2 --> A3["Units table: given, or pknca_units_table()"] + A3 --> B["pk.nca()"] + B --> B1["assert_intervals(); convert date-time
times and intervals to numbers"] + B1 --> B2["Merge the object's options into PKNCA.options()"] + B2 --> B3["Expand reference intervals for
secondary parameters"] + B3 --> B4["full_join_PKNCAdata(): split concentration,
dose, and interval data by group"] + B4 --> C["pk.nca.intervals(): one call per group"] + C --> D["pk.nca.interval(): one call per interval;
dense and sparse parameters in one pass"] + D --> E["Gather results (dense, then sparse)"] + E --> F["pk_nca_secondary(): parameters that
read a reference interval"] + F --> G["PKNCAresults()"] +``` + +Inside `pk.nca.interval()`, the requested parameters are expanded with their `depends`, put in dependency order, and calculated one at a time. For each parameter, the arguments of its calculation function are filled from the interval data, earlier results, constants, or the options (see [formalsmap](#formalsmap)); points named by `include_half.life`/`exclude_half.life` are applied before `half.life` is calculated; and the result's `exclude` and `"method"` attributes become the `exclude` and `PPANMETH` columns. A function that returns a data frame contributes one result row per column. The calculation functions themselves never see a `PKNCAdata` object, so they can be called directly (see [Utility Functions](../user/utilities.qmd)). + ## The interval column system The parameter registry is the core extensibility mechanism. Every NCA parameter is registered as an **interval column** via `add.interval.col()`. @@ -61,28 +89,37 @@ Each entry contains: | Field | Purpose | |---|---| -| `FUN` | Name of the function that computes this parameter (character string, or `NA` for parameters populated by another function's multi-column result) | +| `FUN` | Name of the function that computes this parameter (character string, or `NA` for parameters populated by another function's multi-column result or calculated only by `FUN_sparse`) | +| `FUN_sparse` | Name of a sparse-PK estimator, or `NA` (≥ 0.13.0) | | `values` | Allowed values in the interval column (typically `c(FALSE, TRUE)`) | | `unit_type` | Unit category used for unit assignment and conversion | | `pretty_name` | Human-readable name used in summary tables | +| `desc` | Human-readable description (40 characters or fewer for SDTM; longer warns) | +| `formalsmap` | Maps function arguments to interval/group/options data, other parameters, constants, or reference-interval values | +| `formalsmap_sparse` | The `formalsmap` for `FUN_sparse` (≥ 0.13.0) | | `depends` | Character vector of parameters that must be computed first | -| `desc` | Human-readable description | -| `sparse` | Whether this parameter works for sparse PK | -| `formalsmap` | Maps function arguments to interval/group/options data | | `datatype` | Calculation type; only `"interval"` is currently supported | -| `pptestcd_cdisc` | CDISC PPTESTCD code (≥ 0.12.2) | -| `pptest_cdisc` | CDISC PPTEST label (≥ 0.12.2) | +| `pptestcd_cdisc` | CDISC PPTESTCD code: a string, a route-keyed list, or a dense/sparse list (≥ 0.13.0) | +| `pptest_cdisc` | CDISC PPTEST label, in the same forms (≥ 0.13.0) | +| `formula`, `formula_note` | LaTeX formula and note for documentation; `formula_note` is the `PPANMETH` fallback (≥ 0.13.0) | +| `tier`, `selection` | Classification for automatic interval selection (≥ 0.13.0) | + +The registry has no `sparse` field (≥ 0.13.0): `add.interval.col(sparse = TRUE)` is retired and is an error. Whether a parameter needs sparse data is derived from the registration (a `FUN_sparse` and no `FUN`) and reported by `pknca_parameter_table()`, together with each parameter's concept, tier, and route and dosing contexts. After `pk.nca()` first uses an entry, it can also carry internal cached fields (`requires_*` and `arg_spec`) that are not part of the registration. ## formalsmap -`formalsmap` is how PKNCA passes the right data to each parameter function. Arguments can be sourced from four places: +`formalsmap` is how PKNCA passes the right data to each parameter function. Arguments can be sourced from these places: | Source type | Example | What it provides | |---|---|---| -| Interval column | `conc`, `time` | Concentrations/times for the current interval | -| Other parameters | `auclast = "auclast"` | A previously computed NCA result | +| Interval data | `conc`, `time`, `dose`, `time.dose`, `route` | Data for the current interval (times relative to the interval start) | +| Group data | `conc.group`, `time.group` | Data for the whole group | +| Sparse data (≥ 0.13.0) | `conc.sparse`, `time.sparse`, `subject` | The pooled individual samples (`NULL` for dense data) | +| Other parameters | `auc = "auclast"` | A previously computed NCA result | | Interval bounds | `start`, `end` | The interval start/end times | | Options | `options` | The full analysis options list | +| Constants (≥ 0.13.0) | `auc.type = I("AUCall")` | The value itself | +| Reference interval (≥ 0.13.0) | `auc = pknca_ref("auclast")` | A parameter's value in the reference interval; makes the parameter secondary | Individual option names (e.g. `auc.method`) cannot be mapped through `formalsmap`; only the whole `options` list can, and parameter functions read specific settings from it via `PKNCA.choose.option()`. @@ -109,7 +146,7 @@ add.interval.col( str(get.interval.cols()[["demo.ratio"]]) ``` -For the full walkthrough — parameters computed from raw concentration-time data, multi-output functions, and custom summary statistics — see the [Writing Custom Parameters chapter](../user/custom-parameters.qmd) and the package vignette [Writing PKNCA Parameter Functions](https://humanpred.github.io/pknca/articles/v80-writing-parameter-functions.html). +For the full walkthrough — parameters computed from raw concentration-time data, constants, multi-output functions, parameters that read a reference interval, sparse-only parameters, and custom summary statistics — see the [Writing Custom Parameters chapter](../user/custom-parameters.qmd) and the package vignette [Writing PKNCA Parameter Functions](https://humanpred.github.io/pknca/articles/v80-writing-parameter-functions.html). ## Options system @@ -132,19 +169,32 @@ PKNCA.options(min.hl.points = old_val) PKNCA.options("min.hl.points") ``` -> Prefer passing `options = list(...)` to `PKNCAdata()` over mutating global options. Global changes persist across analyses in the same session. +> Prefer passing `options = list(...)` to `PKNCAdata()` over mutating global options. Global changes persist across analyses in the same session. `pk.nca()` merges the object's options into the global ones at the start of each run, and `PKNCA_options_defaults()` (≥ 0.13.0) returns the package defaults without resetting anything. ## Sparse PK -Sparse PK (one or two samples per subject, common in preclinical or pediatric studies) is handled via a parallel code path: +Sparse PK (one or two samples per subject, common in preclinical or pediatric studies) shares the dense code path (≥ 0.13.0): -- Set `sparse = TRUE` in `PKNCAconc()` -- Concentrations are pooled across subjects per timepoint -- Mean concentration-time profile is computed, then NCA is run on that profile -- Supported parameters are flagged with `sparse = TRUE` in their `add.interval.col()` registration +- Set `sparse = TRUE` in `PKNCAconc()`. +- Each group reaches `pk.nca.interval()` with two representations of the same samples: the arithmetic-mean profile (as `conc`/`time`) and the pooled individual samples (as `conc.sparse`/`time.sparse`, with `subject`). Dense and sparse parameters are calculated in one pass. +- A parameter with a `FUN_sparse` (such as `auclast`, whose sparse estimator also returns `auclast_se` and `auclast_df`) is calculated from the pooled samples; one without falls back to `FUN` on the mean profile (half-life, for example). A parameter with `FUN = NA` and a `FUN_sparse` is sparse-only, and requesting it for dense data is an error. +- `pknca_parameter_table()$sparse` reports which parameters need sparse data. For the full workflow, see the [Sparse NCA chapter](../user/sparse.qmd) and the vignette [Sparse NCA Calculations](https://humanpred.github.io/pknca/articles/v04-sparse.html). +## Conditions and messages + +Errors, warnings, and messages are signaled with `rlang` and carry classes named `pknca_error_*`, `pknca_warning_*`, and `pknca_message_*` (≥ 0.13.0), so code can catch them by class rather than by message text. Several older classes were renamed in 0.13.0 (for example, `pknca_conc_none` is now `pknca_warning_no_concentration`). Input validation uses `checkmate`. With `verbose = TRUE`, `pk.nca()` reports one "Starting PK NCA calculations." message (`pknca_message_pk_start`). + +```{r} +# Catch a PKNCA error by its class +tryCatch( + add.interval.col(name = "x_ref", FUN = "pk.calc.cmax", unit_type = "conc", + pretty_name = "x", desc = "Reserved name"), + pknca_error_param_name_reserved = function(e) class(e)[1] +) +``` + ## Key functions All of these are exported except where marked internal: @@ -152,11 +202,14 @@ All of these are exported except where marked internal: | Function | Purpose | |---|---| | `get.interval.cols()` | Return full parameter registry | +| `pknca_parameter_table()` | Classification of every parameter (concept, tier, sparse, secondary, contexts) | +| `get.parameter.deps()` | Parameters calculated from a parameter, or with `recursive = TRUE` what it is calculated from | | `PKNCA:::sort_interval_cols()` | Topological sort → execution order (internal, unexported) | -| `pk.nca.interval()` | Compute all parameters for one interval/group | +| `assert_intervals()` | Validate an interval specification against a `PKNCAdata` object | +| `pk.nca.interval()` | Compute all parameters for one interval/group; takes `conc.sparse`, `time.sparse`, `conc.sparse.group`, and `time.sparse.group` for sparse data (its `sparse` argument was removed in 0.13.0) | | `pknca_units_table()` | Build unit assignment/conversion table | -| `PKNCA_impute_method_*()` | Built-in imputation methods | -| `pk.calc.auc.last()` | Core AUC calculation (trapezoidal) | +| `PKNCA_impute_method_*()` | Built-in imputation methods (listed by `pknca_impute_methods()`) | +| `pk.calc.auc.last()` | Core AUC calculation; returns a `"method"` attribute | | `pk.calc.half.life()` | λz regression with best-fit selection | --- @@ -167,5 +220,5 @@ All of these are exported except where marked internal: --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [add.interval.col()](https://humanpred.github.io/pknca/reference/add.interval.col.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [pknca_units_table()](https://humanpred.github.io/pknca/reference/pknca_units_table.html) · [pk.nca.interval()](https://humanpred.github.io/pknca/reference/pk.nca.interval.html) · [PKNCA.choose.option()](https://humanpred.github.io/pknca/reference/PKNCA.choose.option.html) · [pk.calc.half.life()](https://humanpred.github.io/pknca/reference/pk.calc.half.life.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [add.interval.col()](https://humanpred.github.io/pknca/reference/add.interval.col.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [pknca_units_table()](https://humanpred.github.io/pknca/reference/pknca_units_table.html) · [pk.nca.interval()](https://humanpred.github.io/pknca/reference/pk.nca.interval.html) · [PKNCA.choose.option()](https://humanpred.github.io/pknca/reference/PKNCA.choose.option.html) · [pk.calc.half.life()](https://humanpred.github.io/pknca/reference/pk.calc.half.life.html) · [pknca_parameter_table()](https://humanpred.github.io/pknca/reference/pknca_parameter_table.html) · [get.parameter.deps()](https://humanpred.github.io/pknca/reference/get.parameter.deps.html) · [assert_intervals()](https://humanpred.github.io/pknca/reference/assert_intervals.html) · [choose.auc.intervals()](https://humanpred.github.io/pknca/reference/choose.auc.intervals.html) · [check.interval.specification()](https://humanpred.github.io/pknca/reference/check.interval.specification.html) · [PKNCA_options_defaults()](https://humanpred.github.io/pknca/reference/PKNCA_options_defaults.html) · [pknca_impute_methods()](https://humanpred.github.io/pknca/reference/pknca_impute_methods.html) ::: diff --git a/dev/function-deps.qmd b/dev/function-deps.qmd index 5ad5cb9..eac796d 100644 --- a/dev/function-deps.qmd +++ b/dev/function-deps.qmd @@ -9,7 +9,7 @@ library(dplyr) conflicted::conflicts_prefer(dplyr::filter, dplyr::select, .quiet = TRUE) ``` -PKNCA resolves parameter dependencies automatically: when you request `aucinf.obs`, PKNCA internally ensures `lambda.z` is computed first because `aucinf.obs` depends on it. These dependencies are declared at registration time — every NCA parameter is registered via `add.interval.col()` with a `depends` vector naming the parameters that must be computed before it, and the full registry is accessible via `get.interval.cols()`. The graph below makes the whole dependency network explicit. +PKNCA resolves parameter dependencies automatically: when you request `aucinf.obs`, PKNCA internally ensures `lambda.z` is computed first because `aucinf.obs` depends on it. These dependencies are declared at registration time — every NCA parameter is registered via `add.interval.col()` with a `depends` vector naming the parameters that must be computed before it, and the full registry is accessible via `get.interval.cols()`. The graph below makes the whole dependency network explicit. It shows `depends` only: a secondary parameter's inputs from its reference interval (declared with `pknca_ref()` in its `formalsmap`, such as the `auclast` that `ratio.auclast` reads from the reference interval) are not edges here. ## Interactive dependency graph @@ -33,10 +33,10 @@ categorize <- function(name) { case_when( grepl("^auc", name) ~ "AUC", grepl("^lambda|half\\.life|r\\.squared", name) ~ "Half-life / λz", - grepl("^cl\\.|^vz\\.|^vss", name) ~ "CL / Volume", + grepl("^cl\\.|^vz\\.|^vss|^kel", name) ~ "CL / Volume", grepl("^mrt", name) ~ "MRT", grepl("^c(max|min|0|last|first)|^t(max|min|last|first|lag)|^clast", name) ~ "Cmax / Tmax", - grepl("^f$|bioavail|^ae|^fe|^clr|^vd", name) ~ "Bioavailability / Renal", + grepl("^f\\.|^ratio\\.|^ae|^fe|^clr|^er|^volpk", name) ~ "Secondary / Excretion", grepl("^tss", name) ~ "Steady-state", TRUE ~ "Other" ) @@ -48,7 +48,7 @@ category_colors <- c( "CL / Volume" = "#e15759", "MRT" = "#76b7b2", "Cmax / Tmax" = "#59a14f", - "Bioavailability / Renal"= "#edc948", + "Secondary / Excretion" = "#edc948", "Steady-state" = "#b07aa1", "Other" = "#aaaaaa" ) @@ -113,9 +113,9 @@ knitr::kable(dep_table, col.names = c("Parameter", "Depends on")) ## Parameter-to-function mapping -Every interval parameter is computed by a function registered alongside it — usually named `pk.calc.*()` — and the registry entry returned by `get.interval.cols()` records that function name in its `$FUN` field, together with the parameter's unit type and description. Where `$FUN` is `NA`, the parameter has no dedicated function: it is either produced as part of another function's output (for example, `r.squared` and `lambda.z.time.first` are returned by the same half-life fit that produces `half.life`) or it is an interval boundary (`start`, `end`). +Every interval parameter is computed by a function registered alongside it — usually named `pk.calc.*()` — and the registry entry returned by `get.interval.cols()` records that function name in its `$FUN` field, together with the parameter's unit type and description. Where `$FUN` is `NA`, the parameter has no dedicated dense function: it is produced as part of another function's output (for example, `r.squared` and `lambda.z.time.first` are returned by the same half-life fit that produces `half.life`, and `auclast_se` by the sparse estimator of `auclast`), it is calculated only by a sparse estimator in `$FUN_sparse`, or it is an interval boundary (`start`, `end`). Parameters with a sparse-specific estimator (≥ 0.13.0) record it in `$FUN_sparse`, shown in its own column below. -The table below is the complete index of the calculation API: every registered parameter, the function that computes it, its unit type, and its description. To add your own parameter to this registry, see the [Writing Custom Parameters chapter](../user/custom-parameters.qmd) and the vignette [Writing PKNCA Parameter Functions](https://humanpred.github.io/pknca/articles/v80-writing-parameter-functions.html). +The table below is the complete index of the calculation API: every registered parameter, the functions that compute it, its unit type, and its description. To add your own parameter to this registry, see the [Writing Custom Parameters chapter](../user/custom-parameters.qmd) and the vignette [Writing PKNCA Parameter Functions](https://humanpred.github.io/pknca/articles/v80-writing-parameter-functions.html). ```{r fun-map} cols <- get.interval.cols() @@ -125,6 +125,7 @@ fun_map <- data.frame( # $FUN is a character function name, or logical NA when the parameter has no # dedicated function; as.character() coerces both to character fun = vapply(cols, function(x) as.character(x$FUN), character(1)), + fun_sparse = vapply(cols, function(x) as.character(x$FUN_sparse), character(1)), unit_type = vapply(cols, function(x) x$unit_type, character(1)), desc = vapply(cols, function(x) x$desc, character(1)), stringsAsFactors = FALSE @@ -134,7 +135,7 @@ fun_map <- data.frame( knitr::kable( fun_map, row.names = FALSE, - col.names = c("Parameter", "Function", "Unit type", "Description") + col.names = c("Parameter", "Function", "Sparse function", "Unit type", "Description") ) ``` @@ -172,6 +173,12 @@ all_deps |> get.parameter.deps("auclast") ``` +With `recursive = TRUE` (≥ 0.13.0) it goes the other way, following each dependency back to the raw inputs (`conc`, `time`, `dose`, ...) and the arguments of the calculation functions along the way: + +```{r parameter-deps-recursive} +get.parameter.deps("cl.last", recursive = TRUE) +``` + --- diff --git a/user/auc-methods.qmd b/user/auc-methods.qmd index 40fc0cc..d5c87f9 100644 --- a/user/auc-methods.qmd +++ b/user/auc-methods.qmd @@ -29,7 +29,11 @@ PKNCA offers four families of AUC — AUClast, AUCall, AUCinf (in observed and p | `aucall` | Last measured timepoint | Post-last-measurable BLQs treated as 0 | When you want to include the BLQ tail | | `aucinf.obs` | Extrapolated to ∞ (using observed Clast) | — | When half-life is reliable | | `aucinf.pred` | Extrapolated to ∞ (using predicted Clast from λz fit) | — | When Clast is noisy | -| `aucint.*` | Any user-defined time window | — | Partial AUC, bioequivalence | +| `aucint.*` | Any user-defined time window (dose-aware, ≥ 0.13.0) | Follows the `.last`, `.all`, or `.inf` suffix | Partial AUC, bioequivalence | + +Two related families use the same suffixes: `auciv*` (IV bolus AUCs that back-extrapolate C0; see the [IV dosing chapter](intravascular.qmd)) and, for sparse sampling, the `_se`/`_df` companions such as `auclast_se` (see the [sparse NCA chapter](sparse.qmd)). + +Each AUC row in the `pk.nca()` results records its integration method in the `PPANMETH` column (≥ 0.13.0), for example `"AUC: lin up/log down"`. A direct call such as `pk.calc.auc.last()` returns its value with the same text in a `"method"` attribute; use `as.numeric()` to drop it before comparing the value to a bare number. For a worked example computing these AUC flavors on a full dataset, see the [AUC Calculation with PKNCA vignette](https://humanpred.github.io/pknca/articles/v05-auc-calculation-with-PKNCA.html). @@ -87,34 +91,34 @@ as.data.frame(o_nca_partial) |> **All partial AUC variants:** -| Parameter | End concentration | Dose-aware boundary | Extrapolation | -|---|---|---|---| -| `aucint.last` | Last observed in window | No | None | -| `aucint.all` | Last observed, BLQs as 0 | No | None | -| `aucint.inf.obs` | Extrapolated to ∞, observed Clast | No | λz-based tail | -| `aucint.inf.pred` | Extrapolated to ∞, predicted Clast | No | λz-based tail | -| `aucint.last.dose` | Same as `.last` | **Yes** | None | -| `aucint.all.dose` | Same as `.all` | **Yes** | None | -| `aucint.inf.obs.dose` | Same as `.inf.obs` | **Yes** | λz-based tail | -| `aucint.inf.pred.dose` | Same as `.inf.pred` | **Yes** | λz-based tail | +| Parameter | Region after Tlast (within the window) | Uses λz | +|---|---|---| +| `aucint.last` | Contributes zero, as for `auclast` | No | +| `aucint.all` | Triangle from Clast down to the first BLQ sample, as for `aucall` | No | +| `aucint.inf.obs` | Extrapolated with λz from the observed Clast | Only when the window extends past Tlast | +| `aucint.inf.pred` | Extrapolated with λz from the predicted Clast | Only when the window extends past Tlast | -> **Note:** `aucint.inf.pred` requires that `lambda.z` has been estimated (i.e. that the terminal slope is calculable from the data window). +Every `aucint.*` parameter is **dose-aware** (≥ 0.13.0) whenever dosing data are given: it uses dose-aware interpolation (`interp.extrap.conc.dose()`) at the interval start and end, and it uses the route and duration of each dose, so an IV bolus is not treated as an extravascular dose. Three rules follow from that: -The `.dose` variants use **dose-aware interpolation** (`interp.extrap.conc.dose()`) at the interval start and end. This matters when the interval boundary coincides with a dose time — the `.dose` variant correctly handles the before/after concentration jump. The full decision rules are in the [dose-aware interpolation and extrapolation vignette](https://humanpred.github.io/pknca/articles/v21-methods-for-dose-aware-interpolation-and-extrapolation.html). +- **The profile ends at the next dose.** The profile being integrated ends at the first dose at or after `end`, so a concentration measured after that dose is never interpolated back into the window. When the window covers a whole dosing interval, `aucint.last` and `aucint.all` equal `auclast` and `aucall`. +- **Only `start` and `end` are estimated.** A dose *inside* the window is integrated across using the concentrations measured on either side of it; no concentration is imputed at that dose. +- **λz is used only when it is needed.** A window that ends at or before Tlast is interpolated throughout, so `aucint.inf.obs` and `aucint.inf.pred` are reported, and are not excluded with the half-life, even when λz could not be estimated. When the window extends past Tlast and λz cannot be estimated, the `.inf` forms fall back to the `aucint.all` rule for a finite window. + +When no dosing data are given, the calculation falls back to plain interpolation across the whole profile (`interp.extrap.conc()`). The `PPANMETH` column of the results (≥ 0.13.0) says which happened ("Interpolation: dose-aware" or "Interpolation: not dose-aware (no dosing data)") and how the region after Tlast was handled ("Extrapolation: none", "AUClast", "AUCall", or "half-life"). + +> **Retired names:** the `.dose` variants (`aucint.last.dose`, `aucint.all.dose`, `aucint.inf.obs.dose`, `aucint.inf.pred.dose`, and the matching `aumcint.*.dose` names) no longer exist because every `aucint`/`aumcint` parameter is now dose-aware. Requesting one is an error that names the parameter to use instead (for example, "'aucint.last.dose' is now named 'aucint.last'"). + +The full decision rules for each combination of doses and samples are in the [dose-aware interpolation and extrapolation vignette](https://humanpred.github.io/pknca/articles/v21-methods-for-dose-aware-interpolation-and-extrapolation.html). ```{r} # All partial AUC variants over a single window all_partial <- data.frame( - start = 0, - end = 8, - aucint.last = TRUE, - aucint.all = TRUE, - aucint.inf.obs = TRUE, - aucint.inf.pred = TRUE, - aucint.last.dose = TRUE, - aucint.all.dose = TRUE, - aucint.inf.obs.dose = TRUE, - aucint.inf.pred.dose = TRUE + start = 0, + end = 8, + aucint.last = TRUE, + aucint.all = TRUE, + aucint.inf.obs = TRUE, + aucint.inf.pred = TRUE ) o_nca_all_partial <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = all_partial)) @@ -123,27 +127,95 @@ as.data.frame(o_nca_all_partial) |> select(PPTESTCD, PPORRES) ``` +The 0–8 h window ends before subject 1's Tlast (24.37 h), so the four variants are interpolated throughout and agree. + +### Why dose-awareness matters {#why-dose-awareness-matters} + +The example below is a two-dose oral profile (doses at 0 and 12 h) whose 12-hour trough sample is missing, so the window 0–12 h has to end somewhere between the 8-hour sample and the post-dose 12.5-hour sample. + +```{r} +d_md <- data.frame( + subject = "A", + time = c(0, 0.5, 1, 2, 4, 6, 8, 12.5, 13, 14, 16, 20, 24), + conc = c(0, 2.1, 3.6, 4.4, 3.5, 2.6, 2.0, 3.2, 4.6, 5.3, 4.2, 2.7, 1.7) +) +d_md_dose <- data.frame(subject = "A", time = c(0, 12), dose = 100) + +o_conc_md <- PKNCAconc(d_md, conc ~ time | subject) +o_dose_md <- PKNCAdose(d_md_dose, dose ~ time | subject) + +window_0_12 <- data.frame( + start = 0, + end = 12, + aucint.last = TRUE, + aucint.inf.obs = TRUE +) + +# With dosing data: dose-aware +o_nca_aware <- pk.nca(PKNCAdata(o_conc_md, o_dose_md, intervals = window_0_12)) +# Without dosing data: interpolation across the whole profile +o_nca_unaware <- pk.nca(PKNCAdata(o_conc_md, intervals = window_0_12)) + +d_compare <- + bind_rows( + as.data.frame(o_nca_aware) |> mutate(dosing = "given"), + as.data.frame(o_nca_unaware) |> mutate(dosing = "not given") + ) |> + filter(PPTESTCD %in% c("aucint.last", "aucint.inf.obs")) + +d_compare |> select(dosing, PPTESTCD, PPORRES) +# The method column records how each value was calculated +d_compare$PPANMETH +``` + +With the doses given, `aucint.last` stops at Tlast (8 h; 24.4), and `aucint.inf.obs` extrapolates from 8 to 12 h along the terminal slope (30.6). Without the doses, PKNCA cannot know that the 12.5-hour sample follows a new dose, so both parameters interpolate from the 8-hour sample up toward it and overestimate the area (34.6). + +### Interval AUMC (`aumcint.*`) (≥ 0.13.0) {#interval-aumc} + +The `aumcint.*` family mirrors `aucint.*` for the first-moment curve, with the same dose-aware rules: `aumcint.last`, `aumcint.all`, `aumcint.inf.obs`, and `aumcint.inf.pred`. + +```{r} +# The two-dose profile above, with the missing 12-hour trough +aumcint_window <- data.frame( + start = 0, + end = 12, + aucint.last = TRUE, + aumcint.last = TRUE, + aumcint.inf.obs = TRUE +) + +o_nca_aumcint <- pk.nca(PKNCAdata(o_conc_md, o_dose_md, intervals = aumcint_window)) +as.data.frame(o_nca_aumcint) |> + filter(grepl("^au.*int", PPTESTCD)) |> + select(start, end, PPTESTCD, PPORRES) +``` + +As with the AUC, `aumcint.last` stops at Tlast (8 h) and `aumcint.inf.obs` extrapolates the moment curve to 12 h along the terminal slope. The interval MRT and IV forms of these parameters (`aumcivint.*` and the rest of the `aumciv*` family) are covered in the [IV dosing chapter](intravascular.qmd). + --- ## Concentration interpolation at interval boundaries -When a requested interval boundary (e.g. `end = 5`) falls between two observed timepoints, PKNCA interpolates the concentration at that boundary before computing AUC — handled internally by `interp.extrap.conc()`. +When a requested interval boundary (e.g. `end = 5`) falls between two observed timepoints, only the `aucint.*` (and `aumcint.*`) parameters estimate the concentration at that boundary — handled internally by `interp.extrap.conc()`, or by `interp.extrap.conc.dose()` when dosing data are given. `auclast` and the other non-interval AUCs do **not** interpolate: they integrate only the samples inside the interval, up to the last measurable one. The interpolation method matches the AUC method: + - `"linear"` → linear interpolation - `"lin-log"` → linear interpolation before Tmax, log-linear after Tmax - `"lin up/log down"` → log-linear interpolation on the descending phase ```{r} # end = 5 falls between observed samples t = 3.82 and t = 5.10 for subject 1 -interp_interval <- data.frame(start = 0, end = 5, auclast = TRUE) +interp_interval <- data.frame(start = 0, end = 5, auclast = TRUE, aucint.last = TRUE) o_nca_interp <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = interp_interval)) as.data.frame(o_nca_interp) |> - filter(PPTESTCD == "auclast") |> + filter(PPTESTCD %in% c("auclast", "aucint.last")) |> select(start, end, PPTESTCD, PPORRES) ``` -For boundaries that coincide with a dose time, the `.dose` variants above use dose-aware interpolation instead — see the [Dose-aware interpolation chapter](dose-aware-interpolation.qmd). +`auclast` stops at the 3.82-hour sample (32.1), while `aucint.last` adds the area from 3.82 h to the interpolated concentration at 5 h (42.1). + +When dosing data are given, the `aucint.*` and `aumcint.*` parameters estimate their boundaries with dose-aware interpolation instead, so a boundary at a dose time takes the concentration from the correct side of the dose — see the [Dose-aware interpolation chapter](dose-aware-interpolation.qmd). --- @@ -269,6 +341,8 @@ $$\text{AUMC} = \int_0^{t_{\mathrm{last}}} t \cdot C(t) \, dt$$ | `aumcall` | AUMC from 0 to last, treating BLQ as 0 | | `aumcinf.obs` | AUMC extrapolated to ∞, observed Clast | | `aumcinf.pred` | AUMC extrapolated to ∞, predicted Clast | +| `aumcint.last`, `aumcint.all`, `aumcint.inf.obs`, `aumcint.inf.pred` (≥ 0.13.0) | AUMC over a time window, dose-aware (see [Interval AUMC](#interval-aumc)) | +| `aumcivlast`, `aumcivall`, `aumcivinf.obs`, `aumcivinf.pred`, `aumcivint.last`, `aumcivint.all` (≥ 0.13.0) | IV bolus AUMC with C0 back-extrapolation (see the [IV dosing chapter](intravascular.qmd)) | ```{r} aumc_interval <- data.frame( @@ -399,5 +473,5 @@ Over 0–24 h all four flavors integrate the same observed curve, so they coinci --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [interp.extrap.conc.dose()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [interp.extrap.conc()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_max.aucinf.pext()](https://humanpred.github.io/pknca/reference/exclude_nca.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pk.calc.aucint()](https://humanpred.github.io/pknca/reference/pk.calc.auxcint.html) · [pk.calc.auc.last()](https://humanpred.github.io/pknca/reference/pk.calc.auxc.html) · [interp.extrap.conc.dose()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [interp.extrap.conc()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_max.aucinf.pext()](https://humanpred.github.io/pknca/reference/exclude_nca.html) ::: diff --git a/user/custom-parameters.qmd b/user/custom-parameters.qmd index bd1b892..b8c60a7 100644 --- a/user/custom-parameters.qmd +++ b/user/custom-parameters.qmd @@ -36,24 +36,43 @@ length(get.interval.cols()) ```r add.interval.col( - name = "my.param", # column name used in intervals data frame - FUN = "pk.calc.my.param", # character string: function name - values = c(FALSE, TRUE), # allowed values in intervals (FALSE = skip, TRUE = compute) - unit_type = "time", # units category (see below) - pretty_name = "My parameter", # human-readable label - desc = "What it does", # one-line description - depends = NULL, # other parameters that must be computed first - sparse = FALSE, # TRUE if this is a sparse-PK parameter - formalsmap = list(), # list( = "") - datatype = "interval", # "interval" (default), "individual", or "population" - # (only "interval" is currently supported; the others error) - pptestcd_cdisc = "MYPARAM", # CDISC PPTESTCD code for the parameter (≥ 0.12.2) - pptest_cdisc = "My Parameter" # CDISC PPTEST label for the parameter (≥ 0.12.2) + name = "my.param", # column name used in the intervals data frame + FUN = "pk.calc.my.param", # function name as a character string, or NA + values = c(FALSE, TRUE), # allowed values in intervals (FALSE = skip, TRUE = compute) + unit_type = "time", # units category (required; see below) + pretty_name = "My parameter", # human-readable label for summary tables + depends = NULL, # other parameters that must be computed first + desc = "What it does", # one-line description; SDTM limit 40 characters + formalsmap = list(), # list( = ) + FUN_sparse = NA, # sparse-PK estimator (function name), if any + formalsmap_sparse = list(), # formalsmap for FUN_sparse + datatype = "interval", # only "interval" is supported; the others error + pptestcd_cdisc = "MYPARAM", # CDISC PPTESTCD code (defaults to name) + pptest_cdisc = "My Parameter", # CDISC PPTEST label (defaults to desc) + formula = NULL, # LaTeX expression, for documentation + formula_note = NULL, # note on the formula; also the PPANMETH fallback + tier = "uncommon", # "common" or "uncommon" + selection = NULL # list(concept=, route=, dosing=, secondary=) ) ``` **Key rule:** `FUN` must be a **character string** (the function name), not a function object. +What each argument does, as of PKNCA 0.13.0: + +| Argument | Notes | +|---|---| +| `name` | A non-empty string. Names ending in `_ref` and the name `interval_id` are reserved for the reference-interval linkage columns (see [Example 4](#example-4-a-parameter-that-needs-another-interval)) and are an error. | +| `FUN` | The calculation function's name, or `NA` for a parameter that is filled in as one column of another parameter's data-frame result, or that only a `FUN_sparse` estimator calculates. | +| `unit_type` | Required; one of the values listed below. | +| `desc` | Since 0.13.0, a description longer than 40 characters (the SDTM limit) is still registered but gives a warning. | +| `formalsmap` | Maps your function's arguments to data, parameters, constants (`I()`), or reference-interval values (`pknca_ref()`); see [below](#the-formalsmap-argument). | +| `FUN_sparse`, `formalsmap_sparse` (≥ 0.13.0) | A second calculation function used when the data are sparse; see [Example 5](#example-5-a-sparse-only-parameter). | +| `pptestcd_cdisc`, `pptest_cdisc` (≥ 0.13.0) | The codes used by `as.data.frame(out_format = "cdisc")`. Each is a single string, a list with one element `route` holding a named list by route (`list(route = list(extravascular = "CLF/FO", intravascular = "CLO"))`), or a list of exactly the elements `dense` and `sparse`; anything else is an error. | +| `formula`, `formula_note` (≥ 0.13.0) | Documentation of the calculation. When the calculation function sets no `"method"` attribute, `formula_note` is reported in the `PPANMETH` column. | +| `tier`, `selection` (≥ 0.13.0) | Classification used by automatic interval selection and reported by `pknca_parameter_table()`. A custom parameter is `"uncommon"` by default, so it is calculated only when requested by name. | +| `sparse` | Retired (≥ 0.13.0). `sparse = TRUE` is an error; an explicit `sparse = FALSE` is accepted and does nothing. Register a sparse-only parameter with `FUN = NA` and `FUN_sparse` instead. | + ### Available `unit_type` values `"unitless"`, `"fraction"`, `"%"`, `"count"`, `"time"`, `"inverse_time"`, `"amount"`, `"amount_dose"`, `"amount_time"`, `"conc"`, `"conc_dosenorm"`, `"dose"`, `"volume"`, `"auc"`, `"aumc"`, `"auc_dosenorm"`, `"aumc_dosenorm"`, `"clearance"`, `"renal_clearance"`, `"renal_clearance_dosenorm"` @@ -64,27 +83,29 @@ add.interval.col( Each `formalsmap` entry has the form ` = ""` — the **names** of the list must be your function's formal arguments, and the **values** are the PKNCA data or parameter names that supply them. For example, PKNCA registers `mrt.obs` with `formalsmap = list(auc = "aucinf.obs", aumc = "aumcinf.obs")`: the computed `aucinf.obs` value is passed to the function's `auc` argument. -Standard PKNCA data names available as `formalsmap` values: +Values available in a `formalsmap` (the full list is in `?add.interval.col`): -| PKNCA name | What it provides | +| `formalsmap` value | What it provides | |---|---| -| `conc` | Concentration vector for the current interval | -| `time` | Time vector for the current interval | -| `dose` | Total dose in the current interval | -| `start` | Interval start time | -| `end` | Interval end time | -| `half.life` | Computed half-life (if in `depends`) | -| `auclast` | Computed AUClast (if in `depends`) | -| `cmax` | Computed Cmax (if in `depends`) | -| *(any parameter name)* | Any other registered parameter in `depends` | - -If your function argument names **exactly match** PKNCA's standard names, `formalsmap` can be omitted or left empty. +| `"conc"`, `"time"` | Concentrations and times for the current interval (times start at 0 at the interval start) | +| `"dose"`, `"time.dose"`, `"duration.dose"`, `"route"` | Dose amounts, times, durations, and route for the current interval | +| `"volume"`, `"duration.conc"` | Sample volume and collection duration (urine and feces) | +| `"start"`, `"end"` | Interval start and end times (`end` may be `Inf`) | +| `"options"` | The `PKNCA.options()` in effect for the calculation | +| `"conc.group"`, `"time.group"`, `"dose.group"`, ... | The same inputs for the whole group rather than the interval | +| `"conc.sparse"`, `"time.sparse"`, `"subject"`, ... (≥ 0.13.0) | The pooled individual samples of sparse data (`NULL` for dense data) | +| *(any parameter name)* | The computed value of that parameter in the current interval (list it in `depends`) | +| `I()` (≥ 0.13.0) | A constant, passed to the function unchanged | +| `pknca_ref("")` (≥ 0.13.0) | The value of that parameter in the reference interval | +| `NULL` | Drop the argument from the call so that it keeps its default | + +If your function argument names **exactly match** PKNCA's standard names, `formalsmap` can be omitted or left empty. An argument whose name matches a registered parameter is filled from that parameter when it is not in the `formalsmap`, so map such an argument to `NULL` if your parameter does not use it. --- ## Example 1: derived from other parameters -A parameter that divides two already-computed parameters. Use `depends` to declare what must be computed first (shown here without `formalsmap` because the function's argument names match the parameter names). +A parameter that divides two already-computed parameters. Use `depends` to declare what must be computed first (shown here without `formalsmap` because the function's argument names match the parameter names). Requesting `exposure.ratio` alone is enough: PKNCA calculates everything in `depends` first, and `get.parameter.deps("exposure.ratio", recursive = TRUE)` lists what it is ultimately calculated from. ```{r} # Exposure ratio: AUClast / Cmax (units: time, since AUC/conc = time × conc/conc = time) @@ -99,7 +120,8 @@ add.interval.col( unit_type = "time", pretty_name = "Exposure ratio (AUClast / Cmax)", desc = "AUClast divided by Cmax", - depends = c("auclast", "cmax") + depends = c("auclast", "cmax"), + formula = "\\frac{AUC_{last}}{C_{max}}" ) ``` @@ -121,25 +143,28 @@ as.data.frame(o_nca_er) |> --- -## Example 2: computed from raw concentration-time data +## Example 2: computed from raw concentration-time data, with a constant -A parameter that accesses `conc` and `time` directly. PKNCA passes the raw vectors from the concentration data. No `depends` needed. The identity `formalsmap` below is shown for explicitness — because the argument names match PKNCA's standard names, it could equally be omitted. +A parameter that accesses `conc` and `time` directly. PKNCA passes the raw vectors from the concentration data. No `depends` needed. The target time is passed as a constant with `I()` (≥ 0.13.0): without `I()`, `2` would be looked up as a data or parameter name instead of being passed as a value. The same function could be registered again as `c.4h` with `time.out = I(4)`. ```{r} -# Concentration at exactly 2 hours (interpolated) -pk.calc.c_at_2h <- function(conc, time) { - interp.extrap.conc(conc = conc, time = time, time.out = 2) +# Concentration at a fixed time (interpolated) +pk.calc.conc.at <- function(conc, time, time.out) { + interp.extrap.conc(conc = conc, time = time, time.out = time.out) } add.interval.col( - name = "c.2h", - FUN = "pk.calc.c_at_2h", - values = c(FALSE, TRUE), - unit_type = "conc", - pretty_name = "Concentration at 2 h", - desc = "Interpolated concentration at t = 2 h", - depends = NULL, - formalsmap = list(conc = "conc", time = "time") + name = "c.2h", + FUN = "pk.calc.conc.at", + values = c(FALSE, TRUE), + unit_type = "conc", + pretty_name = "Concentration at 2 h", + desc = "Interpolated concentration at 2 h", + depends = NULL, + formalsmap = list(conc = "conc", time = "time", time.out = I(2)), + pptestcd_cdisc = "C2H", + pptest_cdisc = "Concentration at 2 h", + formula_note = "Interpolated with interp.extrap.conc()" ) ``` @@ -150,10 +175,18 @@ o_nca_c2 <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = data.frame( as.data.frame(o_nca_c2) |> filter(PPTESTCD == "c.2h") |> - select(subject, PPORRES) |> + select(subject, PPORRES, PPANMETH) |> arrange(subject) ``` +`pk.calc.conc.at()` sets no `"method"` attribute, so `PPANMETH` falls back to the registered `formula_note`. The CDISC codes appear in the CDISC output: + +```{r} +as.data.frame(o_nca_c2, out_format = "cdisc") |> + filter(subject == "1") |> + select(subject, pknca_parameter, PPTESTCD, PPTEST, PPORRES) +``` + --- ## Example 3: multiple related parameters sharing a dependency @@ -207,13 +240,15 @@ add.interval.col( values = c(FALSE, TRUE), unit_type = "fraction", pretty_name = "Early AUC fraction", - desc = "Fraction of AUClast occurring in first 4 h", + desc = "Fraction of AUClast in the first 4 h", depends = "auclast", formalsmap = list(conc = "conc", time = "time", auclast = "auclast") ) ``` -> **Note:** One function *can* populate several output parameters at once by returning a **one-row data frame** whose column names are registered parameter names — `pk.nca()` saves every column of the returned data frame. This is exactly how `pk.calc.half.life()` populates `half.life`, `lambda.z`, `r.squared`, and the other half-life diagnostics. Each output still needs its own `add.interval.col()` registration: register the function on the primary parameter, and register the other columns with `FUN = NA` plus a `depends` on the primary (`formalsmap` may not be given when `FUN = NA`). The one-function-per-parameter pattern shown above is the simpler option when each output is cheap to compute. +These functions return values from `pk.calc.auc.last()`, which carry a `"method"` attribute ("AUC: lin up/log down" with the default `auc.method`); the attribute survives the subtraction and division, so `pk.nca()` reports it in the `PPANMETH` column of these custom parameters too. Return `as.numeric()` of a value if its method should not be reported. + +> **Note:** One function *can* populate several output parameters at once by returning a **one-row data frame** whose column names are registered parameter names — `pk.nca()` saves every column of the returned data frame. A data frame whose names and values do not pair up (for example, one with zero rows) is an error naming the function (≥ 0.13.0). This is exactly how `pk.calc.half.life()` populates `half.life`, `lambda.z`, `r.squared`, and the other half-life diagnostics. Each output still needs its own `add.interval.col()` registration: register the function on the primary parameter, and register the other columns with `FUN = NA` plus a `depends` on the primary (`formalsmap` may not be given when `FUN = NA`). The one-function-per-parameter pattern shown above is the simpler option when each output is cheap to compute. ```{r} o_nca_split <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = data.frame( @@ -246,7 +281,7 @@ add.interval.col( values = c(FALSE, TRUE), unit_type = "auc", pretty_name = "AUC 0-4h (multi-output)", - desc = "AUC from 0 to 4 h, from a multi-output function", + desc = "AUC from 0 to 4 h (multi-output)", depends = "auclast", formalsmap = list(conc = "conc", time = "time", auclast = "auclast") ) @@ -257,7 +292,7 @@ add.interval.col( values = c(FALSE, TRUE), unit_type = "auc", pretty_name = "AUC 4h to last (multi-output)", - desc = "AUC from 4 h to last sample, from a multi-output function", + desc = "AUC from 4 h to last (multi-output)", depends = "auc.early.df" ) @@ -275,6 +310,95 @@ as.data.frame(o_nca_multi) |> --- +## Example 4: a parameter that needs another interval + +Some parameters compare two intervals — bioavailability, renal clearance, and the built-in `ratio.*` parameters. Wrapping a `formalsmap` value in `pknca_ref()` (≥ 0.13.0) takes that argument from the **reference** interval instead of the current one. The intervals then link the two rows: the reference row gets an `interval_id`, and the row calculating the parameter names it in a `_ref` column. Here a custom parameter subtracts the reference interval's `aucint.last` from the current one's: + +```{r} +pk.calc.aucint.change <- function(aucint.last, aucint_reference) { + aucint.last - aucint_reference +} + +add.interval.col( + name = "aucint.change", + FUN = "pk.calc.aucint.change", + values = c(FALSE, TRUE), + unit_type = "auc", + pretty_name = "AUCint change from reference", + desc = "AUCint,last minus reference AUCint,last", + depends = "aucint.last", + formalsmap = list(aucint_reference = pknca_ref("aucint.last")) +) + +intervals_linked <- data.frame( + start = c(0, 8), + end = c(8, 24), + interval_id = c("first8h", NA), + aucint.last = TRUE, + aucint.change = c(FALSE, TRUE), + aucint.change_ref = c(NA, "first8h") +) + +o_nca_linked <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = intervals_linked)) + +as.data.frame(o_nca_linked) |> + filter(subject %in% c("1", "2")) |> + select(subject, start, end, PPTESTCD, PPORRES, PPANMETH) |> + arrange(subject, start) +``` + +A `pknca_ref()` in the `formalsmap` makes the parameter *secondary* (calculated after every interval, from the results), so its function may take only parameter values — every argument comes either from the reference interval through `pknca_ref()` or from a parameter of the current interval listed in `depends`. The secondary row's `PPANMETH` names the reference interval. Requesting, linking, and automatically deriving reference intervals are covered in the [secondary parameters chapter](secondary-parameters.qmd) and the [Secondary Parameters vignette](https://humanpred.github.io/pknca/articles/v09-secondary-parameters.html). + +--- + +## Example 5: a sparse-only parameter + +A parameter registered with `FUN = NA` and a `FUN_sparse` (≥ 0.13.0) is calculated only for sparse data, from the pooled individual samples. `formalsmap_sparse` replaces `formalsmap` for that function and can name the sparse inputs, such as `subject`. (Before 0.13.0 this was `sparse = TRUE`, which is now an error.) + +```{r} +pk.calc.n.subjects <- function(subject) { + length(unique(subject)) +} + +add.interval.col( + name = "n.subjects", + FUN = NA, + FUN_sparse = "pk.calc.n.subjects", + values = c(FALSE, TRUE), + unit_type = "count", + pretty_name = "Subjects contributing samples", + desc = "Number of subjects with samples", + formalsmap_sparse = list(subject = "subject") +) + +# Two samples from each of six animals +d_sparse <- data.frame( + animal = rep(1:6, each = 2), + time = c(0.5, 4, 2, 8, 12, 0.5, 24, 2, 4, 12, 8, 24), + conc = c(2, 3, 4, 2.5, 3.6, 2, 1.5, 1, 0.9, 0.5, 0.6, 0.2) +) +o_conc_sparse <- PKNCAconc(d_sparse, conc ~ time | animal, sparse = TRUE) + +o_nca_sparse <- pk.nca(PKNCAdata(o_conc_sparse, intervals = data.frame( + start = 0, end = 24, n.subjects = TRUE +))) +as.data.frame(o_nca_sparse) |> + select(start, end, PPTESTCD, PPORRES) +``` + +Requesting `n.subjects` for dense data is an error, and `pknca_parameter_table()` reports the parameter as sparse: + +```{r} +pknca_parameter_table(c("exposure.ratio", "c.2h", "aucint.change", "n.subjects")) |> + select(parameter, concept, tier, sparse, secondary) +``` + +A custom parameter whose concept cannot be derived (here `exposure.ratio`, `c.2h`, and `n.subjects`) has `NA` for `concept`: it is calculated normally when requested by name, but it is never selected automatically. Declare one with `selection = list(concept = ...)` (see `pknca_concepts()` for the vocabulary) or with `pknca_concept()` on the calculation function. `aucint.change` takes its concept from its dependency and is secondary because of its `pknca_ref()`. + +A parameter with both `FUN` and `FUN_sparse` uses `FUN_sparse` for sparse data and `FUN` for dense data; one with only `FUN` is calculated on the arithmetic-mean profile for sparse data. Sparse analyses are covered in the [sparse NCA chapter](sparse.qmd). + +--- + ## Inspecting registered parameters ```{r} @@ -319,10 +443,11 @@ The vignette's [summary section](https://humanpred.github.io/pknca/articles/v80- - **`FUN` must be a character string.** Passing a function object causes an error. - **`depends` controls evaluation order.** List all parameters your function reads. PKNCA resolves the dependency graph and ensures they are computed before your function is called. - **Functions must be in scope** when `pk.nca()` runs. Define them in the global environment or in a package loaded before calling `pk.nca()`. +- **Keep `desc` to 40 characters or fewer** if the results may go into an SDTM submission; longer descriptions warn. --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [add.interval.col()](https://humanpred.github.io/pknca/reference/add.interval.col.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [interp.extrap.conc()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [pk.calc.auc.last()](https://humanpred.github.io/pknca/reference/pk.calc.auxc.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [geomean()](https://humanpred.github.io/pknca/reference/geomean.html) · [geocv()](https://humanpred.github.io/pknca/reference/geomean.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [add.interval.col()](https://humanpred.github.io/pknca/reference/add.interval.col.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [get.parameter.deps()](https://humanpred.github.io/pknca/reference/get.parameter.deps.html) · [pknca_ref()](https://humanpred.github.io/pknca/reference/pknca_ref.html) · [pknca_parameter_table()](https://humanpred.github.io/pknca/reference/pknca_parameter_table.html) · [pknca_concepts()](https://humanpred.github.io/pknca/reference/pknca_concepts.html) · [pknca_concept()](https://humanpred.github.io/pknca/reference/pknca_concept.html) · [as.data.frame.PKNCAresults()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [interp.extrap.conc()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [pk.calc.auc.last()](https://humanpred.github.io/pknca/reference/pk.calc.auxc.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [geomean()](https://humanpred.github.io/pknca/reference/geomean.html) · [geocv()](https://humanpred.github.io/pknca/reference/geomean.html) ::: diff --git a/user/dose-aware-interpolation.qmd b/user/dose-aware-interpolation.qmd index 2e72ac5..c993050 100644 --- a/user/dose-aware-interpolation.qmd +++ b/user/dose-aware-interpolation.qmd @@ -209,9 +209,46 @@ interp.extrap.conc.dose( ## How PKNCA uses `interp.extrap.conc.dose()` internally -PKNCA calls this function automatically whenever an interval boundary (the `start` or `end` of an interval in your intervals data frame) falls at a dose time — see [Selection of Calculation Intervals](https://humanpred.github.io/pknca/articles/v03-selection-of-calculation-intervals.html) for how boundaries are chosen. You normally never call it directly. +Within `pk.nca()`, the interval AUC and AUMC parameters (`aucint.*` and `aumcint.*`) call this function to estimate the concentration at the `start` and `end` of their window whenever dosing data are given (≥ 0.13.0); every `aucint`/`aumcint` parameter is dose-aware, and the former `.dose` variants are retired. You normally never call it directly. The rules that apply inside `pk.nca()` are: -Understanding its behavior helps diagnose unusual NCA results when dosing and sampling times coincide — for example, when an interval boundary such as `end = tau` coincides with the next dose (the trough sample time). Every combination of dose events and observations, and the method used for each, is tabulated in the [complete methods table](https://humanpred.github.io/pknca/articles/v21-methods-for-dose-aware-interpolation-and-extrapolation.html#appendix-complete-methods-table) of the dose-aware interpolation vignette. +- **Only the window boundaries are estimated**, and always as the concentration just before any dose at that time (`out.after = FALSE`). A concentration measured at the same time as a dose counts as measured before it. +- **The profile ends at the next dose.** Concentrations measured after the first dose at or after `end` are not integrated and are not used to interpolate or extrapolate back into the window. +- **A dose inside the window is integrated across** using the concentrations measured on either side of it; no concentration is imputed at that dose. +- **The route and duration of each dose are used**, so an IV bolus inside the window is handled as a bolus, not as an extravascular dose. +- **Without dosing data**, `pk.nca()` uses `interp.extrap.conc()` instead, and the `PPANMETH` column reports "Interpolation: not dose-aware (no dosing data)". + +Because the window start is the concentration *before* the dose, an `aucint.*` window that starts at an IV bolus with no sample at that time starts from the pre-dose concentration rather than from a back-extrapolated C0. For IV bolus profiles, use the `aucivint.*` parameters, which back-extrapolate C0: + +```{r} +# Two IV bolus doses (t = 0 and t = 12) with no sample at either dose time +d_iv2 <- data.frame( + subject = "A", + time = c(0.5, 1, 2, 4, 6, 12.5, 13, 14, 16, 18, 24), + conc = c(9, 8, 6, 4, 2.8, 10, 9, 7, 5, 3.6, 1.5) +) +d_iv2_dose <- data.frame(subject = "A", time = c(0, 12), dose = 100) + +o_conc_iv2 <- PKNCAconc(d_iv2, conc ~ time | subject) +o_dose_iv2 <- PKNCAdose(d_iv2_dose, dose ~ time | subject, + route = "intravascular") + +# The dosing interval after the second dose +o_nca_iv2 <- pk.nca(PKNCAdata( + o_conc_iv2, o_dose_iv2, + intervals = data.frame(start = 12, end = 24, + aucint.last = TRUE, aucivint.last = TRUE, c0 = TRUE) +)) + +as.data.frame(o_nca_iv2) |> + filter(PPTESTCD %in% c("aucint.last", "aucivint.last", "c0")) |> + select(start, end, PPTESTCD, PPORRES) +``` + +`aucint.last` starts from the concentration estimated just before the 12-hour dose and rises linearly to the 12.5-hour sample, so it is lower than `aucivint.last`, which starts from the back-extrapolated `c0`. The [AUC chapter](auc-methods.qmd#why-dose-awareness-matters) shows the effect of the next-dose boundary on an oral profile, and the [IV dosing chapter](intravascular.qmd) covers the `auciv*` family. + +Understanding these rules helps diagnose unusual NCA results when dosing and sampling times coincide — for example, when an interval boundary such as `end = tau` coincides with the next dose (the trough sample time). Every combination of dose events and observations, and the method used for each, is tabulated in the [complete methods table](https://humanpred.github.io/pknca/articles/v21-methods-for-dose-aware-interpolation-and-extrapolation.html#appendix-complete-methods-table) of the dose-aware interpolation vignette, and [Selection of Calculation Intervals](https://humanpred.github.io/pknca/articles/v03-selection-of-calculation-intervals.html) explains how interval boundaries are chosen. + +Several edge cases changed in PKNCA 0.13.0: interpolation no longer fails when the concentrations around a dose are all BLQ; the `AUCall` extrapolation finds the first BLQ measurement after Tlast instead of stopping at the last measured concentration; and a concentration estimated before a dose is extrapolated from the Clast of the profile before that dose rather than from the Clast of the interval being calculated. --- @@ -234,5 +271,5 @@ The table is abridged: `interp.extrap.conc.dose()` also accepts `conc.blq` and ` --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [interp.extrap.conc.dose()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [interp.extrap.conc()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [pk.calc.c0()](https://humanpred.github.io/pknca/reference/pk.calc.c0.html) +**pkgdown reference:** [interp.extrap.conc.dose()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [interp.extrap.conc()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [pk.calc.c0()](https://humanpred.github.io/pknca/reference/pk.calc.c0.html) · [pk.calc.aucint()](https://humanpred.github.io/pknca/reference/pk.calc.auxcint.html) · [pk.calc.auciv()](https://humanpred.github.io/pknca/reference/pk.calc.auxciv.html) · [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) ::: diff --git a/user/halflife.qmd b/user/halflife.qmd index dc1fd2f..102ceff 100644 --- a/user/halflife.qmd +++ b/user/halflife.qmd @@ -20,22 +20,24 @@ o_dose <- PKNCAdose(d_dose, dose ~ time | subject, route = "extravascular") ## How PKNCA estimates λz -PKNCA uses **curve stripping**: it systematically evaluates every terminal window ending at the last point (last 3 points, last 4, and so on up to the full tail), fits log-linear regression to each subset, and selects the best fit based on adjusted R². +PKNCA uses **curve stripping**: it systematically evaluates every terminal window ending at the last measurable point (last 3 points, last 4, and so on up to the full tail), fits log-linear regression to each subset, and selects the best fit based on adjusted R². + +Before any window is fit, BLQ and zero concentrations are dropped, and so are points at or before the end of the last dose (including an infusion's duration). Only points after Tmax are candidates unless `allow.tmax.in.half.life = TRUE`. The selection criterion uses a **tolerance window**: among all subsets that yield a valid fit (λz > 0), PKNCA picks the one with the **most points** whose adjusted R² is within `adj.r.squared.factor` of the best adjusted R²: > selected n = the largest n whose adj.R² is within `adj.r.squared.factor` of the best adj.R² -The small tolerance (`adj.r.squared.factor`, default 0.0001) means: prefer longer windows (more points → more accurate λz) unless the longer window's adj.R² is noticeably worse than the best. This is a **model-selection tolerance**, not a per-point penalty added to R². +The small tolerance (`adj.r.squared.factor`, default 0.0001) means: prefer longer windows (more points → more accurate λz) unless the longer window's adj.R² is noticeably worse than the best. This is a **model-selection tolerance**, not a per-point penalty added to R². Selection can use the unadjusted R² instead, and the candidate windows can be limited by number of points and start time; see [Controlling automatic point selection](#controlling-automatic-point-selection). ```{mermaid} flowchart TD - A["All points after Tmax"] --> B["Try subsets: last 3, last 4, ..., all"] - B --> C["Fit log-linear regression to each"] - C --> D{"Valid fit?
λz > 0
≥ min.hl.points points"} - D -- yes --> E["Compute adj.R²"] + A["Drop BLQ/zero concentrations and
points at or before the end of the last dose"] --> B["Candidates: points after Tmax
(and at or after min.hl.start.time)"] + B --> C["Fit log-linear regression to the last 3, last 4, ...
points (min.hl.points to max.hl.points)"] + C --> D{"Valid fit?
λz > 0"} + D -- yes --> E["Compute adj.R² (or R²)"] D -- no --> F["Discard subset"] - E --> G["Select longest window within adj.r.squared.factor of best adj.R²"] + E --> G["Select the longest window within
adj.r.squared.factor (or r.squared.factor) of the best"] G --> H["Return λz, half.life, R², n.points"] ``` @@ -83,14 +85,14 @@ as.data.frame(o_nca) |> ## Quality filters -PKNCA provides one filter enforced **during fitting**, several post-hoc exclusion thresholds applied **after fitting** with `exclude()`, and one fitting option: +PKNCA provides limits enforced **during automatic point selection**, several post-hoc exclusion thresholds applied **after fitting** with `exclude()`, and one fitting option: -> **Important distinction:** Only `min.hl.points` is enforced inside `pk.calc.half.life()` — with too few points, `half.life` is `NA` immediately. `min.hl.r.squared` and `min.span.ratio` are **not** consulted during fitting; they are thresholds for `exclude()` calls after `pk.nca()`. +> **Important distinction:** `min.hl.points`, `max.hl.points`, and `min.hl.start.time` are enforced inside `pk.calc.half.life()` while it selects points — with too few candidate points, `half.life` is `NA` immediately and the `exclude` column gives the reason. `min.hl.r.squared` and `min.span.ratio` are **not** consulted during fitting; they are thresholds for `exclude()` calls after `pk.nca()`. ### Minimum number of points (enforced during fitting) ```{r} -# Default: 3 points minimum — the only filter that makes half.life NA during pk.nca() +# Default: 3 points minimum; with too few points, half.life is NA during pk.nca() o_data_4pts <- PKNCAdata(o_conc, o_dose, intervals = hl_interval, options = list(min.hl.points = 4)) o_nca_4pts <- pk.nca(o_data_4pts) @@ -177,6 +179,66 @@ as.data.frame(o_nca_tmax) |> arrange(subject) ``` +### Controlling automatic point selection {#controlling-automatic-point-selection} + +Three options (≥ 0.13.0) change which windows curve stripping considers or how it ranks them. Each is a `PKNCA.options()` option (set per analysis with `PKNCAdata(options = ...)`) and an argument of `pk.calc.half.life()` with the same name: + +| Option | Default | Effect | +|---|---|---| +| `max.hl.points` | `Inf` (no limit) | The most points a window may use; must be more than `min.hl.points` | +| `min.hl.start.time` | `0` (no restriction) | The earliest time a window may start; within `pk.nca()` it is the time since the start of the interval | +| `r.squared.factor` | `NA` (not in use) | Select on the unadjusted R² with this tolerance instead of on the adjusted R² | + +`max.hl.points` and `min.hl.start.time` mirror the "Lambda Z Rules for Best Fit" of Phoenix WinNonlin, and they apply to both the log-linear and the Tobit methods. `adj.r.squared.factor` and `r.squared.factor` are alternatives: setting either one takes the other out of use, so exactly one is in use at a time, and setting one to `NA` selects on the other. Unlike the adjusted R², the unadjusted R² does not reward more points, so it usually selects fewer of them. + +```{r} +hl_select_interval <- data.frame( + start = 0, + end = Inf, + half.life = TRUE, + lambda.z.n.points = TRUE, + lambda.z.time.first = TRUE +) + +selection_settings <- list( + "default" = list(), + "max.hl.points=4" = list(max.hl.points = 4), + "min.hl.start.time=9" = list(min.hl.start.time = 9), + "r.squared.factor=1e-4" = list(r.squared.factor = 0.0001) +) + +hl_select <- lapply(names(selection_settings), function(setting) { + o_data_setting <- PKNCAdata(o_conc, o_dose, intervals = hl_select_interval, + options = selection_settings[[setting]]) + as.data.frame(pk.nca(o_data_setting)) |> + mutate(setting = setting) +}) |> + bind_rows() + +hl_select |> + filter(subject %in% c("6", "8", "9", "11"), + PPTESTCD %in% c("lambda.z.time.first", "lambda.z.n.points", "half.life")) |> + select(subject, setting, PPTESTCD, PPORRES) |> + tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |> + arrange(subject, setting) +``` + +For the four subjects shown: + +- **`max.hl.points = 4`** shortens subject 6's window from 7 points to 3 (the better of the 3- and 4-point windows) and subject 8's from 6 to 4; subjects 9 and 11 already used 3 points. +- **`min.hl.start.time = 9`** moves the start of subject 6's and subject 8's windows to their first samples after 9 h. Subject 9 has only two measurable points at or after 9 h, so its half-life is not calculated; that is the warning above, and the same text is recorded in the `exclude` column: + +```{r} +hl_select |> + filter(setting == "min.hl.start.time=9", subject == "9", + PPTESTCD == "half.life") |> + pull(exclude) +``` + +- **`r.squared.factor = 1e-4`** selects a shorter window for subject 6 (3 points instead of 7) but a longer one for subject 11 (4 instead of 3), and leaves subjects 8 and 9 unchanged. + +When no window survives selection — for example, when a well-fitting window with λz ≤ 0 sets the R² tolerance so that no window with λz > 0 is within it — `half.life` is `NA` with an exclusion reason beginning "No valid terminal phase" (≥ 0.13.0). + --- ## Manually controlling which points are used @@ -185,7 +247,9 @@ See also [manual point selection](https://humanpred.github.io/pknca/articles/v06 ### Excluding specific points (`exclude_half.life`) -Mark individual observations that should never be included in the λz regression — for example, a suspected outlier or a secondary peak. +Both point-selection columns are optional columns of the concentration data, named with the `exclude_half.life` and `include_half.life` arguments of `PKNCAconc()`. Since PKNCA 0.13.0 they must be logical (`TRUE`/`FALSE`/`NA`), naming a column that does not exist is an error, and only one of the two may be in use for an interval (using both is an error). A column is "in use" for an interval when any of its values there is not `NA` — even if every value is `FALSE` — so initialize the column to `NA` wherever it should not apply. The [Half-Life Calculation vignette](https://humanpred.github.io/pknca/articles/v06-half-life-calculation.html#choosing-a-point-selection-method) has a decision tree for choosing among automatic selection, `exclude_half.life`, and `include_half.life`. + +Mark individual observations that should never be included in the λz regression — for example, a suspected outlier or a secondary peak. The remaining points go through the usual curve stripping. ```{r} d_conc_excl <- d_conc |> @@ -211,7 +275,7 @@ bind_rows( ### Specifying the exact points (`include_half.life`) -Mark the exact points to fit: `include_half.life` **bypasses curve stripping entirely** and fits only the marked points. Automatic selection — including `min.hl.points`, `adj.r.squared.factor`, and `allow.tmax.in.half.life` — is not applied, so even a two-point fit is accepted. Useful when the regression window is pre-specified, e.g. by an analysis plan. +Mark the exact points to fit: `include_half.life` **bypasses curve stripping entirely** and fits only the marked points. Automatic selection — including `min.hl.points`, `max.hl.points`, `min.hl.start.time`, `adj.r.squared.factor`, `r.squared.factor`, and `allow.tmax.in.half.life` — is not applied, so even a two-point fit is accepted. Useful when the regression window is pre-specified, e.g. by an analysis plan. ```{r} d_conc_incl <- d_conc |> @@ -228,11 +292,13 @@ as.data.frame(o_nca_incl) |> select(PPTESTCD, PPORRES) ``` -> **Note:** `include_half.life` forces the curve-stripping algorithm to always start at or before the earliest forced point. If the forced point is early in the profile, this can result in a worse fit (lower R²) — the point is still included even if it pulls the regression down. +The two-point fit is accepted, with the warning "n must be > 2 for adj.r.squared": the adjusted R² is undefined for two points, so it is `NA` for this fit while `half.life` is reported. + +> **Note:** naming a point in `include_half.life` does not force it into the fit. BLQ and zero concentrations, and points at or before the end of the last dose (including an infusion's duration), are dropped first, exactly as for automatic selection. --- -## Tobit regression for BLQ-heavy data (≥ 0.12.2) +## Tobit regression for BLQ-heavy data (≥ 0.13.0) ### Why Tobit? @@ -261,7 +327,7 @@ Tobit regression is most beneficial in the following situations: - **Pediatric or geriatric studies** with limited sampling windows, where sparse late-time samples are often at or near the LLOQ. - The terminal slope is important for downstream parameters (AUC extrapolation, mean residence time) and precision matters for regulatory submissions. -### Using Tobit directly with `pk.calc.half.life()` (≥ 0.12.2) +### Using Tobit directly with `pk.calc.half.life()` (≥ 0.13.0) Tobit can be called directly through `pk.calc.half.life()` or used in the full `pk.nca()` pipeline (next section). For a direct call, supply the full concentration-time vector and the LLOQ (a scalar, or a vector with one value per timepoint): @@ -287,9 +353,9 @@ data.frame( ) ``` -### Tobit through the `pk.nca()` pipeline (≥ 0.12.2) +### Tobit through the `pk.nca()` pipeline (≥ 0.13.0) -To run Tobit across a full dataset, declare the LLOQ on the concentration object with the `lloq` argument of `PKNCAconc()` and select the method with the `hl_method` option: +To run Tobit across a full dataset, declare the LLOQ on the concentration object with the `lloq` argument of `PKNCAconc()` (a column name or a scalar) and select the method with the `hl_method` option. Every non-missing `lloq` value must be a finite number greater than zero; anything else is an error (`pknca_error_conc_invalid_lloq`). Missing values are allowed, and an entirely `NA` column is accepted. ```{r} # Subject 1 with an assay LLOQ of 0.5 mg/L @@ -330,7 +396,11 @@ Tobit adds new output parameters to the results: | `tobit_n_points_penalty` | `0` | Exponent in the window-selection criterion (residual × n^penalty); positive values favor shorter windows | | `tobit_optim_control` | `list()` | Control list forwarded to `stats::optim()` for the Tobit optimization | -Window selection also differs from the log-linear fits: instead of the adjusted-R² rule, PKNCA fits each candidate terminal window and keeps the one that minimizes `tobit_residual × n_points^tobit_n_points_penalty`, with ties going to the larger window. +Window selection also differs from the log-linear fits: instead of the adjusted-R² rule, PKNCA fits each candidate terminal window and keeps the one that minimizes `tobit_residual × n_points^tobit_n_points_penalty`, with ties going to the larger window. `min.hl.points` and `max.hl.points` count only the above-LLOQ points of a Tobit window, and `min.hl.start.time` applies as it does for the log-linear method. + +### Tobit with sparse PK (≥ 0.13.0) + +The Tobit half-life also works with sparse sampling. For sparse data the half-life is calculated on the arithmetic-mean profile, and that profile carries an LLOQ at each time: the median of the `lloq` values of its samples, ignoring missing ones. A time whose mean is set to zero because more than half of its samples are BLQ is therefore censored at that LLOQ rather than dropped. See the [sparse NCA chapter](sparse.qmd) for sparse analyses. See the vignette [Half-life calculation with Tobit regression](https://humanpred.github.io/pknca/articles/v06-half-life-calculation-tobit.html) for a detailed simulation comparison between OLS and Tobit estimates. @@ -338,7 +408,7 @@ See the vignette [Half-life calculation with Tobit regression](https://humanpred ## `lambda.z.corrxy` — regression correlation -`lambda.z.corrxy` (≥ 0.12.2) returns the Pearson correlation between time and log-concentration for the points used in the λz regression. It is a unitless diagnostic — values close to −1 indicate a strong, clean terminal slope. +`lambda.z.corrxy` (≥ 0.13.0) returns the Pearson correlation between time and log-concentration for the points used in the λz regression (numeric `NA` when it cannot be computed). It is a unitless diagnostic — values close to −1 indicate a strong, clean terminal slope. Like `r.squared` and `adj.r.squared`, it is returned only by the log-linear method. ```{r} hl_corrxy_interval <- data.frame( @@ -364,31 +434,47 @@ as.data.frame(o_nca_corrxy) |> ## Visualizing the chosen regression window +Three functions extract what is needed to plot the fit, each from a `PKNCAresults` object or (by running only the half-life calculation) a `PKNCAdata` object: + +- `get_halflife_points()` gives one logical value per row of the concentration data: `TRUE` for points used in the fit, `FALSE` for points not used, and `NA` where no half-life was calculated for the interval. +- `get_halflife_fit()` (≥ 0.13.0) gives the `intercept` and `slope` of the line `log(conc) = intercept + slope*time` and the `time_first`/`time_last` of the points used, one row per group and interval. Times are on the scale of the concentration data, not relative to the interval start, and the line is `NA` when the half-life was not calculated or was excluded. +- `get_halflife_curve()` (≥ 0.13.0) gives concentrations along that line, at the times in `tout` or at `n` equally spaced times (default 50) spanning the points used. It extrapolates after the last point used by default (`extrapolate_later = TRUE`) and not before the first (`extrapolate_earlier = FALSE`); times outside the fit that are not extrapolated give `NA`. + ```{r} -# Extract lambda.z.time.first and n.points to annotate the plot -hl_results <- as.data.frame(o_nca) |> - filter(PPTESTCD %in% c("lambda.z.time.first", "lambda.z.n.points")) |> - tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) +get_halflife_fit(o_nca) |> + filter(subject %in% c("1", "6")) +``` + +```{r} +# Mark the points used for each subject's fit +d_conc_hl <- d_conc |> + mutate(used_in_lambda_z = get_halflife_points(o_nca)) -# Plot subject 1 with regression window highlighted -subj1 <- d_conc |> filter(subject == "1") -win <- hl_results |> filter(subject == "1") -start_t <- win$lambda.z.time.first +# The fitted line on a grid from 0 to 36 h: NA before the first point used, +# extrapolated after the last one +d_curve <- get_halflife_curve(o_nca, tout = seq(0, 36, by = 0.5), + extrapolate_earlier = FALSE) -ggplot(subj1, aes(x = time, y = conc)) + +ggplot(d_conc_hl |> filter(subject %in% c("1", "6"), conc > 0), + aes(x = time, y = conc)) + geom_line(colour = "grey60") + - geom_point(aes(colour = time >= start_t), size = 3) + + geom_point(aes(colour = used_in_lambda_z), size = 3) + + geom_line(data = d_curve |> filter(subject %in% c("1", "6"), !is.na(conc)), + colour = "firebrick", linetype = "dashed") + scale_colour_manual(values = c("FALSE" = "grey60", "TRUE" = "firebrick"), - labels = c("excluded from λz", "used in λz"), + labels = c("FALSE" = "not used in λz", "TRUE" = "used in λz"), name = NULL) + scale_y_log10() + - labs(title = "Subject 1 — terminal regression window (log scale)", + facet_wrap(~subject, labeller = label_both) + + labs(title = "Terminal regression window and λz fit (log scale)", x = "Time (h)", y = "Concentration (mg/L) [log scale]") + theme_minimal() ``` +Subject 1's fit uses its last 3 points (from 9.05 h), while subject 6's uses 7 points (from 2.03 h). The dashed line starts at the first point used because `extrapolate_earlier = FALSE`, and it continues to 36 h because later times are extrapolated by default. For many subjects at once, the same functions feed a faceted plot or a `geom_line()` overlay in a QC report. + --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pk.calc.half.life()](https://humanpred.github.io/pknca/reference/pk.calc.half.life.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_min.hl.r.squared()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [exclude_nca_span.ratio()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [exclude_nca_min.hl.adj.r.squared()](https://humanpred.github.io/pknca/reference/exclude_nca.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [pk.calc.half.life()](https://humanpred.github.io/pknca/reference/pk.calc.half.life.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [get_halflife_points()](https://humanpred.github.io/pknca/reference/get_halflife_points.html) · [get_halflife_fit()](https://humanpred.github.io/pknca/reference/get_halflife_fit.html) · [get_halflife_curve()](https://humanpred.github.io/pknca/reference/get_halflife_curve.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_min.hl.r.squared()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [exclude_nca_span.ratio()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [exclude_nca_min.hl.adj.r.squared()](https://humanpred.github.io/pknca/reference/exclude_nca.html) ::: diff --git a/user/utilities.qmd b/user/utilities.qmd index dcd1b54..63b0edf 100644 --- a/user/utilities.qmd +++ b/user/utilities.qmd @@ -60,7 +60,7 @@ clean.conc.na(conc_na, time_na, conc.na = 0) ## Concentration interpolation / extrapolation: `interp.extrap.conc()` -Interpolates or extrapolates a concentration at a target time given a known concentration-time profile. Used internally when an interval boundary falls between two observed timepoints. +Interpolates or extrapolates a concentration at a target time given a known concentration-time profile. The `aucint.*` parameters use it internally when an interval boundary falls between two observed timepoints and no dosing data are given; with dosing data they use the dose-aware `interp.extrap.conc.dose()` instead. ```{r} conc_ex <- c(10, 8, 5, 2.5, 1) @@ -107,7 +107,7 @@ This is the same function called internally by `pk.nca()` when `half.life = TRUE ## Direct AUC computation: `pk.calc.auc.all()` -Computes AUC (trapezoid rule) directly on a concentration-time vector. Returns the scalar AUC value. +Computes AUC directly on a concentration-time vector with the chosen integration method. It returns the AUC with a `"method"` attribute (≥ 0.13.0) naming that method — the text that `pk.nca()` moves into the `PPANMETH` column of its results. ```{r} pk.calc.auc.all( @@ -117,6 +117,13 @@ pk.calc.auc.all( ) ``` +The attribute carries through arithmetic, so wrap the result in `as.numeric()` before comparing it to a bare number (for example with `identical()` or `testthat::expect_equal()`): + +```{r} +as.numeric(pk.calc.auc.all(conc = subj1$conc, time = subj1$Time, + method = "lin up/log down")) +``` + Other direct AUC functions: | Function | AUC type | @@ -126,7 +133,7 @@ Other direct AUC functions: | `pk.calc.auc.inf.obs()` | AUC extrapolated to ∞ (observed Clast) | | `pk.calc.auc.inf.pred()` | AUC extrapolated to ∞ (predicted Clast) | -These are the building blocks behind every AUC parameter in the registry. Integration methods are compared in [AUC integration methods](https://humanpred.github.io/pknca/articles/v23-auc-integration-methods.html). +These are the building blocks behind the AUC parameters in the registry; the interval forms (`pk.calc.aucint()` and relatives) are described in the [AUC chapter](auc-methods.qmd). Integration methods are compared in [AUC integration methods](https://humanpred.github.io/pknca/articles/v23-auc-integration-methods.html). --- @@ -151,10 +158,11 @@ param_cols[sapply(expanded[1, param_cols], isTRUE)] ``` ```{r} -# Invalid specification — missing start column — raises an error +# Invalid specification — missing start column — raises an error; +# the handler returns the error message tryCatch( check.interval.specification(data.frame(end = Inf, auclast = TRUE)), - error = function(e) cat("Error:", conditionMessage(e), "\n") + error = function(e) conditionMessage(e) ) ``` @@ -205,13 +213,19 @@ This is useful when you want to test how route assumption changes affect MRT or ## Getting parameter dependents: `get.parameter.deps()` -Returns the downstream parameters that depend on a given parameter — the results that would be affected if it fails or is excluded. +By default, returns the downstream parameters that depend on a given parameter — the results that would be affected if it fails or is excluded. ```{r} get.parameter.deps("half.life") ``` -For the opposite direction — the upstream inputs a parameter needs — read its registry entry: +For the opposite direction, `recursive = TRUE` (≥ 0.13.0) returns what a parameter is calculated from, following each dependency back to the raw inputs (such as `conc`, `time`, and `dose`) and the arguments of the calculation functions along the way, including the options they read: + +```{r} +get.parameter.deps("vss.obs", recursive = TRUE) +``` + +The registry entry lists only the direct dependencies: ```{r} get.interval.cols()[["vss.obs"]]$depends @@ -227,6 +241,12 @@ get.interval.cols()[["vss.obs"]]$depends PKNCA.options() ``` +`PKNCA_options_defaults()` (≥ 0.13.0) returns the default value of one or more options (or all of them, with no argument) without changing the current settings — unlike `PKNCA.options(default = TRUE)`, which resets them: + +```{r} +PKNCA_options_defaults(c("auc.method", "min.hl.points", "max.hl.points")) +``` + `PKNCA.options.describe()` returns the description of a single option; see the [Workflow Overview](workflow.qmd) page for the full option reference table, and the vignette [Options for Controlling PKNCA](https://humanpred.github.io/pknca/articles/v40-options-for-controlling-PKNCA.html). ```{r} @@ -249,9 +269,66 @@ PKNCA.choose.option("min.hl.points", options = list()) PKNCA.choose.option("min.hl.points", options = list(min.hl.points = 5)) ``` +--- + +## Routes of administration: `pknca_routes()` and `pknca_match_route()` (≥ 0.13.0) + +`pknca_routes()` lists the routes PKNCA distinguishes. With `synonyms = TRUE` it returns a table of the spellings data use for a route ("po", "intravenous bolus", CDISC `ROUTE` terms, ...), the route PKNCA uses for each, and the `PKNCAdose(route = )` value it implies; `pknca_match_route()` looks a character vector up in that table, giving `NA` for spellings it does not know. A spelling that does not say bolus or infusion maps to the generic `"iv"`. + +```{r} +pknca_routes() +head(pknca_routes(synonyms = TRUE)) +pknca_match_route(c("PO", "INTRAVENOUS BOLUS", "iv", "nasal spray")) +``` + +The `dose_route` column is ready to pass to the `route` argument of `PKNCAdose()`. + +--- + +## Converting durations: `pknca_difftime_to_unit()` (≥ 0.13.0) + +Converts a `difftime` (such as a collection duration computed as the difference of two date-times) into a number in an analysis time unit — the conversion `pk.nca()` applies to date-time data. A value that is not a `difftime` is an error. + +```{r} +pknca_difftime_to_unit(as.difftime(90, units = "mins"), "hr") +``` + +Date-time concentration and dose times are covered in the [date-time chapter](datetime.qmd). + +--- + +## Checking inputs before building objects (≥ 0.13.0) + +Applications that assemble PKNCA objects from user data can run the same checks PKNCA runs, before calling the constructors: + +| Function | What it checks | +|---|---| +| `assert_conc_time()` | Concentrations and times are valid numbers and the times are sorted (also `assert_conc()` and `assert_time()` separately) | +| `assert_impute_method()` | An imputation specification resolves to known methods, the way `PKNCAdata()` and `pk.nca()` resolve it | +| `PKNCA_impute_fun_list()` | The imputation function names an imputation string resolves to | +| `assert_PKNCAconc()`, `assert_PKNCAdose()`, `assert_PKNCAdata()`, `assert_PKNCAresults()` | The object has the expected class | + +```{r} +# Unsorted times are an error +tryCatch( + assert_conc_time(conc = c(1, 2, 3), time = c(0, 2, 1)), + error = function(e) conditionMessage(e) +) + +# An imputation chain resolves to one function name per method +PKNCA_impute_fun_list(c("start_conc0", "start_predose,start_conc0")) + +# An unknown method is an error +tryCatch( + assert_impute_method("start_bogus"), + error = function(e) conditionMessage(e) +) +``` + +Imputation methods themselves are described in the [imputation chapter](imputation.qmd). --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [clean.conc.blq()](https://humanpred.github.io/pknca/reference/clean.conc.blq.html) · [clean.conc.na()](https://humanpred.github.io/pknca/reference/clean.conc.na.html) · [interp.extrap.conc()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [pk.calc.half.life()](https://humanpred.github.io/pknca/reference/pk.calc.half.life.html) · [pk.calc.auc.all()](https://humanpred.github.io/pknca/reference/pk.calc.auxc.html) · [check.interval.specification()](https://humanpred.github.io/pknca/reference/check.interval.specification.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [setRoute()](https://humanpred.github.io/pknca/reference/setRoute.html) · [setDuration()](https://humanpred.github.io/pknca/reference/setDuration.html) · [get.parameter.deps()](https://humanpred.github.io/pknca/reference/get.parameter.deps.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [PKNCA.options.describe()](https://humanpred.github.io/pknca/reference/PKNCA.options.describe.html) · [PKNCA.choose.option()](https://humanpred.github.io/pknca/reference/PKNCA.choose.option.html) +**pkgdown reference:** [clean.conc.blq()](https://humanpred.github.io/pknca/reference/clean.conc.blq.html) · [clean.conc.na()](https://humanpred.github.io/pknca/reference/clean.conc.na.html) · [interp.extrap.conc()](https://humanpred.github.io/pknca/reference/interp.extrap.conc.html) · [pk.calc.half.life()](https://humanpred.github.io/pknca/reference/pk.calc.half.life.html) · [pk.calc.auc.all()](https://humanpred.github.io/pknca/reference/pk.calc.auxc.html) · [check.interval.specification()](https://humanpred.github.io/pknca/reference/check.interval.specification.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [setRoute()](https://humanpred.github.io/pknca/reference/setRoute.html) · [setDuration()](https://humanpred.github.io/pknca/reference/setDuration.html) · [get.parameter.deps()](https://humanpred.github.io/pknca/reference/get.parameter.deps.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [PKNCA.options.describe()](https://humanpred.github.io/pknca/reference/PKNCA.options.describe.html) · [PKNCA.choose.option()](https://humanpred.github.io/pknca/reference/PKNCA.choose.option.html) · [PKNCA_options_defaults()](https://humanpred.github.io/pknca/reference/PKNCA_options_defaults.html) · [pknca_routes()](https://humanpred.github.io/pknca/reference/pknca_concepts.html) · [pknca_match_route()](https://humanpred.github.io/pknca/reference/pknca_match_route.html) · [pknca_difftime_to_unit()](https://humanpred.github.io/pknca/reference/pknca_difftime_to_unit.html) · [assert_conc_time()](https://humanpred.github.io/pknca/reference/assert_conc_time.html) · [assert_impute_method()](https://humanpred.github.io/pknca/reference/assert_impute_method.html) · [PKNCA_impute_fun_list()](https://humanpred.github.io/pknca/reference/PKNCA_impute_fun_list.html) · [assert_PKNCAconc()](https://humanpred.github.io/pknca/reference/assert_PKNCAdata.html) ::: diff --git a/user/validation.qmd b/user/validation.qmd index 6bd2a29..4db9410 100644 --- a/user/validation.qmd +++ b/user/validation.qmd @@ -183,12 +183,36 @@ cat("PKNCA (linear method): ", round(pknca_lin, 3), "\n") cat("Difference: ", round(manual_auclast - pknca_lin, 6), "\n") ``` -To confirm the current method: +To confirm the current global method: ```{r auc-method} PKNCA.options("auc.method") ``` +### Recording how each value was calculated (≥ 0.13.0) + +Each result row also records its calculation method in the `PPANMETH` column, so a validation report can show which rule produced each value: + +```{r ppanmeth} +as.data.frame(o_nca_lin) |> + filter(Subject == "1") |> + select(Subject, PPTESTCD, PPORRES, PPANMETH) +``` + +To document the options an analysis changed, compare the options stored on the analysis with the package defaults from `PKNCA_options_defaults()`, which (unlike `PKNCA.options(default = TRUE)`) does not reset anything: + +```{r options-changed} +o_data_lin <- PKNCAdata(d_conc, d_dose, + intervals = data.frame(start = 0, end = Inf, auclast = TRUE), + options = list(auc.method = "linear")) +analysis_options <- o_data_lin$options +default_options <- PKNCA_options_defaults() +changed <- names(analysis_options)[ + !mapply(identical, analysis_options, default_options[names(analysis_options)]) +] +analysis_options[changed] +``` + --- ## Reproducibility reporting with `sessionInfo()` @@ -218,11 +242,11 @@ renv::restore() renv::status() ``` -Together, `packageVersion("PKNCA")`, `PKNCA.options()`, `sessionInfo()`, and `renv::snapshot()` provide a complete and reproducible record of the computational environment used in an NCA analysis. +Together, `packageVersion("PKNCA")`, `PKNCA.options()` (with any per-analysis changes, as above), the `PPANMETH` column, `sessionInfo()`, and `renv::snapshot()` provide a complete and reproducible record of the computational environment used in an NCA analysis. --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) · [PKNCA_options_defaults()](https://humanpred.github.io/pknca/reference/PKNCA_options_defaults.html) ::: From 79155d4c7de8ac16ba638386808d0630d90f3fc6 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 10 Oct 2026 02:16:39 +0000 Subject: [PATCH 08/12] docs: retarget the book to PKNCA 0.13.0 (chapter order, what's new, CI packages) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/book.yml | 4 +++ CLAUDE.md | 10 +++---- CONTRIBUTING.md | 53 ++++++++++++++++++++--------------- README.md | 57 +++++++++++++++++++------------------- _quarto.yml | 8 +++++- index.qmd | 31 ++++++++++++--------- 6 files changed, 92 insertions(+), 71 deletions(-) diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml index 7e524b8..0ffe419 100644 --- a/.github/workflows/book.yml +++ b/.github/workflows/book.yml @@ -41,6 +41,10 @@ jobs: any::conflicted any::visNetwork any::tidyr + any::units + any::lme4 + any::lmerTest + any::emmeans github::humanpred/pknca - name: Set up Quarto diff --git a/CLAUDE.md b/CLAUDE.md index bc722c2..aef1975 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code when working in this repository. ## What This Is -A Quarto book documenting the [PKNCA](https://github.com/humanpred/pknca) R package (≥ 0.12.2). +A Quarto book documenting the [PKNCA](https://github.com/humanpred/pknca) R package (≥ 0.13.0). Every parameter, option, and user-facing function is covered with runnable examples verified against live PKNCA output; the `pk.calc.*` calculation functions are indexed in the Developer Reference. **Book repo:** https://github.com/humanpred/pknca-book @@ -20,8 +20,8 @@ quarto preview # live preview with auto-reload ```r # Install dependencies (once) -remotes::install_github("humanpred/pknca") # >= 0.12.2 required -install.packages(c("knitr", "rmarkdown", "dplyr", "ggplot2", "conflicted", "testthat", "visNetwork")) +remotes::install_github("humanpred/pknca") # >= 0.13.0 required +install.packages(c("knitr", "rmarkdown", "dplyr", "ggplot2", "conflicted", "testthat", "visNetwork", "tidyr", "units", "lme4", "lmerTest", "emmeans")) ``` ## Project Structure @@ -29,7 +29,7 @@ install.packages(c("knitr", "rmarkdown", "dplyr", "ggplot2", "conflicted", "test ``` _quarto.yml # book config: title, author, chapters, site-url, navbar index.qmd # introduction + What's new per version -user/ # 19 User Guide chapters +user/ # 25 User Guide chapters dev/ # 2 Developer Reference chapters .github/workflows/book.yml # CI: renders and deploys to GitHub Pages on push to main _book/ # rendered output (gitignored) @@ -38,7 +38,7 @@ _book/ # rendered output (gitignored) ## Conventions - **Datasets:** `datasets::Theoph` (extravascular) and `datasets::Indometh` (intravascular) -- **PKNCA version:** ≥ 0.12.2 +- **PKNCA version:** ≥ 0.13.0 - **Code chunks:** `eval: true` — every chunk must run without error - **No `suppressWarnings()`** hiding real issues — fix root causes - **Each page is self-contained:** load packages at the top of every `.qmd` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 502f3c8..3d153da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,7 +55,7 @@ For each item, classify it: |---|---| | New NCA parameter | new `add.interval.col()` entry | | New option | new key in `PKNCA.options()` | -| New function | e.g. `normalize()` in 0.12.2 | +| New function | e.g. `be_assess()` in 0.13.0 | | Changed behavior | existing function works differently | | Bug fix | may invalidate an existing example | @@ -102,8 +102,9 @@ as.data.frame(o_nca) |> **Known gotchas:** -- **Tobit half-life** — set `lloq` on `PKNCAconc()`; in PKNCA ≥ 0.12.2 it is passed through to `pk.calc.half.life()` automatically, so the full `pk.nca()` pipeline works with `options = list(hl_method = "tobit")`. (Older versions required calling `pk.calc.half.life()` directly.) -- **Sparse AUMC** — only set `sparse_aumclast = TRUE`. Explicitly requesting `sparse_auc_se = TRUE` alongside it triggers an internal type error. +- **Tobit half-life** — set `lloq` on `PKNCAconc()`; in PKNCA ≥ 0.13.0 it is passed through to `pk.calc.half.life()` automatically, so the full `pk.nca()` pipeline works with `options = list(hl_method = "tobit")`. (Older versions required calling `pk.calc.half.life()` directly.) +- **Sparse parameters** — use the standard names (`auclast`, `aucinf.obs`, …); with `PKNCAconc(sparse = TRUE)` their `_se` and `_df` companions are calculated with them. The `sparse_*` names are deprecated. +- **Tobit with imputation** — until the LLOQ is carried onto imputed rows (pknca PR 678), combining `hl_method = "tobit"` with `impute = "start_conc0"` errors; examples show Tobit without imputation. - **`normalize()`** — replaces the original parameters (does not append). To keep both, `bind_rows()` before and after. - **New option defaults** — always verify with `PKNCA.options("option_name")` before documenting the default. @@ -210,26 +211,32 @@ Template: ``` _quarto.yml # book config (title, author, chapters) index.qmd # introduction + What's new -user/ # 19 User Guide chapters - workflow.qmd # PKNCAconc → PKNCAdose → PKNCAdata → pk.nca; all options - intervals.qmd # interval data frame; full parameter catalog - auc-methods.qmd # AUC types; integration methods; AUMC; aucabove; time_above - halflife.qmd # λz regression; Tobit; quality filters; lambda.z.corrxy - extravascular.qmd # oral/SC; BLQ; tmin; lag time - intravascular.qmd # IV bolus; C0; IV infusion; ceoi; MRT - multiple-dose.qmd # steady-state parameters; tau detection - superposition.qmd # predicting multiple-dose from single-dose - tss.qmd # time to steady state - urine-excretion.qmd# ae; fe; clr; volpk; ermax; ertmax; ertlst; clr.*.dn - sparse.qmd # sparse_auclast; sparse AUMC; derived sparse params - postprocessing.qmd # exclude(); normalize(); .dn parameters; PKNCA.set.summary - units.qmd # pknca_units_table(); preferred units; conversions - imputation.qmd # built-in imputation methods; custom methods - dose-aware-interpolation.qmd # interp.extrap.conc.dose; .dose AUCint variants - custom-parameters.qmd # add.interval.col(); registering new parameters - utilities.qmd # geomean; geocv; clean.conc.*; assert_conc_time - regulatory.qmd # PP domain; PPEXCLFL; summary() for CSR tables - validation.qmd # testthat suite; version pinning; spot-checks +user/ # 25 User Guide chapters (order in _quarto.yml) + workflow.qmd # PKNCAconc → PKNCAdose → PKNCAdata → pk.nca + intervals.qmd # Interval data frame + auto-intervals.qmd # Interval builder vs legacy + auc-methods.qmd # auclast/aucall/aucinf + halflife.qmd # Point selection controls + extravascular.qmd # BLQ handling + intravascular.qmd # IV bolus + multiple-dose.qmd # deg.fluc + superposition.qmd # Predicting multiple-dose profiles from single-dose data + tss.qmd # pk.tss.monoexponential + urine-excretion.qmd # ae + secondary-parameters.qmd # interval_id/_ref + sparse.qmd # Serial/batch designs + sparse-uncertainty.qmd # Yuan vs delta method (sparse_lambda_z_se) + bioequivalence.qmd # be_assess() + data-quality.qmd # pknca_missing_samples() + postprocessing.qmd # exclude() + units.qmd # pknca_units_table() + datetime.qmd # POSIXct/Date times + imputation.qmd # start_conc0 + dose-aware-interpolation.qmd # interp.extrap.conc.dose + custom-parameters.qmd # add.interval.col() + utilities.qmd # geomean + regulatory.qmd # CDISC output + validation.qmd # testthat suite dev/ # Developer Reference architecture.qmd # class hierarchy; interval column system; formalsmap function-deps.qmd # parameter dependency graph diff --git a/README.md b/README.md index 88463bf..4d99e3b 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ [![Render and deploy book](https://github.com/humanpred/pknca-book/actions/workflows/book.yml/badge.svg)](https://github.com/humanpred/pknca-book/actions/workflows/book.yml) -A comprehensive **Quarto book** documenting the [PKNCA](https://github.com/humanpred/pknca) R package (≥ 0.12.2) with runnable examples throughout. Every parameter, option, and user-facing function is covered and verified against live PKNCA output; the underlying `pk.calc.*` calculation functions are indexed in the Developer Reference. +A comprehensive **Quarto book** documenting the [PKNCA](https://github.com/humanpred/pknca) R package (≥ 0.13.0) with runnable examples throughout. Every parameter, option, and user-facing function is covered and verified against live PKNCA output; the underlying `pk.calc.*` calculation functions are indexed in the Developer Reference. **Live book:** https://humanpred.github.io/pknca-book/ ## Build locally ```r -# Install PKNCA 0.12.2 +# Install PKNCA 0.13.0 or later remotes::install_github("humanpred/pknca") ``` @@ -22,29 +22,35 @@ open _book/index.html ## Contents -### User Guide (19 chapters) +### User Guide (25 chapters) | Chapter | Topics | |---|---| -| [Workflow](user/workflow.qmd) | PKNCAconc → PKNCAdose → PKNCAdata → pk.nca; all 21 options with defaults | -| [Interval selection & parameter catalog](user/intervals.qmd) | Interval data frame structure; group matching; the full parameter catalog, rendered live from the registry | -| [AUC types & integration methods](user/auc-methods.qmd) | auclast/aucall/aucinf; partial AUC; lin up/log down vs linear vs lin-log; aucabove; time_above; AUMC; cav.int | -| [Half-life & terminal phase](user/halflife.qmd) | Curve stripping; adj.r.squared.factor; quality filters; Tobit regression; lambda.z.corrxy | -| [Extravascular (oral/SC)](user/extravascular.qmd) | BLQ handling; imputation strategies; tmin; lag time; multiple peaks | -| [Intravascular (IV)](user/intravascular.qmd) | IV bolus NCA; C0 back-extrapolation; IV infusion with ceoi; MRT correction | -| [Multiple-dose / steady-state](user/multiple-dose.qmd) | deg.fluc; swing; PTR; Cav; tau detection | +| [Workflow](user/workflow.qmd) | PKNCAconc → PKNCAdose → PKNCAdata → pk.nca; default intervals; all options with defaults; update() | +| [Interval selection & parameter catalog](user/intervals.qmd) | Interval data frame; pknca_interval_table(); presets; interval_add_*(); impute columns; validation; the parameter catalog, rendered live | +| [Automatic intervals & dosing regimens](user/auto-intervals.qmd) | Interval builder vs legacy; find.dose.regimen(); find.tau(); nominal times; regimen changes; classed warnings | +| [AUC types & integration methods](user/auc-methods.qmd) | auclast/aucall/aucinf; dose-aware partial AUCs; lin up/log down vs linear vs lin-log; AUMC; aumcint; aumciv | +| [Half-life & terminal phase](user/halflife.qmd) | Point selection controls; Tobit regression; exclusion rules; get_halflife_fit()/get_halflife_curve(); lambda.z.corrxy | +| [Extravascular (oral/SC)](user/extravascular.qmd) | BLQ handling; imputation; tmin; lag time; f.obs pointer; cl/vz/vss/mrt families | +| [Intravascular (IV)](user/intravascular.qmd) | IV bolus; C0 back-extrapolation; auciv*; IV infusion; cl.iv/vz.iv; MRT correction | +| [Multiple-dose / steady-state](user/multiple-dose.qmd) | deg.fluc; swing; PTR; Cav; tau detection; mrt.md/vss.md; accumulation | | [Superposition](user/superposition.qmd) | Predicting multiple-dose profiles from single-dose data | | [Time to steady state](user/tss.qmd) | pk.tss.monoexponential; pk.tss.stepwise.linear; pk.tss | -| [Urine excretion](user/urine-excretion.qmd) | ae; fe; clr.*; volpk; ermax/ertmax/ertlst; clr.*.dn | -| [Sparse PK sampling](user/sparse.qmd) | sparse_auclast + SE + df; sparse AUMC; mrt/cl/kel.sparse.last | -| [Post-processing](user/postprocessing.qmd) | exclude(); normalize(); 17 .dn parameters; PKNCA.set.summary; get_halflife_points | -| [Units](user/units.qmd) | pknca_units_table() S3 generic; PKNCAdata method; preferred units; conversions | -| [Imputation](user/imputation.qmd) | start_conc0; start_predose; start_cmin; custom imputation methods | -| [Dose-aware interpolation](user/dose-aware-interpolation.qmd) | interp.extrap.conc.dose; .dose AUCint variants | -| [Custom parameters](user/custom-parameters.qmd) | add.interval.col; registering new NCA parameters | -| [Utility functions](user/utilities.qmd) | geomean; geocv; clean.conc.*; assert_conc_time | -| [Regulatory & CDISC](user/regulatory.qmd) | PP domain column mapping; CDISC output format; exclusion tracking; summary() for CSR tables | -| [Validation & testing](user/validation.qmd) | testthat suite; version pinning; manual AUClast spot-check | +| [Urine excretion](user/urine-excretion.qmd) | ae; fe; renal clearance in one run; volpk; ermax/ertmax/ertlst; erint/erlst | +| [Secondary parameters](user/secondary-parameters.qmd) | interval_id/_ref; interval_add_secondary(); bioavailability (f.*); ratio.*; renal clearance; composed units | +| [Sparse PK sampling](user/sparse.qmd) | Serial/batch designs; auclast etc. with _se/_df; start_conc0; BLQ rule; summary with SE; sparse_bootstrap() | +| [Sparse AUC to infinity & its uncertainty](user/sparse-uncertainty.qmd) | Yuan vs delta method (sparse_lambda_z_se); IV bolus C0; recommendations; prior work | +| [Bioequivalence](user/bioequivalence.qmd) | be_assess(); ABE/ABEL/RSABE/NTID/HVNTID/descriptive; crossover, replicate, parallel; sparse bootstrap route | +| [Data quality checks](user/data-quality.qmd) | pknca_missing_samples(); exclude_nca_tmax_coverage(); LLOQ and input validation | +| [Post-processing](user/postprocessing.qmd) | exclude(); exclusion rules; normalize(); .dn parameters; summary() options; PKNCA.set.summary | +| [Units](user/units.qmd) | pknca_units_table(); automatic units; preferred units; timeu and tau; conversions | +| [Date-time data & time references](user/datetime.qmd) | POSIXct/Date times; time references; date-time intervals; PPSTINT/PPENINT/PPTPTREF/PPRFTDTC | +| [Imputation](user/imputation.qmd) | start_conc0; start_predose; start_predose_conc0; start_cmin; end_conc_drop; chaining order; custom methods | +| [Dose-aware interpolation](user/dose-aware-interpolation.qmd) | interp.extrap.conc.dose; dose-aware aucint rules | +| [Custom parameters](user/custom-parameters.qmd) | add.interval.col(); FUN_sparse; pknca_ref(); registering new NCA parameters | +| [Utility functions](user/utilities.qmd) | geomean; geocv; clean.conc.*; assert_*; get.parameter.deps(); route helpers | +| [Regulatory & CDISC](user/regulatory.qmd) | CDISC output; pknca_cdisc_codes(); PPANMETH; PPGRPID; exclusion tracking; summary() for CSR tables | +| [Validation & testing](user/validation.qmd) | testthat suite; version pinning; manual AUClast spot-check; PPANMETH | ### Developer Reference (2 chapters) @@ -53,16 +59,9 @@ open _book/index.html | [Architecture](dev/architecture.qmd) | Internal structure of PKNCA | | [Function dependencies](dev/function-deps.qmd) | Parameter dependency graph | -## What's new in PKNCA 0.12.2 +## What's new in PKNCA 0.13.0 -- **Tobit regression** for half-life — handles BLQ terminal-phase observations via censored likelihood -- **`normalize()` / `normalize_by_col()`** — normalize results by any column (body weight, BSA, etc.) -- **`tmin`** — time of minimum concentration; **`first.tmin`** option -- **Sparse AUMC** (`sparse_aumclast`, SE, df) and derived parameters (`mrt.sparse.last`, `cl.sparse.last`, `kel.sparse.last`) -- **New urine parameters**: `volpk`, `ermax`, `ertmax`, `ertlst`, `clr.last.dn`, `clr.obs.dn`, `clr.pred.dn` -- **`lambda.z.corrxy`** — correlation between time and log-concentration for the λz regression window -- **`pknca_units_table()`** now an S3 generic with a `PKNCAdata` method -- **`allow_partial_missing_units`** option +See the book's [introduction](index.qmd) for the full list. Highlights: automatic intervals rebuilt from the dosing regimen (breaking); secondary parameters (bioavailability `f.obs` etc., ratios, renal clearance) in one run; bioequivalence (`be_assess()`); sparse NCA with standard parameter names, the delta-method AUC to infinity, and `sparse_bootstrap()`; dose-aware partial AUCs; data quality checks; date-time data and CDISC time references. ## Related diff --git a/_quarto.yml b/_quarto.yml index ec45cbf..3f6390b 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -9,7 +9,7 @@ book: title: "PKNCA: Noncompartmental Analysis in R" author: "Teun M. Post" date: today - description: "A comprehensive guide to the PKNCA R package (≥ 0.12.2) — covering every function, parameter, and workflow with runnable examples." + description: "A comprehensive guide to the PKNCA R package (≥ 0.13.0) — covering every function, parameter, and workflow with runnable examples." repo-url: https://github.com/humanpred/pknca-book repo-actions: [issue, edit] favicon: "" @@ -27,6 +27,7 @@ book: chapters: - user/workflow.qmd - user/intervals.qmd + - user/auto-intervals.qmd - user/auc-methods.qmd - user/halflife.qmd - user/extravascular.qmd @@ -35,9 +36,14 @@ book: - user/superposition.qmd - user/tss.qmd - user/urine-excretion.qmd + - user/secondary-parameters.qmd - user/sparse.qmd + - user/sparse-uncertainty.qmd + - user/bioequivalence.qmd + - user/data-quality.qmd - user/postprocessing.qmd - user/units.qmd + - user/datetime.qmd - user/imputation.qmd - user/dose-aware-interpolation.qmd - user/custom-parameters.qmd diff --git a/index.qmd b/index.qmd index 595de77..9fb816f 100644 --- a/index.qmd +++ b/index.qmd @@ -10,7 +10,7 @@ pknca_ver <- as.character(packageVersion("PKNCA")) This book was built against **PKNCA `r pknca_ver`**. Examples are verified against that version. For the function reference, see the [pkgdown site](https://humanpred.github.io/pknca/reference/index.html). ::: -**PKNCA** is an R package for automated noncompartmental analysis (NCA). It calculates PK parameters (AUC, Cmax, half-life, clearance, volume, and many more) from concentration-time data, supports IV and extravascular routes, urine excretion, sparse sampling, multiple-dose designs, and CDISC-aligned output. This book documents PKNCA ≥ 0.12.2 with runnable examples throughout. For the package authors' own orientation material, see the vignettes [Introduction to PKNCA and Usage Instructions](https://humanpred.github.io/pknca/articles/v01-introduction-and-usage.html) and [PKNCA – an R package for noncompartmental analysis of pharmacokinetic data](https://humanpred.github.io/pknca/articles/v31-FDA-introduction.html). +**PKNCA** is an R package for automated noncompartmental analysis (NCA). It calculates PK parameters (AUC, Cmax, half-life, clearance, volume, and many more) from concentration-time data, supports IV and extravascular routes, urine excretion, sparse sampling, multiple-dose designs, and CDISC-aligned output. This book documents PKNCA ≥ 0.13.0 with runnable examples throughout. For the package authors' own orientation material, see the vignettes [Introduction to PKNCA and Usage Instructions](https://humanpred.github.io/pknca/articles/v01-introduction-and-usage.html) and [PKNCA – an R package for noncompartmental analysis of pharmacokinetic data](https://humanpred.github.io/pknca/articles/v31-FDA-introduction.html). ## What is NCA? @@ -61,18 +61,23 @@ The vignette [Computing NCA Parameters for Theophylline](https://humanpred.githu For bioavailability, IV data serve as the reference against which extravascular exposure is compared. -## What's new in 0.12.2 - -- **`normalize_by_col()` / `normalize()`** — normalize results by a column in the concentration data (`normalize_by_col()`) or a custom normalization table (`normalize()`) -- **`tmin` parameter** — time of minimum observed concentration -- **Tobit regression** for half-life, including full `pk.nca()` pipeline support via `PKNCAconc(lloq = )` -- **CDISC output format** — `as.data.frame(results, out_format = "cdisc")` translates parameter codes and adds PPTEST -- **Expanded derived parameters** — the `cl.*`, `kel.*`, `mrt.*`, `vss.*`, and `vz.*` families grew substantially -- **Sparse AUMC** and five new sparse-derived parameters (`cl.sparse.last`, `kel.sparse.last`, `mrt.ivint.last`, `vss.sparse.last`, `vz.sparse.last`) -- **New excretion parameters**: `volpk`, `ermax`, `ertmax`, `ertlst`, dose-normalized renal clearance (`clr.*.dn`) -- **`lambda.z.corrxy`** — correlation between x and y in the λz regression -- **`pknca_units_table()`** is now an S3 generic with a `PKNCAdata` method -- **`get_halflife_points()`** works directly on `PKNCAdata` objects; handles `start ≠ 0` correctly +## What's new in 0.13.0 + +PKNCA 0.13.0 is a large release; the package [NEWS](https://humanpred.github.io/pknca/news/index.html) lists every change. Changes that alter default results are marked **breaking**. + +- **Automatic intervals rebuilt (breaking)** — when no intervals are given, `PKNCAdata()` builds them from the dosing regimen: a single dose gets one interval from the dose to infinity with `start_predose_conc0` imputation, and repeated dosing gets an interval between each pair of sampled consecutive doses, chosen segment by segment when the regimen changes. `auto.interval.method = "legacy"` restores the previous behavior. See [Automatic Intervals and Dosing Regimens](user/auto-intervals.qmd). +- **Dosing-regimen detection** — `find.dose.regimen()` and a reworked `find.tau()`, nominal times, and time units for `tau`. +- **Secondary parameters in one run** — bioavailability, accumulation and metabolite ratios, and renal clearance computed across intervals and groups with `interval_id`/`*_ref` columns and `interval_add_secondary()`. See [Secondary Parameters](user/secondary-parameters.qmd). +- **Bioavailability renamed (breaking)** — `f` is now `f.obs`, alongside `f.pred`, `f.last`, and the `f.int.*` family. +- **Bioequivalence** — the `be_*` functions assess average, scaled, and narrow-therapeutic-index bioequivalence and descriptive comparisons. See [Bioequivalence](user/bioequivalence.qmd). +- **Sparse NCA unified (breaking names)** — sparse results use the standard parameter names (`auclast`, `aucinf.obs`, `aucivinf.obs`, …) with `_se` and `_df` companions; the AUC to infinity includes the uncertainty of λz by the delta method; IV bolus, batch designs, and `sparse_bootstrap()` are supported. See [Sparse PK Sampling](user/sparse.qmd). +- **Dose-aware partial AUCs (breaking)** — every `aucint`/`aumcint` parameter is dose-aware; the `.dose` variants are retired. +- **Data quality checks** — `pknca_missing_samples()` and the `exclude_nca_tmax_coverage()` exclusion rule. See [Data Quality Checks](user/data-quality.qmd). +- **Date-time data and CDISC output** — POSIXct/Date times, SDTM time references (PPSTINT, PPENINT, PPTPTREF, PPRFTDTC), PPGRPID, and `as.data.frame(results, out_format = "cdisc")`. See [Date-Time Data and Time References](user/datetime.qmd) and [Regulatory & CDISC](user/regulatory.qmd). +- **Half-life** — Tobit regression through the full `pk.nca()` pipeline (`PKNCAconc(lloq = )`), point-selection controls, `lambda.z.corrxy`, and `get_halflife_fit()`/`get_halflife_curve()` for plotting the fit. +- **Interval tools** — `pknca_interval_table()`, `pknca_presets()`, `pknca_parameter_table()`, and the `interval_add_param()` family. +- **Expanded parameters** — the `cl.*`, `kel.*`, `mrt.*`, `vss.*`, and `vz.*` families, `aumciv*`, `aumcint*`, `tmin`, and excretion parameters (`volpk`, `ermax`, `ertmax`, `ertlst`, `clr.*.dn`). +- **Post-processing and units** — `normalize()`/`normalize_by_col()`, and `pknca_units_table()` built automatically by `PKNCAdata()`. --- From 58da65d2360b117abfacf1cd85f22374ce99d8cf Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sat, 10 Oct 2026 02:31:29 +0000 Subject: [PATCH 09/12] docs: address the review of the 0.13.0 book update Corrections from a review against the package source and the rendered output: min.hl.r.squared is applied by the exclusion rule, not the fit; duration-based samples are selected by collection start; the allowed interval columns include tau, conc_above, and interval_time_kind; the tau.choices default uses the nominal set; per-subject variability in the multiple-dose simulation; the Tmax exclusion rules; PPSTRES appears only with unit conversions; unexported assert_* functions removed; pk.nca() uses a copy of the global options; the Tobit comparison drops BLQ points from the OLS fit; a broken table, US spelling, and smaller wording fixes. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 8 ++++---- README.md | 2 +- dev/architecture.qmd | 4 ++-- user/auto-intervals.qmd | 2 +- user/extravascular.qmd | 4 ++-- user/halflife.qmd | 9 ++++++--- user/intervals.qmd | 18 +++++++++++++----- user/intravascular.qmd | 22 ++++++++++++++-------- user/multiple-dose.qmd | 15 +++++++++------ user/postprocessing.qmd | 6 +++--- user/regulatory.qmd | 4 ++-- user/secondary-parameters.qmd | 2 +- user/sparse-uncertainty.qmd | 3 ++- user/sparse.qmd | 4 ++-- user/urine-excretion.qmd | 4 ++-- user/utilities.qmd | 4 ++-- user/workflow.qmd | 2 +- 17 files changed, 67 insertions(+), 46 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d153da..494755b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,7 +105,7 @@ as.data.frame(o_nca) |> - **Tobit half-life** — set `lloq` on `PKNCAconc()`; in PKNCA ≥ 0.13.0 it is passed through to `pk.calc.half.life()` automatically, so the full `pk.nca()` pipeline works with `options = list(hl_method = "tobit")`. (Older versions required calling `pk.calc.half.life()` directly.) - **Sparse parameters** — use the standard names (`auclast`, `aucinf.obs`, …); with `PKNCAconc(sparse = TRUE)` their `_se` and `_df` companions are calculated with them. The `sparse_*` names are deprecated. - **Tobit with imputation** — until the LLOQ is carried onto imputed rows (pknca PR 678), combining `hl_method = "tobit"` with `impute = "start_conc0"` errors; examples show Tobit without imputation. -- **`normalize()`** — replaces the original parameters (does not append). To keep both, `bind_rows()` before and after. +- **`normalize()`** — on a data frame it replaces the original parameters (does not append); on a `PKNCAresults` object it appends the normalized rows. - **New option defaults** — always verify with `PKNCA.options("option_name")` before documenting the default. --- @@ -132,7 +132,7 @@ as.data.frame(o_nca) |> - Add new sections or update existing ones in the relevant `.qmd` files. - Tag new features with `(≥ X.Y.Z)` so readers know when they were introduced. - Keep the shared dataset pattern consistent across pages. -- If the new version introduces new functions, add them to the `:::callout-note` pkgdown reference footer at the bottom of the relevant chapter(s). The link pattern is `[function()]( https://humanpred.github.io/pknca/reference/function.html)`. +- If the new version introduces new functions, add them to the `:::callout-note` pkgdown reference footer at the bottom of the relevant chapter(s). The link pattern is `[function()](https://humanpred.github.io/pknca/reference/function.html)`. --- @@ -142,7 +142,7 @@ as.data.frame(o_nca) |> quarto render ``` -- Watch for **red error output** in the console — chunks that error are flagged even though `execute: error: false` suppresses them in the HTML output. +- Watch for **red error output** in the console — with `execute: error: false`, a chunk that errors stops the render. - Fix the code, not the error suppression. Then open the book and spot-check every updated page: @@ -234,7 +234,7 @@ user/ # 25 User Guide chapters (order in _quarto.yml) imputation.qmd # start_conc0 dose-aware-interpolation.qmd # interp.extrap.conc.dose custom-parameters.qmd # add.interval.col() - utilities.qmd # geomean + utilities.qmd # clean.conc.* regulatory.qmd # CDISC output validation.qmd # testthat suite dev/ # Developer Reference diff --git a/README.md b/README.md index 4d99e3b..21f2624 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ open _book/index.html | [Imputation](user/imputation.qmd) | start_conc0; start_predose; start_predose_conc0; start_cmin; end_conc_drop; chaining order; custom methods | | [Dose-aware interpolation](user/dose-aware-interpolation.qmd) | interp.extrap.conc.dose; dose-aware aucint rules | | [Custom parameters](user/custom-parameters.qmd) | add.interval.col(); FUN_sparse; pknca_ref(); registering new NCA parameters | -| [Utility functions](user/utilities.qmd) | geomean; geocv; clean.conc.*; assert_*; get.parameter.deps(); route helpers | +| [Utility functions](user/utilities.qmd) | clean.conc.*; interp.extrap.conc(); check.interval.specification(); get.parameter.deps(); route helpers; assert_* | | [Regulatory & CDISC](user/regulatory.qmd) | CDISC output; pknca_cdisc_codes(); PPANMETH; PPGRPID; exclusion tracking; summary() for CSR tables | | [Validation & testing](user/validation.qmd) | testthat suite; version pinning; manual AUClast spot-check; PPANMETH | diff --git a/dev/architecture.qmd b/dev/architecture.qmd index eda34f1..866f0d5 100644 --- a/dev/architecture.qmd +++ b/dev/architecture.qmd @@ -63,7 +63,7 @@ flowchart TD A2 --> A3["Units table: given, or pknca_units_table()"] A3 --> B["pk.nca()"] B --> B1["assert_intervals(); convert date-time
times and intervals to numbers"] - B1 --> B2["Merge the object's options into PKNCA.options()"] + B1 --> B2["Overlay the object's options on a
copy of PKNCA.options()"] B2 --> B3["Expand reference intervals for
secondary parameters"] B3 --> B4["full_join_PKNCAdata(): split concentration,
dose, and interval data by group"] B4 --> C["pk.nca.intervals(): one call per group"] @@ -169,7 +169,7 @@ PKNCA.options(min.hl.points = old_val) PKNCA.options("min.hl.points") ``` -> Prefer passing `options = list(...)` to `PKNCAdata()` over mutating global options. Global changes persist across analyses in the same session. `pk.nca()` merges the object's options into the global ones at the start of each run, and `PKNCA_options_defaults()` (≥ 0.13.0) returns the package defaults without resetting anything. +> Prefer passing `options = list(...)` to `PKNCAdata()` over mutating global options. Global changes persist across analyses in the same session. At the start of each run, `pk.nca()` overlays the object's options on a copy of the global options and uses that copy for the calculations; the global options themselves are not modified. `PKNCA_options_defaults()` (≥ 0.13.0) returns the package defaults without resetting anything. ## Sparse PK diff --git a/user/auto-intervals.qmd b/user/auto-intervals.qmd index decd05a..f660a78 100644 --- a/user/auto-intervals.qmd +++ b/user/auto-intervals.qmd @@ -421,7 +421,7 @@ tryCatch( | `pknca_message_tau_units_fallback` | The units package is not installed, so times in a unit other than `"hr"` cannot be compared with the nominal intervals | | `pknca_error_no_intervals` | No group got an automatically generated interval | -The first four and `pknca_warning_tau_undetermined` share the parent class `pknca_warning_dose_regimen`. A script that has reviewed its regimens can therefore handle all of them at once without silencing other warnings: +The first four, `pknca_warning_intervals_nominal_restart`, and `pknca_warning_tau_undetermined` share the parent class `pknca_warning_dose_regimen`. A script that has reviewed its regimens can therefore handle all of them at once without silencing other warnings: ```{r} o_data_reviewed <- withCallingHandlers( diff --git a/user/extravascular.qmd b/user/extravascular.qmd index f8bf9df..9139bfa 100644 --- a/user/extravascular.qmd +++ b/user/extravascular.qmd @@ -92,9 +92,9 @@ as.data.frame(o_nca) |> | `clast.pred` | Predicted concentration at `tlast` (from λz fit) | | `count_conc` | Number of non-missing concentrations (including BLQ) | | `count_conc_measured` | Number of concentrations above LOQ (non-BLQ) | +| `lambda.z.time.last` | Last timepoint used in the λz regression | Both counts are taken after imputation and do not distinguish imputed from measured values: an imputed zero (`start_conc0`) adds to `count_conc` but not to `count_conc_measured`, while a concentration carried to the start (`start_predose`) adds to both. To count only measured samples, calculate the counts in an interval without imputation. -| `lambda.z.time.last` | Last timepoint used in the λz regression | ```{r} lm_interval <- data.frame( @@ -230,7 +230,7 @@ For extravascular dosing the fraction absorbed (F) is unknown, so clearance and | `cl.obs` | Apparent clearance = Dose / AUCinf.obs (this is CL/F) | | `vz.obs` | Apparent volume = CL.obs / λz (this is Vz/F) | -Each clearance and volume is available for every AUC basis, named by its suffix: `.obs` and `.pred` (AUCinf with observed or predicted Clast), `.last` (AUClast), `.all` (AUCall), and `.int.last`, `.int.all`, `.int.inf.obs`, `.int.inf.pred` (the interval AUCs, `aucint.*`). PKNCA 0.13.0 completed these families for `cl.*`, `vz.*`, `vss.*`, `mrt.*`, and `kel.*` (for example `cl.all`, `vz.last`, `vss.int.last`, `mrt.int.inf.obs`); the full list is printed at the end of this chapter. +Each clearance and volume is available for every AUC basis, named by its suffix: `.obs` and `.pred` (AUCinf with observed or predicted Clast), `.last` (AUClast), `.all` (AUCall), and `.int.last`, `.int.all`, `.int.inf.obs`, `.int.inf.pred` (the interval AUCs, `aucint.*`). PKNCA 0.13.0 completed these families for `cl.*`, `vz.*`, `vss.*`, `mrt.*`, and `kel.*` (for example `vz.last`, `vss.int.last`, `mrt.int.inf.obs`); the full list is printed at the end of this chapter. ### Lag time (tlag) diff --git a/user/halflife.qmd b/user/halflife.qmd index 102ceff..70898c1 100644 --- a/user/halflife.qmd +++ b/user/halflife.qmd @@ -336,9 +336,10 @@ Tobit can be called directly through `pk.calc.half.life()` or used in the full ` conc_tobit <- c(0, 2.5, 4.8, 4.2, 2.9, 1.4, 0.6, 0.05, 0.01) time_tobit <- c(0, 0.5, 1, 2, 4, 8, 12, 16, 24) -# OLS reference -hl_ols <- pk.calc.half.life(conc = conc_tobit, time = time_tobit, tmax = 1, - tlast = 24, hl_method = "log-linear") +# OLS reference — the BLQ observations (16 and 24 h) are dropped +is_blq <- conc_tobit > 0 & conc_tobit < 0.1 +hl_ols <- pk.calc.half.life(conc = conc_tobit[!is_blq], time = time_tobit[!is_blq], + tmax = 1, tlast = 12, hl_method = "log-linear") # Tobit — includes the BLQ observations as left-censored at the scalar lloq hl_tobit <- pk.calc.half.life(conc = conc_tobit, time = time_tobit, tmax = 1, @@ -353,6 +354,8 @@ data.frame( ) ``` +With the two BLQ samples dropped, the log-linear fit uses the 4 remaining points after Tmax and gives a half-life of about 3.58 h. Tobit keeps the 16- and 24-hour samples as censored at the LLOQ, so its `lambda.z.n.points` of 6 counts them, and the steeper slope they imply gives a half-life of about 2.61 h. + ### Tobit through the `pk.nca()` pipeline (≥ 0.13.0) To run Tobit across a full dataset, declare the LLOQ on the concentration object with the `lloq` argument of `PKNCAconc()` (a column name or a scalar) and select the method with the `hl_method` option. Every non-missing `lloq` value must be a finite number greater than zero; anything else is an error (`pknca_error_conc_invalid_lloq`). Missing values are allowed, and an entirely `NA` column is accepted. diff --git a/user/intervals.qmd b/user/intervals.qmd index 27d73ce..56d1903 100644 --- a/user/intervals.qmd +++ b/user/intervals.qmd @@ -26,6 +26,8 @@ Every remaining column is normally a **parameter flag**: its name must match a k - grouping columns used for matching (see the group-matching section below); - the imputation column, which may have any name and is the column that the `impute` argument of `PKNCAdata()` names (see [Imputation columns](#imputation-columns) below and the [Concentration Imputation chapter](imputation.qmd)); - an `interval_id` column and `_ref` columns, which link an interval to a reference interval for secondary parameters such as renal clearance, bioavailability, and ratios (≥ 0.13.0; see the [Secondary Parameters chapter](secondary-parameters.qmd)); and +- value columns that some parameters read from the interval: `conc_above` (the concentration threshold for `time_above`) and `tau` (the dosing interval for `mrt.md.*` and `vss.md.*`, ≥ 0.13.0; see the [Multiple-Dose and Steady-State chapter](multiple-dose.qmd)); +- `interval_time_kind`, which `pk.nca()` adds when it converts [date-time intervals](#date-time-intervals); and - columns carried through to the results via the `keep_interval_cols` option. Any other column is an error (`pknca_error_invalid_interval_columns`) when the intervals are checked by `PKNCAdata(intervals = )`, `set_intervals()`, or `pk.nca()` (≥ 0.13.0; see [Validating intervals](#validating-intervals)). @@ -173,7 +175,7 @@ Overlapping intervals are allowed and do not interfere with each other. Some sampling designs record a *duration* rather than an instantaneous sample — for example, urine collection periods or IV infusions. PKNCA tracks these via the `duration` argument in `PKNCAconc()` and `PKNCAdose()`. -When a concentration object carries duration information, PKNCA selects samples for an interval based on **both the start and end of the collection period**. The collection end time must fall at or before the interval `end` for the sample to be included: +When a concentration object carries duration information, PKNCA still selects samples for an interval by their recorded time, which is the **start** of the collection period: a collection is included when its start is at or after the interval `start` and at or before the interval `end`. The duration is not consulted during selection, so a collection that starts inside the interval but ends after `end` contributes its full amount to the interval. For results that are simple to interpret, align the collection boundaries with the interval boundaries, as here: ```{r duration-concept} # Urine collected 0-4, 4-8, and 8-24 h after a 100 mg oral dose @@ -242,7 +244,7 @@ pknca_interval_table( select(where(~ !is.logical(.x) || any(.x))) ``` -`include` and `exclude` accept parameter names or the concepts listed by `pknca_concepts()`, so `include = "mrt"` adds the MRT that matches the AUC basis and `exclude = "clearance"` drops `cl.obs`: +`include` and `exclude` accept parameter names or the concepts listed by `pknca_concepts()`, so `include = "mrt"` adds the MRT parameters the single-dose context calculates (`mrt.last` and `mrt.obs` in the output below) and `exclude = "clearance"` drops `cl.obs`: ```{r interval-table-include} pknca_interval_table(0, Inf, include = c("mrt", "aumclast"), exclude = "clearance") |> @@ -407,7 +409,7 @@ get_impute_column(d_int_named, impute = "start_conc0") --- -## Date-time intervals +## Date-time intervals {#date-time-intervals} With date-time (POSIXct or Date) concentration and dose times (≥ 0.13.0), `start` and `end` may also be date-times. `pk.nca()` converts each row to numeric time relative to the time reference (the first dose) of the group it applies to. A row that does not name every reference group becomes one row per group, because one clock-time window is a different relative window for each subject. An `end` of `Inf` stays infinite. @@ -529,7 +531,13 @@ valid_int <- data.frame( aucinf.obs = c(FALSE, TRUE), cmax = c(FALSE, TRUE) ) -check.interval.specification(valid_int) +valid_checked <- check.interval.specification(valid_int) + +# Every registered parameter column is filled in ... +ncol(valid_checked) +# ... but only the requested ones are TRUE +valid_checked |> + select(where(~ !is.logical(.x) || any(.x))) ``` An invalid interval (`start >= end`) is an error; here it is caught to show its class and message: @@ -546,7 +554,7 @@ tryCatch( ) ``` -`assert_intervals()` checks a specification against a `PKNCAdata` object, and `set_intervals()` checks it and then stores it in the object. They run whenever intervals are given to `PKNCAdata()`, and `pk.nca()` runs `assert_intervals()` again, so intervals assigned directly to `o_data$intervals` are checked too (≥ 0.13.0). Beyond the checks above, they require that every column is a group column, a parameter, the imputation column, `interval_id` or a `_ref` column, or listed in `keep_interval_cols`; that every `start` is finite and not missing; and that every `end` is after its `start` (`Inf` is allowed, but not missing, `NaN`, or `-Inf`). The errors name the offending columns or rows: +`assert_intervals()` checks a specification against a `PKNCAdata` object, and `set_intervals()` checks it and then stores it in the object. They run whenever intervals are given to `PKNCAdata()`, and `pk.nca()` runs `assert_intervals()` again, so intervals assigned directly to `o_data$intervals` are checked too (≥ 0.13.0). Beyond the checks above, they require that every column is a group column, a parameter, the imputation column, `conc_above`, `tau`, `interval_id`, a `_ref` column, `interval_time_kind`, or listed in `keep_interval_cols`; that every `start` is finite and not missing; and that every `end` is after its `start` (`Inf` is allowed, but not missing, `NaN`, or `-Inf`). The errors name the offending columns or rows: ```{r assert-intervals} o_data_check <- PKNCAdata(o_conc, o_dose, intervals = valid_int) diff --git a/user/intravascular.qmd b/user/intravascular.qmd index a73ce17..28d127b 100644 --- a/user/intravascular.qmd +++ b/user/intravascular.qmd @@ -211,21 +211,20 @@ as.data.frame(o_nca_hl) |> **Controlling half-life quality:** ```{r} -# Stricter: require at least 4 points and R² ≥ 0.95 +# Stricter: require at least 4 points in the fit; then exclude fits with +# R² < 0.95 (min.hl.r.squared is applied by the exclusion rule, not by the fit) o_data_strict <- PKNCAdata( o_conc, o_dose, intervals = hl_params, impute = "start_conc0", - options = list( - min.hl.points = 4, - min.hl.r.squared = 0.95 - ) + options = list(min.hl.points = 4) ) -o_nca_strict <- pk.nca(o_data_strict) +o_nca_strict <- pk.nca(o_data_strict) |> + exclude(FUN = exclude_nca_min.hl.r.squared(0.95)) as.data.frame(o_nca_strict) |> filter(PPTESTCD %in% c("half.life", "r.squared", "lambda.z.n.points")) |> - select(Subject, PPTESTCD, PPORRES) |> + select(Subject, PPTESTCD, PPORRES, exclude) |> arrange(Subject, PPTESTCD) ``` @@ -458,7 +457,14 @@ as.data.frame(o_nca_auciv) |> arrange(Subject, PPTESTCD) ``` -> **When to use `auciv*` vs `auclast`/`aucinf.obs`:** For an IV bolus sampled after the dose, `aucivlast` and `aucivinf.obs` give the more theoretically correct AUC, and `cl.iv.*` / `vz.iv.*` carry it into clearance and volume. `aucivpbext*` shows how much of that AUC came from back-extrapolation; for Indometh it is about 9–20% depending on the subject and AUC, so the choice matters here. +```{r} +#| include: false +pbext <- as.data.frame(o_nca_auciv) |> + filter(grepl("^aucivpbext", PPTESTCD)) |> + pull(PPORRES) +``` + +> **When to use `auciv*` vs `auclast`/`aucinf.obs`:** For an IV bolus sampled after the dose, `aucivlast` and `aucivinf.obs` give the more theoretically correct AUC, and `cl.iv.*` / `vz.iv.*` carry it into clearance and volume. `aucivpbext*` shows how much of that AUC came from back-extrapolation; for Indometh it is `r round(min(pbext))`–`r round(max(pbext))`% depending on the subject and AUC, so the choice matters here. --- diff --git a/user/multiple-dose.qmd b/user/multiple-dose.qmd index dc5300e..709d30d 100644 --- a/user/multiple-dose.qmd +++ b/user/multiple-dose.qmd @@ -45,10 +45,14 @@ C_ss <- function(t, ka, ke, V, dose, tau) { timepoints <- c(0, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 12) n_subjects <- 4 +# ~20% log-normal inter-individual variability (IIV) on the elimination rate +# constant: one ke_i per subject +d_ke_ss <- data.frame(Subject = factor(1:n_subjects), + ke_i = ke * exp(rnorm(n_subjects, 0, 0.2))) + d_conc_ss <- expand.grid(Subject = factor(1:n_subjects), time = timepoints) |> + left_join(d_ke_ss, by = "Subject") |> mutate( - # Add ~20% log-normal inter-individual variability (IIV) on the elimination rate constant (ke) - ke_i = ke * exp(rnorm(n(), 0, 0.2)), conc = mapply(C_ss, time, ka, ke_i, V, dose, tau) + rnorm(n(), 0, 0.05), conc = pmax(conc, 0) ) |> @@ -57,8 +61,7 @@ d_conc_ss <- expand.grid(Subject = factor(1:n_subjects), time = timepoints) |> d_dose_ss <- data.frame( Subject = factor(1:n_subjects), dose = dose, - time = 0, - route = "extravascular" + time = 0 ) ggplot(d_conc_ss, aes(x = time, y = conc, group = Subject, colour = Subject)) + @@ -110,7 +113,7 @@ as.data.frame(o_nca_ss) |> ### Cstart and Ctrough `cstart` is the concentration at the **start** of the interval (time = `start`). For steady state this equals the pre-dose trough. -`ctrough` is the concentration at the **end** of the interval (time = `end`). For steady state, `cstart` ≈ `ctrough`. +`ctrough` is the concentration at the **end** of the interval (time = `end`). At steady state the two are equal apart from measurement error; here they differ only by the simulated residual noise. ```{r} as.data.frame(o_nca_ss) |> @@ -293,7 +296,7 @@ as.data.frame(o_nca_md) |> arrange(Subject) ``` -For the one-compartment model used in the simulation, MRT is 1/ke + 1/ka ≈ 13.2 h at the typical ke of 0.08/h. The individual estimates spread around that value because ke varies between subjects. +For the one-compartment model used in the simulation, MRT is 1/ke + 1/ka ≈ 13.2 h at the typical ke of 0.08/h. The individual estimates (10.1 to 14.9 h) spread around that value because each subject has its own ke. If τ can be neither given nor detected, these parameters are `NA` with a `pknca_warning_tau_undetermined` warning. PKNCA does not fall back to the single-dose equation. For IV infusions, use `mrt.ivmd.obs`, `mrt.ivmd.pred`, `vss.ivmd.obs`, and `vss.ivmd.pred` (≥ 0.13.0). They subtract half the infusion duration, without which MRT is high by `duration/2` and Vss by CL × `duration/2`. diff --git a/user/postprocessing.qmd b/user/postprocessing.qmd index 1e144f8..f80ec3a 100644 --- a/user/postprocessing.qmd +++ b/user/postprocessing.qmd @@ -109,8 +109,8 @@ PKNCA provides ready-made rule functions to pass to `exclude(FUN=...)`. Unlike a | `exclude_nca_min.hl.r.squared(min.hl.r.squared)` | half-life unadjusted R² < `min.hl.r.squared` (default from `PKNCA.options("min.hl.r.squared")`) | | `exclude_nca_min.hl.adj.r.squared(min.hl.adj.r.squared = 0.9)` | half-life adjusted R² < `min.hl.adj.r.squared` (stricter: adjusted for the number of points) | | `exclude_nca_max.aucinf.pext(max.aucinf.pext)` | %AUCextrap > `max.aucinf.pext` (default from `PKNCA.options("max.aucinf.pext")`) | -| `exclude_nca_tmax_0()` | Tmax at or before 0 (shortcut for `exclude_nca_tmax_early(0)`) | -| `exclude_nca_tmax_early(tmax_early = 0)` | Tmax at or before a threshold time (`tmax_early`, default 0) | +| `exclude_nca_tmax_0()` | Tmax at or before 0 (`tmax <= 0`); excludes every parameter of the interval. Use this, not `exclude_nca_tmax_early(0)`, to catch a Tmax of exactly 0 | +| `exclude_nca_tmax_early(tmax_early = 0)` | Tmax strictly before `tmax_early` (`tmax < tmax_early`); excludes every parameter of the interval. With the default 0, only a negative Tmax is excluded | | `exclude_nca_count_conc_measured(min_count, exclude_param_pattern = ...)` | fewer than `min_count` measured concentrations; excludes the AUC-family parameters matching `exclude_param_pattern` | | `exclude_nca_by_param(parameter, min_thr = NULL, max_thr = NULL, affected_parameters = parameter)` (≥ 0.12.1) | the value of `parameter` falls outside `[min_thr, max_thr]`; excludes `affected_parameters` (default: `parameter` itself) | | `exclude_nca_tmax_coverage(min_subjects = 4, k_warn = 1.5, k_exclude = 3)` (≥ 0.13.0) | the subject has no sample within Tukey's outer fences around its group's Tmax values; excludes every result of the interval (subjects outside only the inner fences get a warning) | @@ -460,5 +460,5 @@ Imputation is a pre-calculation step, not a post-processing step. It is specifie --- ::: {.callout-note icon=false appearance="minimal"} -**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_*()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [exclude_nca_by_param()](https://humanpred.github.io/pknca/reference/exclude_nca_by_param.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [normalize()](https://humanpred.github.io/pknca/reference/normalize.html) · [normalize_by_col()](https://humanpred.github.io/pknca/reference/normalize_by_col.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [business.*()](https://humanpred.github.io/pknca/reference/business.mean.html) · [get_halflife_points()](https://humanpred.github.io/pknca/reference/get_halflife_points.html) · [get_halflife_fit()](https://humanpred.github.io/pknca/reference/get_halflife_fit.html) · [get_halflife_curve()](https://humanpred.github.io/pknca/reference/get_halflife_curve.html) · [exclude_nca_tmax_coverage()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [pknca_exclude_rules()](https://humanpred.github.io/pknca/reference/pknca_exclude_rules.html) · [filter()](https://humanpred.github.io/pknca/reference/filter.PKNCAresults.html) · [checkProvenance()](https://humanpred.github.io/pknca/reference/checkProvenance.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) +**pkgdown reference:** [PKNCAconc()](https://humanpred.github.io/pknca/reference/PKNCAconc.html) · [PKNCAdose()](https://humanpred.github.io/pknca/reference/PKNCAdose.html) · [PKNCAdata()](https://humanpred.github.io/pknca/reference/PKNCAdata.html) · [pk.nca()](https://humanpred.github.io/pknca/reference/pk.nca.html) · [as.data.frame()](https://humanpred.github.io/pknca/reference/as.data.frame.PKNCAresults.html) · [exclude()](https://humanpred.github.io/pknca/reference/exclude.html) · [exclude_nca_*()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [exclude_nca_by_param()](https://humanpred.github.io/pknca/reference/exclude_nca_by_param.html) · [summary()](https://humanpred.github.io/pknca/reference/summary.PKNCAresults.html) · [get.interval.cols()](https://humanpred.github.io/pknca/reference/get.interval.cols.html) · [normalize()](https://humanpred.github.io/pknca/reference/normalize.html) · [normalize_by_col()](https://humanpred.github.io/pknca/reference/normalize_by_col.html) · [PKNCA.set.summary()](https://humanpred.github.io/pknca/reference/PKNCA.set.summary.html) · [business.*()](https://humanpred.github.io/pknca/reference/business.mean.html) · [get_halflife_points()](https://humanpred.github.io/pknca/reference/get_halflife_points.html) · [get_halflife_fit()](https://humanpred.github.io/pknca/reference/get_halflife_fit.html) · [get_halflife_curve()](https://humanpred.github.io/pknca/reference/get_halflife_curve.html) · [exclude_nca_tmax_coverage()](https://humanpred.github.io/pknca/reference/exclude_nca.html) · [pknca_exclude_rules()](https://humanpred.github.io/pknca/reference/pknca_exclude_rules.html) · [filter()](https://humanpred.github.io/pknca/reference/filter.PKNCAresults.html) · [group_vars()](https://humanpred.github.io/pknca/reference/group_vars.PKNCAconc.html) · [checkProvenance()](https://humanpred.github.io/pknca/reference/checkProvenance.html) · [PKNCA.options()](https://humanpred.github.io/pknca/reference/PKNCA.options.html) ::: diff --git a/user/regulatory.qmd b/user/regulatory.qmd index 8d7c7a9..f4076f8 100644 --- a/user/regulatory.qmd +++ b/user/regulatory.qmd @@ -27,7 +27,7 @@ o_nca <- pk.nca(o_data) ## CDISC PP domain alignment -The CDISC PP domain stores one parameter value per row, with the parameter code in `PPTESTCD` and the original result in `PPORRES`. When a units table is provided, PKNCA additionally fills its own standardized-result column, `PPSTRES`; the corresponding CDISC SDTM variables are `PPSTRESC` (character) and `PPSTRESN` (numeric). PKNCA's `as.data.frame()` output follows exactly this long-format convention. +The CDISC PP domain stores one parameter value per row, with the parameter code in `PPTESTCD` and the original result in `PPORRES`. With any units table, PKNCA adds the original-unit column `PPORRESU`; when preferred units or custom conversions are requested, it also fills its standardized-result columns, `PPSTRES` and `PPSTRESU` (the corresponding CDISC SDTM variables are `PPSTRESC` (character), `PPSTRESN` (numeric), and `PPSTRESU`). PKNCA's `as.data.frame()` output follows exactly this long-format convention. ```{r result-columns} df <- as.data.frame(o_nca) @@ -46,7 +46,7 @@ The columns map to CDISC PP variables as follows: | `exclude` | `PPSTAT` / `PPREASND` (or a supplemental qualifier) | Non-`NA` string gives the exclusion reason | | Grouping columns (`Subject`, etc.) | `USUBJID`, `PPCAT` | Pass through unchanged | -Units (`PPORRESU`, `PPSTRESU`) are available when the data have units: either unit arguments (`concu`, `timeu`, `doseu`, ...) given to `PKNCAconc()` and `PKNCAdose()`, from which `PKNCAdata()` builds the units table automatically (≥ 0.13.0), or a table from `pknca_units_table()` passed to `PKNCAdata()`. Without units, `PPORRES` carries the raw numeric value and unit derivation must be done externally. For the full units workflow, see the [Units chapter](units.qmd) and [Unit Assignment and Conversion with PKNCA](https://humanpred.github.io/pknca/articles/v07-unit-conversion.html). +The `PPORRESU` column is added whenever the data have units: either unit arguments (`concu`, `timeu`, `doseu`, ...) given to `PKNCAconc()` and `PKNCAdose()`, from which `PKNCAdata()` builds the units table automatically (≥ 0.13.0), or a table from `pknca_units_table()` passed to `PKNCAdata()`. `PPSTRES` and `PPSTRESU` are added only when the units table requests preferred units (`concu_pref`, `timeu_pref`, ...) or custom conversions. Without units, `PPORRES` carries the raw numeric value and unit derivation must be done externally. For the full units workflow, see the [Units chapter](units.qmd) and [Unit Assignment and Conversion with PKNCA](https://humanpred.github.io/pknca/articles/v07-unit-conversion.html). ```{r result-head} head(df, 10) diff --git a/user/secondary-parameters.qmd b/user/secondary-parameters.qmd index 58c50e5..4cf6655 100644 --- a/user/secondary-parameters.qmd +++ b/user/secondary-parameters.qmd @@ -545,7 +545,7 @@ as.data.frame(o_nca_u) |> as.data.frame() ``` -There is no unit argument for the collection volume: `amountu` is the unit of the amount excreted (`conc × vol`), so the volume must be recorded in units that make that product an amount in `amountu`. Here the volumes are in litres, and mg/L × L = mg. +There is no unit argument for the collection volume: `amountu` is the unit of the amount excreted (`conc × vol`), so the volume must be recorded in units that make that product an amount in `amountu`. Here the volumes are in liters, and mg/L × L = mg. `clr.last` is reported in `mg/(hr*ng/mL)`: the `mg` from the urine collection that supplied the amount and the `hr*ng/mL` from the plasma profile that supplied the AUC. Its value, $26.65 / 47748 = 0.000558$, is the same number as before in different units; no value is converted. The units table that `PKNCAdata()` builds gains a `_ref` column and one row per pair of groups to make this possible: diff --git a/user/sparse-uncertainty.qmd b/user/sparse-uncertainty.qmd index 17b4f1e..387c1a1 100644 --- a/user/sparse-uncertainty.qmd +++ b/user/sparse-uncertainty.qmd @@ -146,7 +146,8 @@ o_dose_iv <- PKNCAdose(data.frame(dose = 100, time = 0), dose ~ time, route = "i o_nca_iv <- pk.nca(PKNCAdata( o_conc_iv, o_dose_iv, intervals = data.frame(start = 0, end = Inf, c0 = TRUE, aucivlast = TRUE, - aucivinf.obs = TRUE, aumcivinf.obs = TRUE, cl.iv.obs = TRUE) + aucivinf.obs = TRUE, aumcivinf.obs = TRUE, cl.iv.obs = TRUE, + mrt.iv.obs = TRUE, vss.iv.obs = TRUE) )) summary(o_nca_iv) ``` diff --git a/user/sparse.qmd b/user/sparse.qmd index 84a1e58..2b4e2dc 100644 --- a/user/sparse.qmd +++ b/user/sparse.qmd @@ -54,7 +54,7 @@ ggplot(d_serial, aes(x = time, y = conc)) + stat_summary(fun = mean, geom = "line", linewidth = 1, colour = "black") + stat_summary(fun = mean, geom = "point", size = 4, colour = "black", shape = 18) + labs( - title = "Serial sacrifice: individual samples (grey) and mean profile (black)", + title = "Serial sacrifice: individual samples (gray) and mean profile (black)", x = "Time (h)", y = "Concentration (mg/L)" ) + theme_minimal() @@ -122,7 +122,7 @@ See [Interval Selection and Parameter Catalog](intervals.qmd) for interval speci ## The mean profile and BLQ samples -The mean profile is the arithmetic mean of the samples at each timepoint, with one rule for samples below the limit of quantification (BLQ, coded as 0): if **strictly more than 50%** of the samples at a timepoint are BLQ, the mean at that timepoint is set to 0. At exactly 50% BLQ, the mean is calculated normally, with the BLQ values as 0. This method (`"arithmetic mean, <=50% BLQ"`) is not a user-facing option. +The mean profile is the arithmetic mean of the samples at each timepoint, with one rule for samples below the limit of quantification (BLQ, coded as 0): if **strictly more than 50%** of the samples at a timepoint are BLQ, the mean at that timepoint is set to 0. At exactly 50% BLQ, the mean is calculated normally, with the BLQ values as 0. This method (`"arithmetic mean, <=50% BLQ"`) is not an option of `pk.nca()` or `PKNCA.options()`; `sparse_mean()` takes it as its `sparse_mean_method` argument. To see the rule, censor the serial data at an LLOQ of 0.06 mg/L, which makes 3 of the 4 samples at 24 h BLQ. `sparse_mean()` calculates the mean profile from an `as_sparse_pk()` object: diff --git a/user/urine-excretion.qmd b/user/urine-excretion.qmd index 8ec163f..dcf1ec2 100644 --- a/user/urine-excretion.qmd +++ b/user/urine-excretion.qmd @@ -98,7 +98,7 @@ Requesting a parameter that needs a sample volume (`ae`, `fe`, `volpk`, `clr.*`) ## Renal clearance in one `pk.nca()` call (≥ 0.13.0) -Put the plasma and urine concentrations in one data frame, distinguished by a grouping column (here `specimen`), and give the urine volume through `volume`. Plasma rows have no volume. The volume is converted to litres so that `conc × volume` (mcg/mL × L = mg) is in the same mass unit as the dose and the plasma AUC (mg·h/L), which makes `fe` a fraction and `clr.*` come out in L/h: +Put the plasma and urine concentrations in one data frame, distinguished by a grouping column (here `specimen`), and give the urine volume through `volume`. Plasma rows have no volume. The volume is converted to liters so that `conc × volume` (mcg/mL × L = mg) is in the same mass unit as the dose and the plasma AUC (mg·h/L), which makes `fe` a fraction and `clr.*` come out in L/h: ```{r} d_conc_all <- bind_rows( @@ -195,7 +195,7 @@ as.data.frame(o_nca_ae) |> arrange(Subject) ``` -Note the units: `ae` here is in mcg (urine concentration in mcg/mL × volume in mL), so ~90,000 mcg matches the ~90 mg of the combined analysis above, where the volume was in litres. Units can be declared with the unit arguments of `PKNCAconc()`; there is no separate volume unit, so `amountu` is the unit of `conc × volume`. +Note the units: `ae` here is in mcg (urine concentration in mcg/mL × volume in mL), so ~90,000 mcg matches the ~90 mg of the combined analysis above, where the volume was in liters. Units can be declared with the unit arguments of `PKNCAconc()`; there is no separate volume unit, so `amountu` is the unit of `conc × volume`. --- diff --git a/user/utilities.qmd b/user/utilities.qmd index 63b0edf..fb3f880 100644 --- a/user/utilities.qmd +++ b/user/utilities.qmd @@ -303,10 +303,10 @@ Applications that assemble PKNCA objects from user data can run the same checks | Function | What it checks | |---|---| -| `assert_conc_time()` | Concentrations and times are valid numbers and the times are sorted (also `assert_conc()` and `assert_time()` separately) | +| `assert_conc_time()` | Concentrations and times are valid numbers and the times are sorted | | `assert_impute_method()` | An imputation specification resolves to known methods, the way `PKNCAdata()` and `pk.nca()` resolve it | | `PKNCA_impute_fun_list()` | The imputation function names an imputation string resolves to | -| `assert_PKNCAconc()`, `assert_PKNCAdose()`, `assert_PKNCAdata()`, `assert_PKNCAresults()` | The object has the expected class | +| `assert_PKNCAconc()`, `assert_PKNCAdose()`, `assert_PKNCAresults()` | The object has the expected class | ```{r} # Unsorted times are an error diff --git a/user/workflow.qmd b/user/workflow.qmd index 6f660e2..6a8192f 100644 --- a/user/workflow.qmd +++ b/user/workflow.qmd @@ -222,7 +222,7 @@ Details: the [Half-Life chapter](halflife.qmd), [Half-Life Calculation](https:// | `auto.interval.method` | `"builder"` | Where the parameters of automatically generated intervals come from: `"builder"` asks `pknca_interval_table()` for the parameters and imputation that suit each interval; `"legacy"` uses the pre-0.13.0 lists (`single.dose.aucs` for a single dose; AUClast, Cmax, and Tmax for each dosing interval) (≥ 0.13.0) | | `auto.interval.tolerance` | `0.05` | How far before an interval boundary a sample may be drawn and still count as the sample at that boundary, as a fraction of the interval length (a trough at 167.5 h still ends an interval nominally ending at 168 h) (≥ 0.13.0) | | `single.dose.aucs` | (data frame) | Intervals for single-dose data, used only with `auto.interval.method = "legacy"` | -| `tau.choices` | `NA` | Candidate dosing intervals (τ) considered when detecting the dosing interval of multiple-dose data; `NA` considers any interval | +| `tau.choices` | `NA` | Nominal dosing intervals (τ) that detected dosing intervals are matched to. `NA` uses the built-in set from every 4 hours to every 12 weeks (Q4H, QID, TID, BID, QD, ..., Q12W); a numeric vector replaces it. An interval that matches none is reported as found in the dose times, with a `pknca_warning_tau_not_nominal` warning | The [Automatic Intervals and Dosing Regimens chapter](auto-intervals.qmd) shows these options at work. From 9e1b513eacfc6f76bb4181bf3e791187f372f3a8 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Wed, 21 Oct 2026 13:12:05 -0400 Subject: [PATCH 10/12] ci: install cdiscdata from GitHub until its 0.2.0 reaches CRAN PKNCA 0.13.0 depends on cdiscdata 0.2.0 or later, which is still in CRAN review, so pak could not solve the book's dependencies; the workflow now installs it from humanpred/cdiscdata before PKNCA. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/book.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml index b4c7986..b2cae4f 100644 --- a/.github/workflows/book.yml +++ b/.github/workflows/book.yml @@ -45,6 +45,7 @@ jobs: any::lme4 any::lmerTest any::emmeans + github::humanpred/cdiscdata github::humanpred/pknca # Fails the job (non-zero exit) if any documented example check fails From 87776f20d9deaa42640b9b50a3614aa4b9d86fd0 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sun, 25 Oct 2026 22:10:26 -0400 Subject: [PATCH 11/12] ci: install cdiscdata from CRAN cdiscdata 0.2.0 is on CRAN, so the book's workflow installs it like the other packages instead of from GitHub. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/book.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml index b2cae4f..4da6811 100644 --- a/.github/workflows/book.yml +++ b/.github/workflows/book.yml @@ -45,7 +45,7 @@ jobs: any::lme4 any::lmerTest any::emmeans - github::humanpred/cdiscdata + cdiscdata github::humanpred/pknca # Fails the job (non-zero exit) if any documented example check fails From 729573b5becd8745cb390d11f8621c1d152e2321 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sun, 25 Oct 2026 22:10:48 -0400 Subject: [PATCH 12/12] ci: write cdiscdata with the any:: prefix like the other packages Co-Authored-By: Claude Fable 5.1 --- .github/workflows/book.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml index 4da6811..82897ef 100644 --- a/.github/workflows/book.yml +++ b/.github/workflows/book.yml @@ -45,7 +45,7 @@ jobs: any::lme4 any::lmerTest any::emmeans - cdiscdata + any::cdiscdata github::humanpred/pknca # Fails the job (non-zero exit) if any documented example check fails