Skip to contents

Model and source

Al-Zubaydi and colleagues built a gabapentin population PK model entirely from routine therapeutic drug monitoring (TDM) concentrations drawn during inpatient care, rather than from a prospectively sampled PK study. That design choice drives most of the model’s shape: with few absorption-phase samples, ka had to be fixed to a literature value, and the saturable bioavailability constants were fixed to a previously published model. What the data could support was the disposition and its covariate dependence, and the headline result is that serum creatinine on clearance is the single retained covariate – while diabetes and every body-size metric were rejected.

Population

The model was fit to 82 hospitalized adults at Robert Wood Johnson University Hospital Somerset (New Jersey, USA) who received at least one oral gabapentin dose and had at least one subsequent serum concentration, collected retrospectively between 1 January 2009 and 7 December 2023 (Methods Section 2.1). Of 123 TDM concentrations available from 108 screened patients, the 82 who met the inclusion criteria contributed to the model; the paper does not report how many concentrations those 82 patients supplied (Results Section 3.1).

The cohort is elderly and renally heterogeneous: age 65.7 +/- 16.4 years (range 22-93), serum creatinine 1.3 +/- 1.0 mg/dL (range 0.4-3.8) against a laboratory reference interval of 0.66-1.25 mg/dL, MDRD eGFR 71.0 +/- 42.4 mL/min/1.73 m^2 (range 16.0-127), and 21.9% with acute kidney injury within 48 h of sampling. It is also heavy: weight 84.3 +/- 25.9 kg (range 44.2-195.0), BMI 30.0 +/- 7.9 kg/m^2, 22.0% obese, and 31.7% with type 2 diabetes. 62.2% were female and 89.0% White. Single doses ranged 100-1200 mg (median 300 mg) and total daily doses 100-2700 mg (median 900 mg). Baseline demographics are Table 1 of the source.

str(ui$population)
#> List of 14
#>  $ species       : chr "human"
#>  $ n_subjects    : num 82
#>  $ n_studies     : num 1
#>  $ age_range     : chr "22-93 years"
#>  $ age_mean      : chr "65.7 years"
#>  $ weight_range  : chr "44.2-195.0 kg"
#>  $ weight_mean   : chr "84.3 kg"
#>  $ sex_female_pct: num 62.2
#>  $ race_ethnicity: Named num [1:4] 89 7.3 2.4 1.2
#>   ..- attr(*, "names")= chr [1:4] "White" "Black" "Other" "Asian"
#>  $ disease_state : chr "Hospitalized adults receiving oral gabapentin; the medical indication was not recorded in the dataset and the a"| __truncated__
#>  $ renal_function: chr "Serum creatinine 1.3 +/- 1.0 mg/dL (range 0.4-3.8); MDRD eGFR 71.0 +/- 42.4 mL/min/1.73 m^2 (range 16.0-127); C"| __truncated__
#>  $ dose_range    : chr "Single oral dose 100-1200 mg (median 300 mg); total daily dose 100-2700 mg (median 900 mg)"
#>  $ regions       : chr "United States (New Jersey)"
#>  $ notes         : chr "Retrospective therapeutic drug monitoring cohort, Robert Wood Johnson University Hospital Somerset, 1 January 2"| __truncated__

Source trace

Per-parameter provenance is recorded as an in-file comment beside each ini() entry in inst/modeldb/specificDrugs/AlZubaydi_2024_gabapentin.R. Collected here for review:

Equation / parameter Value Source location
lka (ka) 0.778 1/h, fixed Table 2 (“0.778 (fixed)”); Methods Section 2.3 fixes it per refs 16 and 32; Table S1 repeats it in every column
lcl (Cl) 5.73 L/h (RSE 17.62%) Table 2; bootstrap median 7.01, 95% CI 4.3-12.54
lvc (Vd) 44.61 L (RSE 15.94%) Table 2; bootstrap median 45.99, 95% CI 32.84-65.91
e_creat_cl (beta Cl_SCR) -0.89 (RSE 16.94%) Table 2; bootstrap median -1.12, 95% CI -1.66 to -0.58
ldmax_fdepot (Dmax) 823, fixed Equation 1 text, fixed to Carlsson 2009 (ref 20); Table S1 “823 (fixed)”
ld50_fdepot (D50) 1120, fixed Equation 1 text, fixed to Carlsson 2009 (ref 20); Table S1 “1120 (fixed)”
etalcl 0.0784 variance (= 0.28^2) Table 2 omega Cl = 0.28 SD (footnote “omega: standard deviation”)
etalvc 0.5929 variance (= 0.77^2) Table 2 omega Vd = 0.77 SD (same footnote)
addSd 2.03 ug/mL Table 2 a = 2.03 (RSE 17.5%); “a: constant error model”
F = Dmax / (D50 + Dose) n/a Equation 1 (page 4)
cl = cl_pop * (CREAT / 1.3)^beta n/a Methods Section 2.3 (“log-transformed and centered using mean values”) x Table 1 mean SCr 1.3 mg/dL
d/dt(depot), d/dt(central) n/a One-compartment, first-order absorption, no lag, linear elimination (Results Section 3.2; Table S1 selected column)
Structural model selection -2LL 496.44 Table S1 (“1st-order absorption with no Tlag and nonlinear F”)
Covariate selection -2LL 452.85, delta -43.59 Table S2 model 1 (SCr on Cl)

