23  Writing Custom Parameter Functions

23.1 The parameter registry

Every NCA parameter that PKNCA can compute is registered in an internal registry via add.interval.col(). When you request auclast = TRUE in an interval, PKNCA looks up the registered function for auclast and calls it with the right arguments.

You can extend the registry with your own parameters — new functions that compute anything derivable from concentrations, times, doses, or other already-computed parameters. See also the package vignette Writing PKNCA Parameter Functions.

# Number of parameters registered by default
length(get.interval.cols())
[1] 237

23.2 Anatomy of add.interval.col()

add.interval.col(
  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(<your function arg> = <data or parameter name>)
  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) 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.
FUN_sparse, formalsmap_sparse (≥ 0.13.0) A second calculation function used when the data are sparse; see Example 5.
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.

23.2.1 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"


23.3 The formalsmap argument

Each formalsmap entry has the form <your function's argument> = "<PKNCA data name>" — 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.

Values available in a formalsmap (the full list is in ?add.interval.col):

formalsmap value What it provides
"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(<value>) (≥ 0.13.0) A constant, passed to the function unchanged
pknca_ref("<parameter>") (≥ 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.


23.4 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). 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.

# Exposure ratio: AUClast / Cmax  (units: time, since AUC/conc = time × conc/conc = time)
pk.calc.exposure.ratio <- function(auclast, cmax) {
  auclast / cmax
}

add.interval.col(
  name        = "exposure.ratio",
  FUN         = "pk.calc.exposure.ratio",
  values      = c(FALSE, TRUE),
  unit_type   = "time",
  pretty_name = "Exposure ratio (AUClast / Cmax)",
  desc        = "AUClast divided by Cmax",
  depends     = c("auclast", "cmax"),
  formula     = "\\frac{AUC_{last}}{C_{max}}"
)
o_nca_er <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = data.frame(
  start          = 0, end = Inf,
  auclast        = TRUE,
  cmax           = TRUE,
  exposure.ratio = TRUE
)))

as.data.frame(o_nca_er) |>
  filter(PPTESTCD %in% c("auclast", "cmax", "exposure.ratio")) |>
  select(subject, PPTESTCD, PPORRES) |>
  tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |>
  mutate(check = auclast / cmax) |>
  arrange(subject)
# A tibble: 12 × 5
   subject auclast  cmax exposure.ratio check
   <ord>     <dbl> <dbl>          <dbl> <dbl>
 1 6          71.7  6.44          11.1  11.1 
 2 7          88.0  7.09          12.4  12.4 
 3 8          86.8  7.56          11.5  11.5 
 4 11         77.9  8              9.74  9.74
 5 3          95.9  8.2           11.7  11.7 
 6 2          88.7  8.33          10.7  10.7 
 7 4         103.   8.6           11.9  11.9 
 8 9          83.9  9.03           9.30  9.30
 9 12        115.   9.75          11.8  11.8 
10 10        136.  10.2           13.3  13.3 
11 1         147.  10.5           14.0  14.0 
12 5         118.  11.4           10.4  10.4 

23.5 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 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).

# 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.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()"
)
o_nca_c2 <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = data.frame(
  start = 0, end = Inf, c.2h = TRUE
)))

as.data.frame(o_nca_c2) |>
  filter(PPTESTCD == "c.2h") |>
  select(subject, PPORRES, PPANMETH) |>
  arrange(subject)
# A tibble: 12 × 3
   subject PPORRES PPANMETH                              
   <ord>     <dbl> <chr>                                 
 1 6          6.32 Interpolated with interp.extrap.conc()
 2 7          6.55 Interpolated with interp.extrap.conc()
 3 8          7.56 Interpolated with interp.extrap.conc()
 4 11         6.80 Interpolated with interp.extrap.conc()
 5 3          7.81 Interpolated with interp.extrap.conc()
 6 2          8.25 Interpolated with interp.extrap.conc()
 7 4          8.41 Interpolated with interp.extrap.conc()
 8 9          6.35 Interpolated with interp.extrap.conc()
 9 12         9.72 Interpolated with interp.extrap.conc()
10 10         7.76 Interpolated with interp.extrap.conc()
11 1          9.68 Interpolated with interp.extrap.conc()
12 5          9.37 Interpolated with interp.extrap.conc()

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:

as.data.frame(o_nca_c2, out_format = "cdisc") |>
  filter(subject == "1") |>
  select(subject, pknca_parameter, PPTESTCD, PPTEST, PPORRES)
  subject pknca_parameter PPTESTCD               PPTEST  PPORRES
