6  Extravascular (Oral/SC) Examples

6.1 The EV dataset: Theophylline

We use datasets::Theoph — oral theophylline (bronchodilator) in 12 subjects, sampled over 25 hours after a single oral dose. The package authors’ worked analysis of this dataset is in the vignette Computing NCA Parameters for Theophylline.

head(Theoph)
Grouped Data: conc ~ Time | Subject
  Subject   Wt Dose Time  conc
1       1 79.6 4.02 0.00  0.74
2       1 79.6 4.02 0.25  2.84
3       1 79.6 4.02 0.57  6.57
4       1 79.6 4.02 1.12 10.50
5       1 79.6 4.02 2.02  9.66
6       1 79.6 4.02 3.82  8.58
str(Theoph)
Classes 'nfnGroupedData', 'nfGroupedData', 'groupedData' and 'data.frame':  132 obs. of  5 variables:
 $ Subject: Ord.factor w/ 12 levels "6"<"7"<"8"<"11"<..: 11 11 11 11 11 11 11 11 11 11 ...
 $ Wt     : num  79.6 79.6 79.6 79.6 79.6 79.6 79.6 79.6 79.6 79.6 ...
 $ Dose   : num  4.02 4.02 4.02 4.02 4.02 4.02 4.02 4.02 4.02 4.02 ...
 $ Time   : num  0 0.25 0.57 1.12 2.02 ...
 $ conc   : num  0.74 2.84 6.57 10.5 9.66 8.58 8.36 7.47 6.89 5.94 ...
 - attr(*, "formula")=Class 'formula'  language conc ~ Time | Subject
  .. ..- attr(*, ".Environment")=<environment: R_EmptyEnv> 
 - attr(*, "labels")=List of 2
  ..$ x: chr "Time since drug administration"
  ..$ y: chr "Theophylline concentration in serum"
 - attr(*, "units")=List of 2
  ..$ x: chr "(hr)"
  ..$ y: chr "(mg/l)"

Columns: Subject, Wt (body weight, kg), Dose (mg/kg), Time (h), conc (mg/L).

# Prepare concentration data
d_conc <- as.data.frame(Theoph) |>
  rename(time = Time, subject = Subject)

# Prepare dose data: convert mg/kg to mg per subject
d_dose <- Theoph |>
  as.data.frame() |>
  group_by(Subject) |>
  summarise(dose = Dose[1] * Wt[1], .groups = "drop") |>
  rename(subject = Subject) |>
  mutate(time = 0)

# Visualize concentration profiles
ggplot(d_conc, aes(x = time, y = conc, group = subject, colour = subject)) +
  geom_line() + geom_point() +
  labs(title = "Theophylline oral — concentration-time profiles",
       x = "Time (h)", y = "Concentration (mg/L)") +
  theme_minimal()

The typical absorption-distribution-elimination shape is visible: concentrations rise to a peak (Tmax/Cmax) then decline.


6.2 Basic extravascular analysis

o_conc <- PKNCAconc(d_conc, conc ~ time | subject)

o_dose <- PKNCAdose(d_dose, dose ~ time | subject, route = "extravascular")

# Core EV parameters
ev_intervals <- data.frame(
  start       = 0,
  end         = Inf,
  cmax        = TRUE,
  tmax        = TRUE,
  auclast     = TRUE,
  aucinf.obs  = TRUE,
  half.life   = TRUE,
  lambda.z    = TRUE,
  cl.obs      = TRUE,   # apparent CL (= CL/F for EV)
  vz.obs      = TRUE    # apparent Vz (= Vz/F for EV)
)

o_data <- PKNCAdata(o_conc, o_dose, intervals = ev_intervals)
o_nca  <- pk.nca(o_data)

as.data.frame(o_nca) |>
  select(subject, PPTESTCD, PPORRES) |>
  arrange(subject, PPTESTCD)
# A tibble: 216 × 3
   subject PPTESTCD        PPORRES
   <ord>   <chr>             <dbl>
 1 6       adj.r.squared    0.998 
 2 6       aucinf.obs      82.2   
 3 6       auclast         71.7   
 4 6       cl.obs           3.89  
 5 6       clast.obs        0.92  
 6 6       clast.pred       0.941 
 7 6       cmax             6.44  
 8 6       half.life        7.89  
 9 6       lambda.z         0.0878
