Inspecting NCA Algorithms with Standalone PKNCA Functions

Use standalone PKNCA functions (pk.calc.*) to understand how AUC, half-life, and other NCA metrics are computed internally.
Tip

Big idea: The pk.calc.* functions expose the algorithms behind NCA metrics. They are excellent for learning how calculations work, but full analyses should usually use the structured PKNCA workflow.

Learning Objectives

By the end of this lesson, you will be able to:

  • Use standalone PKNCA functions to compute core NCA metrics.
  • Understand how AUC and half-life calculations operate at the algorithm level.
  • Compare standalone calculations with results from the pk.nca() workflow.
  • Recognize when standalone functions are appropriate (education, debugging, algorithm inspection).

Key Ideas

  • PKNCA provides standalone functions that compute individual PK metrics directly.
  • Examples include:
    • pk.calc.auc.last()
    • pk.calc.auc.inf()
    • pk.calc.half.life()
    • pk.calc.cmax()
    • pk.calc.tmax()
  • These functions operate directly on vectors of concentration and time.
  • They expose the core algorithms used internally by PKNCA.
  • However, they do not manage profiles, intervals, or datasets, which is why full analyses typically rely on pk.nca().

Example Setup

library(tidyverse)
library(PKNCA)

data(Theoph)

conc_df <- as_tibble(Theoph) %>%
  transmute(
    ID   = Subject,
    TIME = Time,
    CONC = conc
  )

For demonstration, extract one subject profile:

subj1 <- conc_df %>%
  filter(ID == 1) %>%
  arrange(TIME)

time <- subj1$TIME
conc <- subj1$CONC

Example 1: AUC to Last Observation

pk.calc.auc.last(conc, time)
[1] 147.2347

This function integrates the concentration-time curve using trapezoidal rules up to the last observation.

Conceptually:

\[ AUC_{0-tlast} \]

is computed from the observed data only.


Example 2: Half-Life and Terminal Slope

hl <- pk.calc.half.life(conc, time)

hl
  lambda.z r.squared adj.r.squared lambda.z.time.first lambda.z.time.last
1 0.048457 0.9999997     0.9999995                9.05              24.37
  lambda.z.n.points clast.pred half.life span.ratio tmax tlast
1                 3   3.280146  14.30438   1.071001 1.12 24.37

Half-life is derived from the terminal slope:

\[ t_{1/2} = \frac{\ln(2)}{\lambda_z} \]

The function internally:

  1. Identifies terminal points
  2. Fits a log-linear regression
  3. Computes the slope and derived half-life

Example 3: AUC to Infinity

pk.calc.auc.inf() requires a terminal slope (lambda.z). A practical standalone workflow is to estimate lambda.z first with pk.calc.half.life() and then pass it into pk.calc.auc.inf().

pk.calc.auc.inf(
  conc,
  time,
  lambda.z = hl$lambda.z
)
[1] 214.9236

This reflects the dependency:

\[ AUC_{0-\infty} = AUC_{0-tlast} + \frac{C_{last}}{\lambda_z} \]

So AUCinf depends on:

  • observed AUC
  • estimated terminal slope

Example 4: Cmax and Tmax

pk.calc.cmax(conc)
[1] 10.5
pk.calc.tmax(conc, time)
[1] 1.12

These are simple functions:

  • Cmax = maximum observed concentration
  • Tmax = time of that concentration

Comparing with the Structured Workflow

The same metrics can be computed through the standard PKNCA pipeline:

dose_df <- as_tibble(Theoph) %>%
  distinct(Subject, Dose) %>%
  transmute(ID = Subject, TIME = 0, DOSE = Dose)

conc_obj <- PKNCAconc(CONC ~ TIME | ID, data = conc_df)
dose_obj <- PKNCAdose(DOSE ~ TIME | ID, data = dose_df)

intervals_df <- data.frame(
  start = 0,
  end = Inf,
  auclast = TRUE,
  aucinf.obs = TRUE,
  half.life = TRUE
)

nca_data <- PKNCAdata(conc_obj, dose_obj, intervals = intervals_df)

results <- pk.nca(nca_data)

as.data.frame(results) %>%
    filter(ID == 1) 