Two encoding decisions are worth spelling out, because both are places a reader could reasonably have guessed differently.

MONOLIX omegas are standard deviations, not variances. The Table 2 footnote states this explicitly (“omega: standard deviation”), and Table S1 repeats it. nlmixr2’s ini() takes variances, so the packaged values are the published omegas squared: 0.28 -> 0.0784 (about 28.6% CV) and 0.77 -> 0.5929 (about 90% CV). Carrying the SDs through unsquared would have understated Vd variability roughly three-fold.

The dose in Equation 1 is the per-administration amount, supplied by podo(depot). The model reads the dose being administered directly from the event record rather than from a separate dose covariate column, so the saturable-bioavailability nonlinearity follows any regimen automatically and cannot be silently lost when a simulation changes the dose.

ui$modelDesc
#> [1] "rxode2-based free-form 2-cmt ODE model"

Virtual cohort

Original observed data are not publicly available (Data Availability Statement offers them on request). Simulations below use virtual cohorts whose serum creatinine distribution approximates Table 1.

Serum creatinine is drawn from a lognormal matched to the Table 1 mean (1.3 mg/dL) and SD (1.0), then truncated to the reported range 0.4-3.8 mg/dL. A normal distribution with those moments would place mass below zero, so a lognormal is the minimal defensible choice; the paper reports only the mean, SD and range, not the shape.

set.seed(20241125)

n_per_arm <- 200L

# Lognormal matched to mean 1.3, SD 1.0 (CV = 1.0 / 1.3), truncated to Table 1's range.
scr_cv <- 1.0 / 1.3
scr_s  <- sqrt(log(1 + scr_cv^2))
scr_mu <- log(1.3) - scr_s^2 / 2

draw_scr <- function(n) {
  x <- numeric(0)
  while (length(x) < n) {
    cand <- rlnorm(n * 3L, scr_mu, scr_s)
    x <- c(x, cand[cand >= 0.4 & cand <= 3.8])
  }
  x[seq_len(n)]
}

scr <- draw_scr(n_per_arm)
c(mean = mean(scr), sd = sd(scr), min = min(scr), max = max(scr))
#>      mean        sd       min       max 
#> 1.3234427 0.7596480 0.4048063 3.7987168

The realized cohort mean and SD sit slightly below the Table 1 moments because truncation at 3.8 mg/dL removes the upper tail; this is reported rather than corrected, since the truncation bounds are themselves the paper’s data.

# Build one arm: q8h oral dosing to steady state, observations on `central`
# (the ODE state) so rxode2 returns the algebraic observable Cc at those rows.
make_arm <- function(scr, dose, tau, n_doses, t_end, by = 0.5, id_offset = 0L) {
  ids <- id_offset + seq_along(scr)
  subj <- tibble(id = ids, CREAT = scr)

  doses <- subj |>
    tidyr::crossing(time = seq(0, by = tau, length.out = n_doses)) |>
    mutate(amt = dose, evid = 1L, cmt = "depot")

  obs <- subj |>
    tidyr::crossing(time = seq(0, t_end, by = by)) |>
    mutate(amt = NA_real_, evid = 0L, cmt = "central")

  bind_rows(doses, obs) |>
    arrange(id, time, desc(evid))
}

# Median regimen in the cohort: 300 mg q8h (= 900 mg/day, the Table 1 median TDD)
events_ss <- make_arm(scr, dose = 300, tau = 8, n_doses = 12L, t_end = 96)

stopifnot(!anyDuplicated(unique(events_ss[, c("id", "time", "evid")])))
nrow(events_ss)
#> [1] 41000

Simulation

mod <- readModelDb("AlZubaydi_2024_gabapentin")