1       1            c.2h      C2H Concentration at 2 h 9.677916

23.7 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 <parameter>_ref column. Here a custom parameter subtracts the reference interval’s aucint.last from the current one’s:

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 tibble: 6 × 6
  subject start   end PPTESTCD      PPORRES PPANMETH                            
  <ord>   <dbl> <dbl> <chr>           <dbl> <chr>                               
1 2           0     8 aucint.last      51.3 AUC: lin up/log down. Interpolation…
2 2           8    24 aucint.last      37.2 AUC: lin up/log down. Interpolation…
3 2           8    24 aucint.change   -14.1 Reference interval: 0-8             
4 1           0     8 aucint.last      65.3 AUC: lin up/log down. Interpolation…
5 1           8    24 aucint.last      80.7 AUC: lin up/log down. Interpolation…
6 1           8    24 aucint.change    15.4 Reference interval: 0-8             

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 and the Secondary Parameters vignette.


23.8 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.)

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)
# A tibble: 1 × 4
  start   end PPTESTCD   PPORRES
  <dbl> <dbl> <chr>        <int>
1     0    24 n.subjects       6

Requesting n.subjects for dense data is an error, and pknca_parameter_table() reports the parameter as sparse:

pknca_parameter_table(c("exposure.ratio", "c.2h", "aucint.change", "n.subjects")) |>
  select(parameter, concept, tier, sparse, secondary)
       parameter concept     tier sparse secondary
1 exposure.ratio    <NA> uncommon  FALSE     FALSE
2           c.2h    <NA> uncommon  FALSE     FALSE
3  aucint.change     auc uncommon  FALSE      TRUE
4     n.subjects    <NA> uncommon   TRUE     FALSE

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.


23.9 Inspecting registered parameters

# Confirm your parameter is registered
cols <- get.interval.cols()
cat("exposure.ratio registered:", "exposure.ratio" %in% names(cols), "\n")
exposure.ratio registered: TRUE 
cat("c.2h registered:          ", "c.2h" %in% names(cols), "\n")
c.2h registered:           TRUE 
# Inspect registration details
str(cols[["exposure.ratio"]])
List of 22
 $ FUN               : chr "pk.calc.exposure.ratio"
 $ FUN_sparse        : chr NA
 $ values            : logi [1:2] FALSE TRUE
 $ unit_type         : chr "time"
 $ pretty_name       : chr "Exposure ratio (AUClast / Cmax)"
 $ desc              : chr "AUClast divided by Cmax"
 $ formalsmap        : list()
 $ formalsmap_sparse : list()
 $ depends           : chr [1:2] "auclast" "cmax"
 $ datatype          : chr "interval"
 $ pptestcd_cdisc    : chr "exposure.ratio"
 $ pptest_cdisc      : chr "AUClast divided by Cmax"
 $ formula           : chr "\\frac{AUC_{last}}{C_{max}}"
 $ formula_note      : NULL
 $ tier              : chr "uncommon"
 $ selection         : list()
 $ requires_dose_amt : logi FALSE
 $ requires_dose_time: logi FALSE
 $ requires_dose_dur : logi FALSE
 $ requires_volume   : logi FALSE
 $ requires_conc_dur : logi FALSE
 $ arg_spec          :List of 2
  ..$ arglist :List of 2
  .. ..$ auclast: chr "auclast"
  .. ..$ cmax   : chr "cmax"
  ..$ required: chr [1:2] "auclast" "cmax"

23.10 Custom summary statistics for your parameter

After registering the parameter, set a custom summary rule with PKNCA.set.summary():

PKNCA.set.summary(
  "exposure.ratio",
  description = "geometric mean [CV%]",
  point  = PKNCA::geomean,
  spread = PKNCA::geocv
)

summary(pk.nca(PKNCAdata(o_conc, o_dose, intervals = data.frame(
  start = 0, end = Inf,
  auclast = TRUE, cmax = TRUE, exposure.ratio = TRUE
))))
 start end  N     auclast        cmax exposure.ratio
     0 Inf 12 98.7 [22.5] 8.65 [17.0]    11.4 [12.0]

Caption: auclast, cmax: geometric mean and geometric coefficient of variation; exposure.ratio: geometric mean [CV%]; N: number of subjects

The vignette’s summary section covers the same registration.


23.11 Important notes

  • Registration is session-scoped. Custom parameters registered with add.interval.col() persist for the life of your R session. Re-run registration code in each session (e.g. in your analysis script or package).
  • 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.