10 6       lambda.z.corrxy -0.999 
# ℹ 206 more rows

6.3 Parameter reference

6.3.1 Concentration and time landmarks

Parameter Meaning
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)
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)
lambda.z.time.last Last timepoint used in the λz regression
lm_interval <- data.frame(
  start               = 0,
  end                 = Inf,
  cmax                = TRUE,
  tmax                = TRUE,
  cmin                = TRUE,
  tmin                = TRUE,
  tfirst              = TRUE,
  tlast               = TRUE,
  clast.obs           = TRUE,
  clast.pred          = TRUE,
  count_conc          = TRUE,
  count_conc_measured = TRUE
)

o_nca_lm <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = lm_interval))

as.data.frame(o_nca_lm) |>
  filter(PPTESTCD %in% c("cmax","tmax","cmin","tmin","tfirst","tlast",
                          "clast.obs","clast.pred","count_conc","count_conc_measured")) |>
  select(subject, PPTESTCD, PPORRES) |>
  arrange(subject, PPTESTCD)
# A tibble: 120 × 3
   subject PPTESTCD            PPORRES
   <ord>   <chr>                 <dbl>
 1 6       clast.obs             0.92 
 2 6       clast.pred            0.941
 3 6       cmax                  6.44 
 4 6       cmin                  0    
 5 6       count_conc           11    
 6 6       count_conc_measured  10    
 7 6       tfirst                0.27 
 8 6       tlast                23.8  
 9 6       tmax                  1.15 
10 6       tmin                  0    
# ℹ 110 more rows

When the maximum occurs at more than one timepoint (an exact tie), the default is to return the first Tmax. Control this with first.tmax:

# Use the last Tmax when there are ties
o_data_lasttmax <- PKNCAdata(
  o_conc, o_dose,
  intervals = ev_intervals,
  options = list(first.tmax = FALSE)
)
o_nca_lasttmax <- pk.nca(o_data_lasttmax)

# Tmax is unchanged for every subject:
identical(
  as.data.frame(o_nca)          |> filter(PPTESTCD == "tmax") |> pull(PPORRES),
  as.data.frame(o_nca_lasttmax) |> filter(PPTESTCD == "tmax") |> pull(PPORRES)
)
[1] TRUE

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.

6.3.2 AUC variants

The AUC family covers exposure to the last measurable concentration (auclast) and exposure extrapolated to infinity using either the observed or the λz-predicted Clast (aucinf.obs / aucinf.pred), with aucpext.obs reporting how much of AUCinf was extrapolated. See the AUC chapter for the full AUC family and the integration methods behind it.

Parameter Meaning
auclast AUC from 0 to last measurable concentration
aucinf.obs AUC extrapolated to ∞ (using observed Clast)
aucinf.pred AUC extrapolated to ∞ (using predicted Clast)
aucpext.obs % of AUCinf that is extrapolated
auc_params <- data.frame(
  start        = 0,
  end          = Inf,
  auclast      = TRUE,
  aucinf.obs   = TRUE,
  aucinf.pred  = TRUE,
  aucpext.obs  = TRUE
)

o_data_auc <- PKNCAdata(o_conc, o_dose, intervals = auc_params)
o_nca_auc  <- pk.nca(o_data_auc)

as.data.frame(o_nca_auc) |>
  filter(PPTESTCD %in% c("auclast", "aucinf.obs", "aucinf.pred", "aucpext.obs")) |>
  select(subject, PPTESTCD, PPORRES) |>
  tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |>
  arrange(subject)
# A tibble: 12 × 5
   subject auclast aucinf.obs aucinf.pred aucpext.obs
   <ord>     <dbl>      <dbl>       <dbl>       <dbl>
 1 6          71.7       82.2        82.4       12.8 
 2 7          88.0      101.        101.        12.9 
 3 8          86.8      102.        102.        15.0 
 4 11         77.9       86.9        86.9       10.4 
 5 3          95.9      106.        106.         9.66
 6 2          88.7       97.4        97.3        8.88
 7 4         103.       114.        114.        10.1 
 8 9          83.9       97.5        97.5       13.9 
 9 12        115.       126.        126.         8.43