sim_ss <- rxode2::rxSolve(mod, events = events_ss, keep = c("CREAT")) |>
  as.data.frame()

# Sanity: IIV is active (one distinct Cl and Vd per subject).
c(n_subjects = dplyr::n_distinct(sim_ss$id),
  n_distinct_cl = dplyr::n_distinct(round(sim_ss$cl, 8)),
  n_distinct_vc = dplyr::n_distinct(round(sim_ss$vc, 8)))
#>    n_subjects n_distinct_cl n_distinct_vc 
#>           200           200           200

Typical-value (deterministic) profiles are built by supplying zero eta columns and omega = NA, rather than with rxode2::zeroRe()zeroRe() mutates shared model state, which would silently strip IIV from mod for every subsequent chunk in this vignette.

solve_typical <- function(mod, dose, creat = 1.3, tau = NULL, n_doses = 1L,
                          t_end = 48, by = 0.25) {
  dose_times <- if (is.null(tau)) 0 else seq(0, by = tau, length.out = n_doses)
  ev <- bind_rows(
    tibble(time = dose_times, amt = dose, evid = 1L, cmt = "depot"),
    tibble(time = seq(0, t_end, by = by), amt = NA_real_, evid = 0L, cmt = "central")
  ) |>
    mutate(id = 1L, CREAT = creat, etalcl = 0, etalvc = 0) |>
    arrange(time, desc(evid))
  rxode2::rxSolve(mod, ev, omega = NA, returnType = "data.frame")
}

typ_single <- solve_typical(mod, dose = 300, t_end = 48)
typ_ss     <- solve_typical(mod, dose = 300, tau = 8, n_doses = 12L, t_end = 96)

# The typical-value solve must reproduce Table 2 exactly at the SCr reference.
stopifnot(
  isTRUE(all.equal(unique(typ_single$cl), 5.73,  tolerance = 1e-8)),
  isTRUE(all.equal(unique(typ_single$vc), 44.61, tolerance = 1e-8)),
  isTRUE(all.equal(unique(typ_single$ka), 0.778, tolerance = 1e-8))
)

Validation gates

Gate 1 – saturable bioavailability and mass balance

Equation 1 gives F = 823 / (1120 + Dose). The amount actually delivered into depot at a dose event must equal Dose x F exactly.

f_expected <- function(dose) 823 / (1120 + dose)

mb <- lapply(c(100, 300, 400, 600, 900, 1200), function(dz) {
  r <- solve_typical(mod, dose = dz, t_end = 24, by = 0.5)
  tibble(
    dose        = dz,
    F_model     = unique(r$fdepot),
    F_equation1 = f_expected(dz),
    depot_t0    = r$depot[r$time == 0][1],
    expected_t0 = dz * f_expected(dz)
  )
}) |>
  bind_rows() |>
  mutate(absorbed_mg = dose * F_model)

stopifnot(
  isTRUE(all.equal(mb$F_model,  mb$F_equation1, tolerance = 1e-10)),
  isTRUE(all.equal(mb$depot_t0, mb$expected_t0, tolerance = 1e-8))
)

mb |>
  select(dose, F_model, absorbed_mg) |>
  mutate(across(c(F_model, absorbed_mg), \(x) round(x, 4))) |>
  dplyr::rename(
    "Single dose (mg)"     = dose,
    "F (Equation 1)"       = F_model,
    "Absorbed amount (mg)" = absorbed_mg
  ) |>
  knitr::kable(caption = "Gate 1: saturable bioavailability reproduces Equation 1 exactly, and the amount delivered to `depot` equals Dose x F.")
Gate 1: saturable bioavailability reproduces Equation 1 exactly, and the amount delivered to depot equals Dose x F.
Single dose (mg) F (Equation 1) Absorbed amount (mg)
100 0.6746 67.4590
300 0.5796 173.8732
400 0.5414 216.5789
600 0.4785 287.0930
900 0.4074 366.6832
1200 0.3547 425.6897

At the cohort median 300 mg single dose, F = 0.580. That is an independent plausibility check on the units question discussed in Errata below: gabapentin’s absolute bioavailability is roughly 60% at low doses, which the mg reading reproduces. F then falls monotonically with dose (0.675 at 100 mg to 0.355 at 1200 mg), which is the paper’s stated behavior – concentrations are dose-proportional over 300-400 mg q8h but less than proportional above 600 mg q8h (Introduction).