# A tibble: 14 × 6
   ID    start   end PPTESTCD             PPORRES exclude
   <ord> <dbl> <dbl> <chr>                  <dbl> <chr>  
 1 1         0   Inf auclast             147.     <NA>   
 2 1         0   Inf tmax                  1.12   <NA>   
 3 1         0   Inf tlast                24.4    <NA>   
 4 1         0   Inf clast.obs             3.28   <NA>   
 5 1         0   Inf lambda.z              0.0485 <NA>   
 6 1         0   Inf r.squared             1.000  <NA>   
 7 1         0   Inf adj.r.squared         1.000  <NA>   
 8 1         0   Inf lambda.z.time.first   9.05   <NA>   
 9 1         0   Inf lambda.z.time.last   24.4    <NA>   
10 1         0   Inf lambda.z.n.points     3      <NA>   
11 1         0   Inf clast.pred            3.28   <NA>   
12 1         0   Inf half.life            14.3    <NA>   
13 1         0   Inf span.ratio            1.07   <NA>   
14 1         0   Inf aucinf.obs          215.     <NA>   

The structured workflow:

  • manages multiple profiles
  • applies interval definitions
  • maintains reproducibility
  • generates diagnostic outputs

Strategies

  • Use standalone functions to inspect algorithms and debug results.
  • Confirm how metrics behave when data change (e.g., BLQ handling).
  • Estimate lambda.z before calling pk.calc.auc.inf().
  • Use the structured PKNCA workflow for full dataset analyses.
  • Treat standalone functions as tools for understanding calculations, not replacing workflows.

Common Mistakes

Warning
  • Using standalone functions on multiple profiles without looping.
  • Forgetting to sort timepoints before calculations.
  • Calling pk.calc.auc.inf() without lambda.z.
  • Comparing standalone outputs with structured outputs without matching assumptions.
  • Treating algorithm inspection tools as production analysis tools.

Practice Problems

Executable

  1. Extract subject 2 and compute AUC0-tlast using pk.calc.auc.last().
  2. Compute half-life for subject 3 using pk.calc.half.life().
  3. Compute AUCinf for subject 1 by first estimating lambda.z.
  4. Compare Cmax from pk.calc.cmax() with the value returned by pk.nca().

Conceptual

  1. Why do standalone functions require sorted timepoints?
  2. Why is pk.nca() preferred for multi-subject analyses?

1. Subject 2 AUC:

subj2 <- conc_df %>%
  filter(ID == 2) %>%
  arrange(TIME)

pk.calc.auc.last(subj2$CONC, subj2$TIME)
[1] 88.73128

2. Subject 3 half-life:

subj3 <- conc_df %>%
  filter(ID == 3) %>%
  arrange(TIME)

pk.calc.half.life(subj3$CONC, subj3$TIME)
   lambda.z r.squared adj.r.squared lambda.z.time.first lambda.z.time.last
1 0.1024443  0.999325     0.9986499                   9              24.17
  lambda.z.n.points clast.pred half.life span.ratio tmax tlast
1                 3   1.055097  6.766087   2.242064 1.02 24.17

3. Subject 1 AUCinf:

hl1 <- pk.calc.half.life(conc, time)

pk.calc.auc.inf(
  conc,
  time,
  lambda.z = hl1$lambda.z
)
[1] 214.9236

4. Compare Cmax:

pk.calc.cmax(conc)
[1] 10.5

Then inspect the cmax output from pk.nca().

5. Conceptual:
Integration requires ordered timepoints. If time is not sorted, trapezoidal integration produces incorrect results.

6. Conceptual:
pk.nca() handles profiles, intervals, grouping, and diagnostics automatically, making analyses reproducible and scalable.


Summary

Standalone PKNCA functions reveal the algorithms behind NCA metrics.

  • pk.calc.* functions compute individual PK metrics.
  • They operate on vectors of concentration and time.
  • pk.calc.auc.inf() requires a supplied terminal slope.
  • They are ideal for learning, debugging, and algorithm inspection.
  • Full analyses should usually rely on the structured PKNCA workflow.

  • Use pk.calc.* functions to understand how NCA metrics are computed.
  • Always sort timepoints before applying standalone calculations.
  • Estimate lambda.z before calling pk.calc.auc.inf().
  • Use pk.nca() for production analyses.
  • Treat standalone functions as algorithm inspection tools.