10 10        136.       168.        168.        19.2 
11 1         147.       215.        215.        31.5 
12 5         118.       136.        136.        13.3 

6.3.3 Half-life and λz

For EV, the terminal phase reflects elimination (not absorption) once absorption is complete. One extravascular-specific caveat: with slow absorption the terminal slope can reflect absorption rather than elimination (flip-flop kinetics), in which case the apparent half-life is an absorption half-life. The usual λz quality controls — enough points in the regression, adjusted R², and span ratio — apply regardless of route; see the Half-Life chapter for point selection and quality thresholds. The λz point-selection algorithm and quality metrics are detailed in the Half-Life Calculation vignette.

hl_params <- data.frame(
  start             = 0,
  end               = Inf,
  lambda.z          = TRUE,
  half.life         = TRUE,
  lambda.z.n.points = TRUE,
  r.squared         = TRUE,
  adj.r.squared     = TRUE
)

o_data_hl <- PKNCAdata(o_conc, o_dose, intervals = hl_params)
o_nca_hl  <- pk.nca(o_data_hl)

as.data.frame(o_nca_hl) |>
  filter(PPTESTCD %in% c("half.life", "lambda.z.n.points", "adj.r.squared")) |>
  select(subject, PPTESTCD, PPORRES) |>
  arrange(subject, PPTESTCD)
# A tibble: 36 × 3
   subject PPTESTCD          PPORRES
   <ord>   <chr>               <dbl>
 1 6       adj.r.squared       0.998
 2 6       half.life           7.89 
 3 6       lambda.z.n.points   7    
 4 7       adj.r.squared       0.998
 5 7       half.life           7.85 
 6 7       lambda.z.n.points   4    
 7 8       adj.r.squared       0.989
 8 8       half.life           8.51 
 9 8       lambda.z.n.points   6    
10 11      adj.r.squared       1.000
# ℹ 26 more rows

Allow or exclude Tmax from the terminal regression:

By default, the Tmax point is excluded from the λz regression (absorption may still be ongoing). You can override this:

o_data_allow_tmax <- PKNCAdata(
  o_conc, o_dose,
  intervals = hl_params,
  options = list(allow.tmax.in.half.life = TRUE)
)
o_nca_allow_tmax <- pk.nca(o_data_allow_tmax)

# Subject 8 is the only subject whose selected regression changes
bind_rows(
  as.data.frame(o_nca_hl)         |> mutate(version = "default (Tmax excluded)"),
  as.data.frame(o_nca_allow_tmax) |> mutate(version = "Tmax allowed")
) |>
  filter(subject == "8",
         PPTESTCD %in% c("lambda.z.n.points", "adj.r.squared", "half.life")) |>
  select(version, PPTESTCD, PPORRES) |>
  tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES)
# A tibble: 2 × 4
  version                 adj.r.squared lambda.z.n.points half.life
  <chr>                           <dbl>             <dbl>     <dbl>
1 default (Tmax excluded)         0.989                 6      8.51
2 Tmax allowed                    0.991                 7      8.47

For the other 11 subjects the best-fitting regression already excluded Tmax, so nothing changes; subject 8’s best fit (highest adjusted R²) now starts at Tmax, using one more point and shifting the half-life slightly.

6.3.4 Clearance and volume (apparent)

For extravascular dosing the fraction absorbed (F) is unknown, so clearance and volume cannot be separated from it: PKNCA’s cl.* and vz.* values are apparent, i.e. CL/F and Vz/F. The formulas are the same as for IV dosing (Dose / AUC, then CL / λz), with F implicitly in the denominator — both are requested in the basic analysis above.

Parameter Meaning
cl.obs Apparent clearance = Dose / AUCinf.obs (this is CL/F)
vz.obs Apparent volume = CL.obs / λz (this is Vz/F)

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