mb |>
  ggplot(aes(dose, absorbed_mg)) +
  geom_abline(aes(intercept = 0, slope = f_expected(300), linetype = "Dose-proportional from 300 mg")) +
  geom_line(aes(colour = "Saturable F (Equation 1)"), linewidth = 0.9) +
  geom_point(aes(colour = "Saturable F (Equation 1)"), size = 2.4) +
  scale_colour_manual(values = c("Saturable F (Equation 1)" = "#1b6ca8")) +
  scale_linetype_manual(values = c("Dose-proportional from 300 mg" = "dashed")) +
  labs(
    x = "Single gabapentin dose (mg)", y = "Absorbed amount, Dose x F (mg)",
    colour = NULL, linetype = NULL,
    title = "Equation 1 produces less-than-proportional absorption",
    caption = "Reproduces the dose-dependent bioavailability described in Al-Zubaydi 2024 Equation 1."
  ) +
  theme_bw() +
  theme(legend.position = "bottom")

Gate 2 – closed-form disposition

For a one-compartment first-order-absorption model the typical-value profile has analytic Tmax, Cmax and AUC. The simulation must match them.

ka <- 0.778; cl <- 5.73; vc <- 44.61
kel <- cl / vc
fd_300 <- f_expected(300)
amt_abs <- 300 * fd_300

tmax_a  <- log(ka / kel) / (ka - kel)
thalf_a <- log(2) / kel
cmax_a  <- (amt_abs * ka) / (vc * (ka - kel)) *
  (exp(-kel * tmax_a) - exp(-ka * tmax_a))
auc_a   <- amt_abs / cl

tmax_sim <- typ_single$time[which.max(typ_single$Cc)]
cmax_sim <- max(typ_single$Cc)

closed <- tibble(
  Quantity  = c("Tmax (h)", "Cmax (ug/mL)"),
  Analytic  = c(tmax_a, cmax_a),
  Simulated = c(tmax_sim, cmax_sim)
) |>
  mutate(`Difference (%)` = 100 * (Simulated - Analytic) / Analytic)

# Cmax must agree to better than 0.1%; Tmax to within one grid step (0.25 h).
stopifnot(
  abs(cmax_sim - cmax_a) / cmax_a < 0.001,
  abs(tmax_sim - tmax_a) <= 0.25
)

closed |>
  mutate(across(where(is.numeric), \(x) round(x, 4))) |>
  knitr::kable(caption = "Gate 2: analytic vs simulated typical-value disposition (300 mg single dose, SCr = 1.3 mg/dL). t1/2 and AUCinf are validated independently against PKNCA in Gates 4 and 5.")
Gate 2: analytic vs simulated typical-value disposition (300 mg single dose, SCr = 1.3 mg/dL). t1/2 and AUCinf are validated independently against PKNCA in Gates 4 and 5.
Quantity Analytic Simulated Difference (%)
Tmax (h) 2.7730 2.7500 -0.8295
Cmax (ug/mL) 2.7297 2.7296 -0.0027

Gate 3 – the serum creatinine covariate

e_creat_cl = -0.89 on a mean-centered log scale means clearance scales as (SCr / 1.3)^-0.89. Across the observed SCr range the model spans a roughly 7-fold clearance range.

scr_grid <- c(0.4, 0.66, 1.25, 1.3, 2.0, 3.8)

cov_tab <- lapply(scr_grid, function(sc) {
  r <- solve_typical(mod, dose = 300, creat = sc, t_end = 24, by = 0.5)
  tibble(
    CREAT      = sc,
    cl_model   = unique(r$cl),
    cl_formula = 5.73 * (sc / 1.3)^(-0.89)
  )
}) |>
  bind_rows() |>
  mutate(thalf_h = log(2) / (cl_model / 44.61))

stopifnot(isTRUE(all.equal(cov_tab$cl_model, cov_tab$cl_formula, tolerance = 1e-8)))

cov_tab |>
  mutate(across(c(cl_model, thalf_h), \(x) round(x, 3))) |>
  select(CREAT, cl_model, thalf_h) |>
  dplyr::rename(
    "Serum creatinine (mg/dL)" = CREAT,
    "Cl (L/h)"                 = cl_model,
    "t1/2 (h)"                 = thalf_h
  ) |>
  knitr::kable(caption = "Gate 3: clearance and half-life across the observed serum creatinine range (Table 1: 0.4-3.8 mg/dL; laboratory reference 0.66-1.25).")
Gate 3: clearance and half-life across the observed serum creatinine range (Table 1: 0.4-3.8 mg/dL; laboratory reference 0.66-1.25).
Serum creatinine (mg/dL) Cl (L/h) t1/2 (h)
0.40 16.358 1.890
0.66 10.475 2.952
1.25 5.934 5.211
1.30 5.730 5.396
2.00 3.905 7.918
3.80 2.206 14.018