o_nca_tlag <- pk.nca(PKNCAdata(o_conc, o_dose,
  intervals = data.frame(start = 0, end = Inf, tlag = TRUE, tmax = TRUE, cmax = TRUE)))

as.data.frame(o_nca_tlag) |>
  filter(PPTESTCD %in% c("tlag", "tmax", "cmax")) |>
  select(subject, PPTESTCD, PPORRES) |>
  tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |>
  arrange(subject)
# A tibble: 12 × 4
   subject  cmax  tmax  tlag
   <ord>   <dbl> <dbl> <dbl>
 1 6        6.44  1.15     0
 2 7        7.09  3.48     0
 3 8        7.56  2.02     0
 4 11       8     0.98     0
 5 3        8.2   1.02     0
 6 2        8.33  1.92     0
 7 4        8.6   1.07     0
 8 9        9.03  0.63     0
 9 12       9.75  3.52     0
10 10      10.2   3.55     0
11 1       10.5   1.12     0
12 5       11.4   1        0

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.

6.3.6 Bioavailability (f)

f (relative bioavailability via pk.calc.f()) requires paired test/reference treatments in the grouping structure — see the Bioavailability section below.

6.3.7 Volume at steady state (Vss)

Vss is computed from CL and MRT, so for extravascular dosing vss.obs and vss.pred are the apparent Vss (i.e., Vss/F). vss.last uses MRTlast instead of the extrapolated MRT.

vss_params <- data.frame(
  start    = 0,
  end      = Inf,
  vss.obs  = TRUE,   # apparent Vss = CL.obs × MRT.obs
  vss.pred = TRUE,
  vss.last = TRUE    # uses MRTlast
)

o_nca_vss <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = vss_params))
as.data.frame(o_nca_vss) |>
  filter(grepl("^vss", PPTESTCD)) |>
  select(subject, PPTESTCD, PPORRES) |>
  arrange(subject, PPTESTCD)
# A tibble: 36 × 3
   subject PPTESTCD PPORRES
   <ord>   <chr>      <dbl>
 1 6       vss.last    38.5
 2 6       vss.obs     46.8
 3 6       vss.pred    46.9
 4 7       vss.last    32.9
 5 7       vss.obs     39.5
 6 7       vss.pred    39.5
 7 8       vss.last    32.1
 8 8       vss.obs     40.2
 9 8       vss.pred    40.2
10 11      vss.last    33.0
# ℹ 26 more rows

6.3.8 Mean residence time (MRT)

Parameter Meaning
mrt.last AUMC(0–last) / AUC(0–last)
mrt.obs AUMC(0–∞) / AUC(0–∞), observed Clast
mrt.pred AUMC(0–∞) / AUC(0–∞), predicted Clast
mrt_ev_params <- data.frame(
  start    = 0, end = Inf,
  mrt.last = TRUE,
  mrt.obs  = TRUE,
  mrt.pred = TRUE
)
o_nca_mrt_ev <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = mrt_ev_params))
as.data.frame(o_nca_mrt_ev) |>
  filter(grepl("^mrt", PPTESTCD)) |>
  select(subject, PPTESTCD, PPORRES) |>
  arrange(subject, PPTESTCD)
# A tibble: 36 × 3
   subject PPTESTCD PPORRES
   <ord>   <chr>      <dbl>
 1 6       mrt.last    8.63
 2 6       mrt.obs    12.0 
 3 6       mrt.pred   12.1 
 4 7       mrt.last    9.04
 5 7       mrt.obs    12.5 
 6 7       mrt.pred   12.5 
 7 8       mrt.last    8.71
 8 8       mrt.obs    12.9 
 9 8       mrt.pred   12.8 
10 11      mrt.last    8.04
# ℹ 26 more rows

6.3.9 Effective half-life and elimination rate constant

The effective half-life is ln(2) × MRT — a rate that reflects overall persistence rather than the terminal slope. The related kel.* parameters are 1/MRT, not λz; the two differ whenever distribution or absorption contributes materially to MRT.

All extravascular effective half-life and elimination rate constant variants:

Parameter Based on
thalf.eff.last mrt.last
thalf.eff.obs mrt.obs
thalf.eff.pred mrt.pred
kel.obs 1 / mrt.obs
kel.pred 1 / mrt.pred
kel.last 1 / mrt.last
eff_params <- data.frame(
  start           = 0, end = Inf,
  thalf.eff.last  = TRUE,
  thalf.eff.obs   = TRUE,
  thalf.eff.pred  = TRUE,
  kel.obs         = TRUE,
  kel.pred        = TRUE,
  kel.last        = TRUE
)

o_nca_eff <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = eff_params))
as.data.frame(o_nca_eff) |>
  filter(grepl("^(thalf|kel)", PPTESTCD)) |>
  select(subject, PPTESTCD, PPORRES) |>
  arrange(subject, PPTESTCD)
# A tibble: 72 × 3
   subject PPTESTCD       PPORRES
   <ord>   <chr>            <dbl>
 1 6       kel.last        0.116 
 2 6       kel.obs         0.0832
 3 6       kel.pred        0.0827
 4 6       thalf.eff.last  5.98  
 5 6       thalf.eff.obs   8.33  
 6 6       thalf.eff.pred  8.38  
 7 7       kel.last        0.111 
 8 7       kel.obs         0.0803
 9 7       kel.pred        0.0801
10 7       thalf.eff.last  6.27  
# ℹ 62 more rows

6.4 AUC over a specific interval

For multiple-dose studies, request AUC over the dosing interval (tau):

# Single dose, but demonstrating syntax for AUC(0-tau)
tau_interval <- data.frame(
  start      = 0,
  end        = 24,      # tau = 24 h
  auclast    = TRUE,
  cmax       = TRUE,
  tmax       = TRUE
)

o_data_tau <- PKNCAdata(o_conc, o_dose, intervals = tau_interval)
o_nca_tau  <- pk.nca(o_data_tau)
as.data.frame(o_nca_tau) |> select(subject, PPTESTCD, PPORRES)
# A tibble: 36 × 3
   subject PPTESTCD PPORRES
   <ord>   <chr>      <dbl>
 1 6       auclast    71.7 
 2 6       cmax        6.44
 3 6       tmax        1.15
 4 7       auclast    62.1 
 5 7       cmax        7.09
 6 7       tmax        3.48
 7 8       auclast    62.8 
 8 8       cmax        7.56
 9 8       tmax        2.02
10 11      auclast    58.7 
# ℹ 26 more rows

6.5 Imputation of missing predose concentrations

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 and the Data Imputation vignette.

Available built-in methods (comma-separate to chain 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
# Remove the time=0 observation from subject 1 to simulate missing predose
d_conc_missing <- d_conc |>
  filter(!(subject == "1" & time == 0))

o_conc_miss <- PKNCAconc(d_conc_missing, conc ~ time | subject)

# With imputation: start_conc0 adds conc = 0 at the interval start
o_data_imputed <- PKNCAdata(
  o_conc_miss, o_dose,
  intervals = ev_intervals,
  impute = "start_conc0"
)

o_nca_imputed <- pk.nca(o_data_imputed)

# Compare subject 1 AUClast: original data vs. missing predose imputed as 0
bind_rows(
  as.data.frame(o_nca) |> filter(subject == "1", PPTESTCD == "auclast") |> mutate(version = "original"),
  as.data.frame(o_nca_imputed) |> filter(subject == "1", PPTESTCD == "auclast") |> mutate(version = "imputed")
) |>
  select(version, PPTESTCD, PPORRES) |>
  as.data.frame()  # base print shows full precision; the difference is small
   version PPTESTCD  PPORRES
1 original  auclast 147.2347
2  imputed  auclast 147.1422

Per-interval imputation — apply different methods to different calculation windows:

# Intervals with an "impute" column specifying method per row
d_intervals_impute <- data.frame(
  start   = 0,
  end     = Inf,
  auclast = TRUE,
  impute  = "start_conc0"
)

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"
)
o_nca_per_interval <- pk.nca(o_data_per_interval)