The direction and magnitude are consistent with the label guidance the paper cites: a patient at SCr 3.8 mg/dL clears gabapentin at 2.21 L/h versus 5.73 L/h at the cohort mean – a 61% reduction, matching the recommendation of a greater-than-50% dose reduction below CrCl 60 mL/min (Discussion).

Replicate published figures

Figure 1C of the source is a visual predictive check of concentration against time from the first recorded dose. The paper’s own figure is built on the observed TDM sampling times; here the same percentiles are shown for a 300 mg q8h virtual cohort. Percentiles are taken from sim (which carries the additive residual error) rather than Cc (the individual prediction), matching what a VPC displays.

# rxSolve returns observation records only, and carries no `evid` column.
sim_ss |>
  filter(!is.na(Cc)) |>
  group_by(time) |>
  summarise(
    Q05 = quantile(sim, 0.05, na.rm = TRUE),
    Q50 = quantile(sim, 0.50, na.rm = TRUE),
    Q95 = quantile(sim, 0.95, na.rm = TRUE),
    .groups = "drop"
  ) |>
  ggplot(aes(time, Q50)) +
  geom_ribbon(aes(ymin = Q05, ymax = Q95), alpha = 0.25, fill = "#1b6ca8") +
  geom_line(linewidth = 0.8, colour = "#1b6ca8") +
  geom_hline(yintercept = c(2, 20), linetype = "dotted") +
  labs(
    x = "Time from first dose (h)", y = "Gabapentin concentration (ug/mL)",
    title = "Figure 1C -- VPC, 300 mg q8h virtual cohort",
    caption = paste(
      "Replicates the VPC structure of Figure 1C of Al-Zubaydi 2024",
      "(5th / 50th / 95th percentiles). Dotted lines mark the assay reference",
      "range 2-20 ug/mL (Methods Section 2.2)."
    )
  ) +
  theme_bw()

The 5th percentile dips below zero early in the profile. That is a faithful consequence of the published constant (additive) error model with a = 2.03 ug/mL acting on concentrations of the same order, not an implementation artifact – and it is the same weakness the authors report, that the model’s predictability decreases “at low gabapentin concentrations and early time points” (Results Section 3.2).

sim_ss |>
  filter(!is.na(Cc), time >= 88) |>
  mutate(
    scr_band = cut(
      CREAT, breaks = c(0.4, 1.0, 1.5, 3.8), include.lowest = TRUE,
      labels = c("SCr 0.4-1.0", "SCr 1.0-1.5", "SCr 1.5-3.8")
    ),
    time_in_tau = time - 88
  ) |>
  group_by(scr_band, time_in_tau) |>
  summarise(median_Cc = median(Cc), .groups = "drop") |>
  ggplot(aes(time_in_tau, median_Cc, colour = scr_band)) +
  geom_line(linewidth = 0.9) +
  labs(
    x = "Time within the steady-state dosing interval (h)",
    y = "Median individual prediction (ug/mL)",
    colour = NULL,
    title = "Renal function drives steady-state exposure",
    caption = "300 mg q8h, final dosing interval; stratified by simulated serum creatinine."
  ) +
  theme_bw() +
  theme(legend.position = "bottom")

PKNCA validation

NCA is run on the typical-value profiles, not the population median. With 90% CV on Vd, the median of a cohort is not the typical-value prediction, so a typical-value target is the correct comparator for closed-form and published point values.

nca_frame <- function(r, label) {
  out <- r |>
    filter(!is.na(Cc)) |>
    transmute(id = 1L, treatment = label, time, Cc)
  # Guarantee a time-zero row (extravascular pre-dose Cc = 0).
  bind_rows(out, tibble(id = 1L, treatment = label, time = 0, Cc = 0)) |>
    distinct(id, treatment, time, .keep_all = TRUE) |>
    arrange(time)
}

conc_single <- nca_frame(typ_single, "300 mg single dose")
stopifnot(nrow(conc_single) > 0, all(conc_single$Cc >= 0))

dose_single <- tibble(id = 1L, treatment = "300 mg single dose", time = 0, amt = 300)

conc_obj <- PKNCA::PKNCAconc(conc_single, Cc ~ time | treatment + id,
                             concu = "ug/mL", timeu = "h")
dose_obj <- PKNCA::PKNCAdose(dose_single, amt ~ time | treatment + id,
                             doseu = "mg")