# Subject 1's missing predose is imputed by the interval row — same result as above
bind_rows(
  as.data.frame(o_nca_imputed)      |> mutate(version = "global impute"),
  as.data.frame(o_nca_per_interval) |> mutate(version = "per-interval impute")
) |>
  filter(subject == "1", PPTESTCD == "auclast") |>
  select(version, PPTESTCD, PPORRES)
# A tibble: 2 × 3
  version             PPTESTCD PPORRES
  <chr>               <chr>      <dbl>
1 global impute       auclast     147.
2 per-interval impute auclast     147.

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.


6.6 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:

# 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))
Mean IV AUCinf (Indometh): 2.69
cat(sprintf("Mean EV AUCinf (Theoph):   %.2f\n", ev_aucinf))
Mean EV AUCinf (Theoph):   119.37
cat(sprintf("AUC ratio EV/IV: %.1f — illustrative only, different drugs\n",
            ev_aucinf / iv_aucinf))
AUC ratio EV/IV: 44.4 — illustrative only, different drugs

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.


6.7 Multiple-dose / steady-state

For multiple-dose data, request steady-state parameters over a tau interval — see the Multiple-Dose chapter for the full workflow. To simulate steady state from single-dose data, see Superposition of Pharmacokinetic Data; to estimate when steady state is reached, see Noncompartmental evaluation of time to steady-state. Here the interval syntax is demonstrated on the single-dose Theoph data:

ss_intervals <- data.frame(
  start  = 0,
  end    = 24,
  cmax   = TRUE,
  cmin   = TRUE,
  tmax   = TRUE,
  auclast = TRUE
)

o_data_ss <- PKNCAdata(o_conc, o_dose, intervals = ss_intervals)
o_nca_ss  <- pk.nca(o_data_ss)

as.data.frame(o_nca_ss) |>
  select(subject, PPTESTCD, PPORRES) |>
  arrange(subject, PPTESTCD)
# A tibble: 48 × 3
   subject PPTESTCD PPORRES
   <ord>   <chr>      <dbl>
 1 6       auclast    71.7 
 2 6       cmax        6.44
 3 6       cmin        0   
 4 6       tmax        1.15
 5 7       auclast    62.1 
 6 7       cmax        7.09
 7 7       cmin        0.15
 8 7       tmax        3.48
 9 8       auclast    62.8 
10 8       cmax        7.56
# ℹ 38 more rows

6.8 Excluding observations

Mark individual timepoints to exclude (e.g. vomiting within 2× median Tmax, suspected contamination):

d_conc_excl <- d_conc |>
  mutate(
    exclude_reason = ifelse(subject == "5" & time == 2.02,
                            "vomiting within 2x median Tmax", NA_character_)
  )

o_conc_excl <- PKNCAconc(d_conc_excl, conc ~ time | subject, exclude = "exclude_reason")


# The flagged row stays in the data with its reason
as.data.frame(o_conc_excl) |> filter(!is.na(exclude_reason))
  subject   Wt Dose time conc                 exclude_reason volume duration
1       5 54.6 5.86 2.02 9.33 vomiting within 2x median Tmax     NA        0

The excluded point is dropped from every parameter calculation, while the flagged row remains in the data with its reason (shown above), preserving the audit trail.


6.9 BLQ (below limit of quantification) handling

PKNCA’s conc.blq option controls how BLQ values (typically entered as 0) are treated:

The default is list(first = "keep", middle = "drop", last = "keep"): - first — BLQs before the first non-BLQ concentration (keep, often a predose zero) - middle — BLQs sandwiched between non-BLQ values (dropped by default) - last — BLQs after the last non-BLQ concentration (keep by default)

In Theoph the only BLQ values are the predose zeros, which the default rule keeps, so overriding conc.blq changes little on this dataset. To see the mechanism, simulate a mid-profile BLQ — subject 3’s 3.62 h sample set to 0 — and compare the default against a global "keep":

d_conc_blq <- d_conc |>
  mutate(conc = ifelse(subject == "3" & time == 3.62, 0, conc))
o_conc_blq <- PKNCAconc(d_conc_blq, conc ~ time | subject)

auclast_interval <- data.frame(start = 0, end = Inf, auclast = TRUE)

# Default: the middle BLQ is dropped and the AUC interpolates across the gap
o_nca_blq_default <- pk.nca(PKNCAdata(o_conc_blq, o_dose, intervals = auclast_interval))

# Global override: keep all BLQ values as 0
o_data_blq_keep <- PKNCAdata(
  o_conc_blq, o_dose,
  intervals = auclast_interval,
  options = list(conc.blq = "keep")
)
o_nca_blq_keep <- pk.nca(o_data_blq_keep)

bind_rows(
  as.data.frame(o_nca_blq_default) |> mutate(version = "default (drop middle BLQ)"),
  as.data.frame(o_nca_blq_keep)    |> mutate(version = "conc.blq = \"keep\"")
) |>
  filter(subject == "3", PPTESTCD == "auclast") |>
  select(version, PPTESTCD, PPORRES)
# A tibble: 2 × 3
  version                     PPTESTCD PPORRES
  <chr>                       <chr>      <dbl>
1 "default (drop middle BLQ)" auclast     95.0
2 "conc.blq = \"keep\""       auclast     84.4

Keeping the mid-profile 0 pulls the concentration curve down to 0 at 3.62 h and cuts a wedge out of subject 3’s AUC; the default treats an isolated BLQ between measurable concentrations as an assay artifact and interpolates across it.

Overrides can also be per-position (first/middle/last) or relative to Tmax (before.tmax/after.tmax), and the action can be "keep", "drop", or a numeric replacement value. The objects are built the same way; running them on this data gives the results described below:

# Global: remove all BLQ values
o_data_blq_drop <- PKNCAdata(
  o_conc, o_dose,
  intervals = ev_intervals,
  options = list(conc.blq = "drop")
)

# Per-phase (before/after Tmax):
o_data_blq <- PKNCAdata(
  o_conc, o_dose,
  intervals = ev_intervals,
  options = list(
    conc.blq = list(
      before.tmax = "keep",   # keep 0s before Tmax (ascending phase)
      after.tmax  = "drop"    # drop BLQs after Tmax
    )
  )
)

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 (≥ 0.12.2).


6.10 AUC integration methods

The default integration method is lin up/log down (linear interpolation on the ascending phase, log-linear on the descending phase). The alternatives, "linear" and "lin-log", are selected per-analysis with the auc.method option. See the AUC chapter for how each method computes the trapezoids and when to prefer which. Formal definitions of each rule are in the AUC integration methods vignette.

# Switch to the linear trapezoidal method
o_data_lin <- PKNCAdata(o_conc, o_dose, intervals = ev_intervals,
                        options = list(auc.method = "linear"))
as.data.frame(pk.nca(o_data_lin)) |>
  filter(PPTESTCD == "auclast") |>
  select(subject, PPTESTCD, PPORRES) |>
  head(3)
# A tibble: 3 × 3
  subject PPTESTCD PPORRES
  <ord>   <chr>      <dbl>
1 6       auclast     73.8
2 7       auclast     90.8
3 8       auclast     88.6

6.11 Units

Assign units to get automatic unit propagation through derived parameters. See the Units chapter for the full units system, including unit conversion and per-parameter overrides. See also Unit Assignment and Conversion with PKNCA for the full units workflow.

units_table <- pknca_units_table(
  concu   = "mg/L",
  timeu   = "h",
  doseu   = "mg",
  amountu = "mg"
)

o_conc_u <- PKNCAconc(d_conc, conc ~ time | subject, concu = "mg/L", timeu = "h")
o_dose_u <- PKNCAdose(d_dose, dose ~ time | subject, route = "extravascular",
                      doseu = "mg", timeu = "h")
o_data_u <- PKNCAdata(o_conc_u, o_dose_u, intervals = ev_intervals, units = units_table)
o_nca_u  <- pk.nca(o_data_u)

as.data.frame(o_nca_u) |>
  filter(PPTESTCD %in% c("auclast", "aucinf.obs", "cl.obs", "half.life")) |>
  select(subject, PPTESTCD, PPORRES) |>
  arrange(subject, PPTESTCD)