intervals_single <- data.frame(
  start = 0, end = Inf,
  cmax = TRUE, tmax = TRUE, aucinf.obs = TRUE, half.life = TRUE, cl.obs = TRUE
)

nca_single <- PKNCA::pk.nca(
  PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals_single)
)

res_single <- as.data.frame(nca_single$result)
res_single |>
  filter(PPTESTCD %in% c("cmax", "tmax", "aucinf.obs", "half.life", "cl.obs")) |>
  mutate(PPORRES = round(PPORRES, 4)) |>
  select(PPTESTCD, PPORRES, PPORRESU) |>
  dplyr::rename("NCA parameter" = PPTESTCD, "Value" = PPORRES, "Units" = PPORRESU) |>
  knitr::kable(caption = "PKNCA on the typical-value 300 mg single-dose profile.")
PKNCA on the typical-value 300 mg single-dose profile.
NCA parameter Value Units
cmax 2.7296 ug/mL
tmax 2.7500 h
half.life 5.4208 h
aucinf.obs 30.3273 h*ug/mL
cl.obs 9.8921 mg/(h*ug/mL)

Gate 4 – apparent vs true clearance

Because the model carries an explicit bioavailability term, PKNCA’s cl.obs (computed as Dose / AUCinf, with no knowledge of F) is the apparent oral clearance Cl/F. Multiplying it back by Equation 1’s F must recover the published Cl = 5.73 L/h. This is the sharpest available check that F is applied where the paper says it is.

get_nca <- function(res, code) {
  v <- res$PPORRES[res$PPTESTCD == code]
  if (length(v) != 1L) stop("no unique NCA row for '", code, "'")
  v
}

cl_obs     <- get_nca(res_single, "cl.obs")
auc_obs    <- get_nca(res_single, "aucinf.obs")
thalf_obs  <- get_nca(res_single, "half.life")
cl_implied <- cl_obs * fd_300

clf <- tibble(
  Quantity = c("Cl/F from PKNCA (L/h)", "x F (Equation 1)",
               "Implied Cl (L/h)", "Published Cl (L/h)",
               "AUCinf PKNCA (ug*h/mL)", "AUCinf closed form (ug*h/mL)",
               "t1/2 PKNCA (h)", "t1/2 closed form log(2)/kel (h)"),
  Value = c(cl_obs, fd_300, cl_implied, 5.73, auc_obs, auc_a,
            thalf_obs, thalf_a)
)

# Cl and AUC must recover the closed form to better than 1%. PKNCA's
# lambda.z regression window includes part of the absorption tail, so its
# half-life sits marginally above the analytic log(2)/kel -- allow 2%.
stopifnot(
  abs(cl_implied - 5.73) / 5.73 < 0.01,
  abs(auc_obs - auc_a) / auc_a < 0.01,
  abs(thalf_obs - thalf_a) / thalf_a < 0.02
)

clf |>
  mutate(Value = round(Value, 4)) |>
  knitr::kable(caption = "Gate 4: PKNCA's apparent clearance times Equation 1's F recovers the published Cl to within 0.1%; AUCinf and t1/2 recover their closed forms.")
Gate 4: PKNCA’s apparent clearance times Equation 1’s F recovers the published Cl to within 0.1%; AUCinf and t1/2 recover their closed forms.
Quantity Value
Cl/F from PKNCA (L/h) 9.8921
x F (Equation 1) 0.5796
Implied Cl (L/h) 5.7332
Published Cl (L/h) 5.7300
AUCinf PKNCA (ug*h/mL) 30.3273
AUCinf closed form (ug*h/mL) 30.3444
t1/2 PKNCA (h) 5.4208
t1/2 closed form log(2)/kel (h) 5.3964

Gate 5 – steady state on the median regimen

tau <- 8
start_ss <- max(seq(0, by = tau, length.out = 12L))
end_ss <- start_ss + tau

conc_ss <- nca_frame(typ_ss, "300 mg q8h")
dose_ss <- tibble(
  id = 1L, treatment = "300 mg q8h",
  time = seq(0, by = tau, length.out = 12L), amt = 300
)

nca_ss <- PKNCA::pk.nca(PKNCA::PKNCAdata(
  PKNCA::PKNCAconc(conc_ss, Cc ~ time | treatment + id,
                   concu = "ug/mL", timeu = "h"),
  PKNCA::PKNCAdose(dose_ss, amt ~ time | treatment + id, doseu = "mg"),
  intervals = data.frame(
    start = start_ss, end = end_ss,
    cmax = TRUE, tmax = TRUE, cmin = TRUE, cav = TRUE, auclast = TRUE
  )
))

res_ss <- as.data.frame(nca_ss$result)
res_ss |>
  filter(PPTESTCD %in% c("cmax", "cmin", "cav", "tmax", "auclast")) |>
  mutate(PPORRES = round(PPORRES, 4)) |>
  select(PPTESTCD, PPORRES, PPORRESU) |>
  dplyr::rename("NCA parameter" = PPTESTCD, "Value" = PPORRES, "Units" = PPORRESU) |>
  knitr::kable(caption = "PKNCA over the final 300 mg q8h dosing interval (typical value, SCr = 1.3 mg/dL).")
PKNCA over the final 300 mg q8h dosing interval (typical value, SCr = 1.3 mg/dL).
NCA parameter Value Units
auclast 30.3273 h*ug/mL
cmax 4.6362 ug/mL
cmin 2.5925 ug/mL
tmax 2.0000 h
cav 3.7909 ug/mL

The paper states that gabapentin plasma concentrations range between 1 and 10 ug/mL over the typical clinical range of 300-400 mg every 8 h (Introduction, citing refs 5-7). The typical-value steady-state peak and trough must fall inside that window.

cmax_ss <- get_nca(res_ss, "cmax")
cmin_ss <- get_nca(res_ss, "cmin")
cav_ss  <- get_nca(res_ss, "cav")

# Independent closed-form average concentration over tau at steady state.
cav_closed <- amt_abs / cl / tau

stopifnot(
  cmin_ss >= 1, cmax_ss <= 10,
  abs(cav_ss - cav_closed) / cav_closed < 0.02
)

tibble(
  Quantity = c("Cmin,ss (ug/mL)", "Cmax,ss (ug/mL)", "Cavg,ss (ug/mL)",
               "Cavg,ss closed form (ug/mL)", "Published range (ug/mL)"),
  Value = c(round(cmin_ss, 3), round(cmax_ss, 3), round(cav_ss, 3),
            round(cav_closed, 3), NA_real_),
  Note = c("", "", "", "F x Dose / (Cl x tau)", "1-10 for 300-400 mg q8h")
) |>
  knitr::kable(caption = "Gate 5: steady-state exposure on the cohort median regimen falls inside the published 1-10 ug/mL window.")
Gate 5: steady-state exposure on the cohort median regimen falls inside the published 1-10 ug/mL window.
Quantity Value Note
Cmin,ss (ug/mL) 2.593
Cmax,ss (ug/mL) 4.636
Cavg,ss (ug/mL) 3.791
Cavg,ss closed form (ug/mL) 3.793 F x Dose / (Cl x tau)
Published range (ug/mL) NA 1-10 for 300-400 mg q8h

Comparison against published values

Al-Zubaydi 2024 reports no NCA of its own, so the reference column below is drawn from the gabapentin values the paper itself quotes: Tmax “within 2-3 h” and half-life “5-7 h” (Introduction, citing refs 1 and 5-7). Range midpoints are used as the point comparators, and the ranges are given in the narrative.

published <- tibble::tribble(
  ~treatment,            ~tmax, ~half.life,
  "300 mg single dose",  2.5,   6.0
)

cmp <- nlmixr2lib::ncaComparisonTable(
  simulated     = nca_single,
  reference     = published,
  by            = "treatment",
  params        = c("tmax", "half.life"),
  units         = c(tmax = "h", half.life = "h"),
  tolerance_pct = 20
)

knitr::kable(
  cmp,
  caption = "Simulated vs published gabapentin NCA values. * differs from reference by >20%.",
  align = c("l", "l", "r", "r", "r")
)
Simulated vs published gabapentin NCA values. * differs from reference by >20%.
NCA parameter treatment Reference Simulated % diff
Tmax (h) 300 mg single dose 2.5 2.75 +10.0%
t½ (h) 300 mg single dose 6 5.42 -9.7%

Both rows are within tolerance and, more usefully, both fall inside the published ranges rather than merely near their midpoints: simulated Tmax is 2.75 h (published 2-3 h) and simulated half-life 5.42 h (published 5-7 h). No row is starred, so no discrepancy needs chasing.

The paper also compares its own structural estimates against Gidal 1998 (reference 32) in the Discussion. That comparison is reproduced here as a check that the packaged ini() values are the ones the authors discussed.

tibble::tribble(
  ~Parameter,   ~Packaged, ~`Gidal 1998`, ~Source,
  "Vd (L)",     44.61,     45.4,          "Table 2 vs Discussion ('approximately similar')",
  "Cl (L/h)",   5.73,      6.31,          "Table 2 vs Discussion ('smaller than the previously reported')"
) |>
  mutate(`Difference (%)` = round(100 * (Packaged - `Gidal 1998`) / `Gidal 1998`, 1)) |>
  knitr::kable(caption = "Structural estimates against the literature values the paper compares itself to.")