# A tibble: 48 × 3
   subject PPTESTCD   PPORRES
   <ord>   <chr>        <dbl>
 1 6       aucinf.obs   82.2 
 2 6       auclast      71.7 
 3 6       cl.obs        3.89
 4 6       half.life     7.89
 5 7       aucinf.obs  101.  
 6 7       auclast      88.0 
 7 7       cl.obs        3.17
 8 7       half.life     7.85
 9 8       aucinf.obs  102.  
10 8       auclast      86.8 
# ℹ 38 more rows

6.12 Summary with custom statistics

summary() produces a study-report-style table of summary statistics per parameter, and PKNCA.set.summary() customizes the statistics reported for any parameter — see Post-processing for the full summary workflow. More summary and exclusion workflows are in the Post-Processing vignette.

PKNCA.set.summary(
  "tmax",
  description = "median [min, max]",
  point  = median,
  spread = function(x) c(min(x), max(x))  # must return numeric; PKNCA formats the string
)

summary(o_nca)
 start end  N     auclast        cmax               tmax   half.life
     0 Inf 12 98.7 [22.5] 8.65 [17.0] 1.14 [0.630, 3.55] 8.18 [2.12]
      lambda.z aucinf.obs      cl.obs      vz.obs
 0.0868 [21.9] 115 [28.4] 2.74 [27.9] 31.6 [19.2]

Caption: auclast, cmax, lambda.z, aucinf.obs, cl.obs, vz.obs: geometric mean and geometric coefficient of variation; tmax: median [min, max]; half.life: arithmetic mean and standard deviation; N: number of subjects

6.13 Full parameter list for extravascular

All extravascular-relevant parameters you can request in an interval:

interval_cols <- get.interval.cols()
ev_relevant <- c(
  # Concentration / time landmarks
  "cmax", "tmax", "cmin", "tmin", "tfirst", "tlast", "tlag",
  "clast.obs", "clast.pred", "count_conc", "count_conc_measured",
  # 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",
  # AUMC
  "aumclast", "aumcall", "aumcinf.obs", "aumcinf.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",
  # Volume (apparent)
  "vz.obs", "vz.pred", "vss.obs", "vss.pred", "vss.last",
  # MRT
  "mrt.last", "mrt.obs", "mrt.pred",
  # Effective half-life / kel
  "thalf.eff.last", "thalf.eff.obs", "thalf.eff.pred",
  "kel.obs", "kel.pred", "kel.last",
  # Bioavailability
  "f",
  # Dose-normalized
  "auclast.dn", "aucinf.obs.dn", "cmax.dn", "clast.obs.dn"
)

sort(names(interval_cols)[names(interval_cols) %in% ev_relevant])
 [1] "adj.r.squared"       "aucall"              "aucinf.obs"         
 [4] "aucinf.obs.dn"       "aucinf.pred"         "aucint.all"         
 [7] "aucint.all.dose"     "aucint.inf.obs"      "aucint.inf.obs.dose"
[10] "aucint.last"         "aucint.last.dose"    "auclast"            
[13] "auclast.dn"          "aucpext.obs"         "aucpext.pred"       
[16] "aumcall"             "aumcinf.obs"         "aumcinf.pred"       
[19] "aumclast"            "cl.all"              "cl.last"            
[22] "cl.obs"              "cl.pred"             "clast.obs"          
[25] "clast.obs.dn"        "clast.pred"          "cmax"               
[28] "cmax.dn"             "cmin"                "count_conc"         
[31] "count_conc_measured" "f"                   "half.life"          
[34] "kel.last"            "kel.obs"             "kel.pred"           
[37] "lambda.z"            "lambda.z.n.points"   "lambda.z.time.first"
[40] "lambda.z.time.last"  "mrt.last"            "mrt.obs"            
[43] "mrt.pred"            "r.squared"           "span.ratio"         
[46] "tfirst"              "thalf.eff.last"      "thalf.eff.obs"      
[49] "thalf.eff.pred"      "tlag"                "tlast"              
[52] "tmax"                "tmin"                "vss.last"           
[55] "vss.obs"             "vss.pred"            "vz.obs"             
[58] "vz.pred"