Structural estimates against the literature values the paper compares itself to.
Parameter Packaged Gidal 1998 Source Difference (%)
Vd (L) 44.61 45.40 Table 2 vs Discussion (‘approximately similar’) -1.7
Cl (L/h) 5.73 6.31 Table 2 vs Discussion (‘smaller than the previously reported’) -9.2

Vd differs by -1.7% and Cl by -9.2%, both in the direction the Discussion describes (“approximately similar” for Vd; “smaller than the previously reported values” for Cl). The signs and magnitudes agreeing with the authors’ own prose is evidence the parameters were transcribed onto the intended scale.

Assumptions and deviations

Unit label on Equation 1’s constants. Dmax and D50 are printed with mg/day unit labels, but the same sentence defines Dose as “the last gabapentin dose before the sample collection” – a single administration in mg (Table 1 footnote d defines the single-dose amount the same way). The two readings are not reconcilable as printed, and the labels appear to be inherited from Carlsson 2009, where the model was parameterized on daily dose. The model is implemented on the single dose in mg, following the paper’s explicit definition of Dose. This is corroborated numerically: at the median 300 mg single dose the mg reading gives F = 0.580, matching gabapentin’s known roughly 60% absolute bioavailability at low doses, whereas reading Dose as the median 900 mg/day total daily dose would give F = 0.407. A user who wants the daily-dose reading should dose amt in mg/day.

Serum creatinine centering constant. Methods Section 2.3 states that all continuous covariates were “log-transformed and centered using mean values”, so the centering value is the Table 1 cohort mean serum creatinine, 1.3 mg/dL. That mean is reported to only two significant figures, so MONOLIX’s actual centering constant may carry more precision than the packaged 1.3. The alternative reading – an uncentered power model referenced to SCr = 1 mg/dL – was rejected because the Methods sentence is explicit; note that it is not excluded by the half-life gate alone (it would give a typical half-life of 6.9 h, also inside the published 5-7 h window), so the prose is doing the discriminating work here.

MONOLIX omegas are standard deviations. Converted to variances for ini() by squaring (Table 2 footnote; re-confirmed in Table S1’s footnote).

Base-model clearance is inconsistent with the final model. Table S1 reports Cl = 1.59 L/h for the selected structural model before the covariate step, versus 5.73 L/h in the final model (Table 2) – a 3.6-fold shift that no plausible centering value reconciles, and which would imply a 19 h half-life against gabapentin’s known 5-7 h. The packaged model is the final model of Table 2, which reproduces the literature half-life; the base-model estimate is noted here only so a reader comparing the two tables is not surprised. The accompanying drop in omega Cl from 0.84 to 0.28 suggests the base model’s typical value was poorly identified against 82 sparse TDM records.

No lag time. A Tlag of 0.31 h was evaluated as a fixed value (Methods Section 2.3; Table S1 columns 3 and 4), but the no-lag model was selected and Tlag does not appear in Table 2. It is therefore absent from the packaged model.

No IIV on absorption. ka was fixed, and no omega ka is reported, so the model carries between-subject variability only on Cl and Vd. Absorption variability that a richly sampled study would have estimated is folded into the residual error here.

Body-size and diabetes covariates are documented but not implemented. Every screened-and-rejected covariate is recorded in the model file’s covariatesDataExcluded metadata with its Table S2 likelihood result. No point estimates were published for any of them, so none can be implemented; the paper’s finding is precisely that they are unnecessary.

Virtual cohort distribution. Serum creatinine is drawn from a lognormal matched to the Table 1 mean and SD and truncated to the reported range; the paper does not report the distribution’s shape. Age, weight, sex and race are not simulated because they are not covariates in the final model.

Specimen wording. The paper uses “serum” (Table 1; the Mayo Clinic Laboratories test name) and “plasma” (Methods Section 2.2) interchangeably. compartmentData records serum; gabapentin is largely unbound to plasma proteins (Introduction), so the distinction is immaterial here.

Negative simulated observations. The additive-only residual error (a = 2.03 ug/mL) produces negative values in sim at low concentrations. This is inherent to the published constant error model and is not corrected, since truncating it would misrepresent the fitted model.

Number of observations. The paper reports 123 concentrations from 108 screened patients but not how many of those came from the 82 patients in the final model, so the observation count cannot be reproduced.