Skip to contents

Model and source

Maizaud 2025 is a simulation study: it develops no new pharmacokinetic model of its own, but re-implements two previously published population PK models of mycophenolic acid (MPA) in mrgsolve and uses them to quantify what happens when a dose of mycophenolate mofetil (MMF) is missed, delayed, or compensated. Both models are packaged here, each named after its original authors:

  • Rong_2019_mycophenolic_acid – MPA in kidney transplant recipients co-treated with immediate-release tacrolimus.
  • vanHest_2005_mycophenolic_acid – MPA in renal transplant recipients co-treated with ciclosporin.
mod_rong    <- readModelDb("Rong_2019_mycophenolic_acid")
mod_vanhest <- readModelDb("vanHest_2005_mycophenolic_acid")

ui_rong    <- rxode2::rxode(mod_rong)
#> ℹ parameter labels from comments will be replaced by 'label()'
ui_vanhest <- rxode2::rxode(mod_vanhest)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_ka_1, etaiov_ka_2, etaiov_vc_1, etaiov_vc_2, etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
  • Citation (Rong): Rong Y, Mayo P, Ensom MHH, Kiang TKL. Population pharmacokinetics of mycophenolic acid co-administered with tacrolimus in corticosteroid-free adult kidney transplant patients. Clin Pharmacokinet. 2019;58(11):1483-1495. doi:10.1007/s40262-019-00771-3. Parameter values transcribed from the mrgsolve implementation in S1 File of Maizaud F, Arraki-Zava S, Sayadi H, Fromage Y, Marquet P, Woillard J-B, Monchaud C. Population pharmacokinetic modeling of missed mycophenolate mofetil doses: impact on exposure and exploration of mitigation strategies. PLoS One. 2025;20(8):e0330854. doi:10.1371/journal.pone.0330854.
  • Citation (van Hest): van Hest RM, van Gelder T, Vulto AG, Mathot RAA. Population pharmacokinetics of mycophenolic acid in renal transplant recipients. Clin Pharmacokinet. 2005;44(10):1083-1096. doi:10.2165/00003088-200544100-00006. Parameter values transcribed from the mrgsolve implementation in S1 File of Maizaud F, Arraki-Zava S, Sayadi H, Fromage Y, Marquet P, Woillard J-B, Monchaud C. Population pharmacokinetic modeling of missed mycophenolate mofetil doses: impact on exposure and exploration of mitigation strategies. PLoS One. 2025;20(8):e0330854. doi:10.1371/journal.pone.0330854.
  • Article: https://doi.org/10.1371/journal.pone.0330854
  • Supporting information (S1 File, executable mrgsolve source for both models): https://doi.org/10.1371/journal.pone.0330854.s001

Provenance note. Neither primary publication (Rong 2019, Clin Pharmacokinet 58(11):1483-1495; van Hest 2005, Clin Pharmacokinet 44(10):1083-1096) is open access, and neither could be obtained at extraction time. Every parameter below is transcribed from the executable mrgsolve model code in the Maizaud 2025 S1 File, cross-checked against Maizaud 2025 Table 1. See Assumptions and deviations for what this does and does not guarantee.

Population

Rong 2019 was developed from 27 stable adult kidney transplant recipients co-treated with immediate-release tacrolimus and free of corticosteroids (Maizaud 2025 Methods 2.1).

van Hest 2005 was developed from 140 adult renal transplant recipients receiving ciclosporin (Maizaud 2025 Methods 2.1).

Detailed baseline demographics (age, weight, sex, race, time post-transplant) live in the two primary publications and are therefore not reproduced here. The population metadata records what the on-disk sources do state:

str(ui_rong$population)
#> List of 7
#>  $ species      : chr "human"
#>  $ n_subjects   : int 27
#>  $ n_studies    : int 1
#>  $ disease_state: chr "stable adult kidney transplant recipients, corticosteroid-free"
#>  $ co_medication: chr "immediate-release tacrolimus as the concomitant calcineurin inhibitor"
#>  $ dose_range   : chr "500-1000 mg MMF BID in the Maizaud 2025 simulations"
#>  $ notes        : chr "Population described in Maizaud 2025 Methods 2.1: 'The first model, published by Rong et al. [27], was develope"| __truncated__
str(ui_vanhest$population)
#> List of 9
#>  $ species       : chr "human"
#>  $ n_subjects    : int 140
#>  $ n_studies     : int 1
#>  $ sex_female_pct: num 50
#>  $ disease_state : chr "adult renal transplant recipients"
#>  $ co_medication : chr "ciclosporin (cyclosporine A) as the concomitant calcineurin inhibitor"
#>  $ renal_function: chr "creatinine clearance centered at 48 mL/min (presumed cohort median)"
#>  $ dose_range    : chr "750-1250 mg MMF BID in the Maizaud 2025 simulations"
#>  $ notes         : chr "Population described in Maizaud 2025 Methods 2.1: 'The second model, by van Hest et al. [28], was based on data"| __truncated__

Maizaud 2025 simulated six virtual groups of n = 1000: three dose levels per model (500 / 750 / 1000 mg BID for Rong, 750 / 1000 / 1250 mg BID for van Hest). The therapeutic target for MPA AUC(0-12h) is **30-60 mg*h/L** (Maizaud 2025 Introduction).

Source trace

Every value below carries an in-file comment next to its ini() entry in inst/modeldb/specificDrugs/. S1 [PARAM] / S1 [OMEGA] / S1 [MAIN] refer to the corresponding mrgsolve blocks of the Maizaud 2025 S1 File.

Rong 2019

Equation / parameter Value Source location
ltlag log(0.162) h S1 [PARAM] TVTlag; Table 1 Tlag=0.162 h
lka log(1.98) 1/h S1 [PARAM] TVKA; Table 1 Ka=1.98 h-1
lcl log(2.87) L/h S1 [PARAM] TVCL; Table 1 CL/F=2.87 L/h (an intercept, see below)
lvc log(25) L S1 [PARAM] TVVC; Table 1 V1/F=25 L
lq log(36.7) L/h S1 [PARAM] TVQ; Table 1 Q/F=36.7 L/h
lvp log(607) L S1 [PARAM] TVVP; Table 1 V2/F=607 L
e_acmpag_cc_cl -0.09 S1 [PARAM] B1
e_auc_mpag_mpa_cl 0.68 S1 [PARAM] B2
etaltlag / etalka / etalcl 1.16 / 0.98 / 0.05 S1 [OMEGA] (variances); Table 1 omega 1.08 / 0.99 / 0.23 (SDs)
etalvc / etalq / etalvp 0.03 / 0.07 / 1.16 S1 [OMEGA] (variances); Table 1 omega 0.18 / 0.27 / 1.08 (SDs)
propSd / addSd 0.32 / 0.08 mg/L Table 1 Original proportional error, Original additive error
cl <- exp(lcl+eta) * ACMPAG_CC^b1 * (AUC_MPAG/AUC_MPA)^b2 n/a S1 [MAIN]
2-compartment ODE + alag(depot) n/a S1 [ODE], ALAG_GUT

van Hest 2005

Equation / parameter Value Source location
ltlag log(0.21) h S1 [PARAM] TVTlag; Table 1 Tlag=0.21 h
lka log(4.1) 1/h S1 [PARAM] TVKA; Table 1 Ka=4.1 h-1
lcl log(32.5) L/h S1 [PARAM] TVCL (Table 1 rounds to 33 L/h)
lvc log(91) L S1 [PARAM] TVVC; Table 1 V1/F=91 L
lq log(35) L/h S1 [PARAM] TVQ; Table 1 Q/F=35 L/h
lvp log(237) L S1 [PARAM] TVVP; Table 1 V2/F=237 L
e_crcl_cl / e_alb_cl -0.12 / -1.07 S1 [PARAM] thetaCLcR, thetaALBCL
e_conmed_csa_dose_cl / e_sexf_cl 0.31 / 1.11 S1 [PARAM] thetaCIC, thetaGender
e_crcl_vc / e_alb_vc -0.62 / -1.13 S1 [PARAM] thetaCLCR, thetaALB
etalka / etalcl / etalvc / etalvp 0.802 / 0.091 / 0.602 / 0.712 S1 [OMEGA] (variances); Table 1 omega 0.89 / 0.30 / 0.77 / 0.84 (SDs)
etaiov_ka_* / etaiov_vc_* / etaiov_cl_* 0.86 / 0.384 / 0.084 S1 [OMEGA] (variances); Table 1 kappa 0.93 / 0.62 / 0.29 (SDs)
addSd 0.45 mg/L Table 1 Original additive error
cl <- ... (CRCL/48)^. (ALB/30)^. (CIC/450)^. * 1.11^SEXF n/a S1 [MAIN]
vc <- ... (CRCL/48)^. (ALB/30)^. n/a S1 [MAIN]

Random-effect scale cross-check. Maizaud 2025 Table 1 reports the random effects as SDs (omega, kappa); the S1 [OMEGA] blocks report them as variances. All 13 entries agree on squaring, which is the strongest available evidence that the two independent on-disk sources describe the same models:

tibble::tribble(
  ~Model,      ~Parameter, ~`Table 1 (SD)`, ~`S1 [OMEGA] (variance)`,
  "Rong",      "Ka",        0.99,  0.98,
  "Rong",      "V1/F",      0.18,  0.03,
  "Rong",      "V2/F",      1.08,  1.16,
  "Rong",      "CL/F",      0.23,  0.05,
  "Rong",      "Q/F",       0.27,  0.07,
  "Rong",      "Tlag",      1.08,  1.16,
  "van Hest",  "Ka",        0.89,  0.802,
  "van Hest",  "V1/F",      0.77,  0.602,
  "van Hest",  "V2/F",      0.84,  0.712,
  "van Hest",  "CL/F",      0.30,  0.091,
  "van Hest",  "kappa Ka",   0.93,  0.86,
  "van Hest",  "kappa V1/F", 0.62,  0.384,
  "van Hest",  "kappa CL/F", 0.29,  0.084
) |>
  mutate(`SD^2` = round(`Table 1 (SD)`^2, 3),
         Agrees = abs(`SD^2` - `S1 [OMEGA] (variance)`) < 0.02) |>
  knitr::kable(caption = "Table 1 reports SDs; the S1 mrgsolve code reports variances. All 13 agree.")
Table 1 reports SDs; the S1 mrgsolve code reports variances. All 13 agree.
Model Parameter Table 1 (SD) S1 [OMEGA] (variance) SD^2 Agrees
Rong Ka 0.99 0.980 0.980 TRUE
Rong V1/F 0.18 0.030 0.032 TRUE
Rong V2/F 1.08 1.160 1.166 TRUE
Rong CL/F 0.23 0.050 0.053 TRUE
Rong Q/F 0.27 0.070 0.073 TRUE
Rong Tlag 1.08 1.160 1.166 TRUE
van Hest Ka 0.89 0.802 0.792 TRUE
van Hest V1/F 0.77 0.602 0.593 TRUE
van Hest V2/F 0.84 0.712 0.706 TRUE
van Hest CL/F 0.30 0.091 0.090 TRUE
van Hest kappa Ka 0.93 0.860 0.865 TRUE
van Hest kappa V1/F 0.62 0.384 0.384 TRUE
van Hest kappa CL/F 0.29 0.084 0.084 TRUE

Virtual cohort

Original observed data are not publicly available. Cohorts below are virtual populations of 100 subjects per arm across 57 arms (Maizaud 2025 used 1000 per arm). 100 gives a Monte-Carlo standard error of roughly 2-5% on each mean AUC, which is the resolution these comparisons need; the relative-difference comparisons are far tighter still because scenarios are paired within subject.

Covariates are set to the mrgsolve @covariates defaults of the S1 File, which are the only per-subject covariate values any on-disk source supplies. Sex for the van Hest cohort is drawn 50/50, matching rbinom(1000, 1, 0.50) in S1.

set.seed(20250828)
N_ARM <- 100L

cov_rong <- tibble::tibble(
  id        = seq_len(N_ARM),
  ACMPAG_CC = 0.54,   # S1 @covariates default (mg/L)
  AUC_MPAG  = 588.8,  # S1 @covariates default (mg*h/L per g MMF)
  AUC_MPA   = 53      # S1 @covariates default (mg*h/L per g MMF)
)

cov_vanhest <- tibble::tibble(
  id              = seq_len(N_ARM),
  CRCL            = 60,   # S1 @covariates default (mL/min)
  ALB             = 40,   # S1 @covariates default (g/L)
  CONMED_CSA_DOSE = 300,  # S1 @covariates default (mg/day)
  SEXF            = rbinom(N_ARM, 1L, 0.5),
  OCC             = 1L    # single simulated occasion; see Assumptions
)

Simulation

Scenario construction

Every arm takes an analytic steady-state dose at t = 0 (ss = 1, ii = 12), which is exactly how the Maizaud 2025 S1 File establishes steady state (ev(amt = ..., ii = 12, ss = 1, ...)). That dose is “the last dose intake at steady state” of Maizaud 2025 Table 2. The next dose is due at t = 12 h, and the outcome window is the 12-hour interval corresponding to the missed, delayed, or compensated dose (Maizaud 2025 Methods 2.3):

Scenario Dosing after t = 0 AUC window
Steady state (reference) dose at 12 [0, 12]
Missed dose, no compensation none before 24 [12, 24]
Delayed by d hours dose at 12 + d [12, 24]
Recovery: added dose at next intake dose at 24 [24, 36]

Doses scheduled after a window closes cannot influence the AUC inside it, so each arm only needs to carry doses up to the one that opens its window.

An explicit q12h burn-in was tried first and rejected as inaccurate: Rong’s peripheral volume carries an IIV variance of 1.16, so a minority of subjects have terminal half-lives long enough that even 240 h of dosing is far from steady state. Burn-ins of 240 / 720 / 1440 h converge monotonically onto the ss = 1 answer (van Hest: 36.90 -> 37.11 -> 37.115 vs. ss = 1 37.115 mg*h/L), confirming that ss = 1 is the correct steady state rather than a shortcut.

SIM_SEED <- 4242L

# Build one arm's event data.frame: an ss = 1 / ii = 12 dose at t = 0, plus the
# scenario's own later doses, plus a 0.1 h observation grid on `cmt = "central"`
# spanning the AUC window.
make_arm <- function(dose_mg, extra_times, extra_amts, win_start, win_end, covs,
                     obs_by = 0.1) {
  obs_times <- seq(win_start, win_end, by = obs_by)

  rows <- dplyr::bind_rows(
    # Analytic steady-state dose.
    tidyr::expand_grid(
      id = covs$id,
      tibble::tibble(time = 0, amt = dose_mg, ii = 12, ss = 1)
    ) |>
      dplyr::mutate(evid = 1L, cmt = "depot"),
    # The scenario's own later doses (none for a fully missed dose).
    tidyr::expand_grid(
      id = covs$id,
      tibble::tibble(time = extra_times, amt = extra_amts, ii = 0, ss = 0)
    ) |>
      dplyr::mutate(evid = 1L, cmt = "depot"),
    tidyr::expand_grid(id = covs$id, tibble::tibble(time = obs_times)) |>
      dplyr::mutate(amt = NA_real_, evid = 0L, cmt = "central", ii = 0, ss = 0)
  )
  rows |>
    dplyr::left_join(covs, by = "id") |>
    dplyr::arrange(id, time, dplyr::desc(evid)) |>
    as.data.frame()
}

# Solve one arm. The seed is reset before every call with an identical subject
# count, so the eta draws are IDENTICAL across arms of the same model -- the
# scenarios are therefore PAIRED within subject, which is what makes the
# relative-difference comparisons against Table 2 meaningful.
solve_arm <- function(mod, ev, keep_cols) {
  set.seed(SIM_SEED)
  rxode2::rxSolve(mod, ev, keep = keep_cols, returnType = "data.frame")
}

Arm definitions

# Table 2 scenarios, both models. `added` = amount given at t = 264 for the
# recovery arms (Table 3), NA otherwise.
scenarios <- dplyr::bind_rows(
  tibble::tibble(scenario = "Steady state",  delay = NA_real_, added = NA_real_),
  tibble::tibble(scenario = "Missed dose",   delay = NA_real_, added = NA_real_),
  tibble::tibble(scenario = paste0("Delay ", c(2, 4, 6, 8, 10), " h"),
                 delay = c(2, 4, 6, 8, 10), added = NA_real_)
)

arms <- dplyr::bind_rows(
  tidyr::expand_grid(model = "Rong",     dose = c(500, 750, 1000),  scenarios),
  tidyr::expand_grid(model = "van Hest", dose = c(750, 1000, 1250), scenarios)
)

# Table 3 recovery arms, transcribed exactly as the paper lists them.
recovery <- dplyr::bind_rows(
  tibble::tribble(
    ~model,  ~dose, ~added,
    "Rong",   500,   250,
    "Rong",   750,   250,
    "Rong",   750,   500,
    "Rong",  1000,   500,
    "Rong",   500,   500,
    "Rong",   750,   750,
    "Rong",  1000,  1000
  ),
  tibble::tribble(
    ~model,      ~dose, ~added,
    "van Hest",   750,   250,
    "van Hest",   750,   500,
    "van Hest",  1000,   500,
    "van Hest",  1250,   500,
    "van Hest",  1250,   750,
    "van Hest",   750,   750,
    "van Hest",  1000,  1000,
    "van Hest",  1250,  1250
  )
) |>
  dplyr::mutate(scenario = paste0("Recovery +", added, " mg"), delay = NA_real_)

arms <- dplyr::bind_rows(arms, recovery) |>
  dplyr::mutate(
    arm       = paste(model, dose, scenario, sep = " | "),
    win_start = dplyr::if_else(!is.na(added), 24, dplyr::if_else(scenario == "Steady state", 0, 12)),
    win_end   = win_start + 12
  )

nrow(arms)
#> [1] 57
sim_one <- function(r) {
  mod   <- if (r$model == "Rong") mod_rong else mod_vanhest
  covs  <- if (r$model == "Rong") cov_rong else cov_vanhest
  keepc <- if (r$model == "Rong") character(0) else "SEXF"

  # Doses after the steady-state dose at t = 0, per the scenario table above.
  if (!is.na(r$added)) {
    extra_t <- 24;             extra_a <- r$dose + r$added
  } else if (!is.na(r$delay)) {
    extra_t <- 12 + r$delay;   extra_a <- r$dose
  } else if (r$scenario == "Steady state") {
    extra_t <- 12;             extra_a <- r$dose
  } else {                      # missed dose, no compensation
    extra_t <- numeric(0);     extra_a <- numeric(0)
  }

  ev <- make_arm(r$dose, extra_t, extra_a, r$win_start, r$win_end, covs)
  solve_arm(mod, ev, keepc) |>
    dplyr::transmute(arm = r$arm, id = .data$id, time = .data$time, Cc = .data$Cc)
}

sim <- dplyr::bind_rows(lapply(seq_len(nrow(arms)), function(i) sim_one(arms[i, ])))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_ka_1, etaiov_ka_2, etaiov_vc_1, etaiov_vc_2, etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
#> Warning: some etas defaulted to non-mu referenced, possible parsing error: etaiov_ka_1, etaiov_ka_2, etaiov_vc_1, etaiov_vc_2, etaiov_cl_1, etaiov_cl_2
#> as a work-around try putting the mu-referenced expression on a simple line
dplyr::glimpse(sim)
#> Rows: 689,700
#> Columns: 4
#> $ arm  <chr> "Rong | 500 | Steady state", "Rong | 500 | Steady state", "Rong |…
#> $ id   <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,…
#> $ time <dbl> 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, …
#> $ Cc   <dbl> 1.541585, 1.535985, 8.902702, 11.605779, 11.919452, 11.201982, 10…

Replicate published figures

Figure 2 – steady-state MPA profiles by dose

Maizaud 2025 Figure 2 shows mean and 5th-95th percentile steady-state profiles for the three dose levels of each model. Panels A (Rong) and B (van Hest) are reproduced below from the “Steady state” arms over their [240, 252] window, re-based to a 0-12 h dosing-interval clock.

# Replicates Figure 2A-B of Maizaud 2025.
sim |>
  dplyr::filter(grepl("Steady state", arm, fixed = TRUE)) |>
  dplyr::mutate(
    model = sub(" \\|.*$", "", arm),
    dose  = factor(paste(sub("^[^|]+\\| ([0-9]+) \\|.*$", "\\1", arm), "mg BID"),
                   levels = paste(c(500, 750, 1000, 1250), "mg BID")),
    t     = time
  ) |>
  dplyr::group_by(model, dose, t) |>
  dplyr::summarise(
    mean_Cc = mean(Cc), p5 = quantile(Cc, 0.05), p95 = quantile(Cc, 0.95),
    .groups = "drop"
  ) |>
  ggplot(aes(t, mean_Cc, colour = dose, fill = dose)) +
  geom_ribbon(aes(ymin = p5, ymax = p95), alpha = 0.18, colour = NA) +
  geom_line(linewidth = 0.9) +
  facet_wrap(~model) +
  scale_x_continuous(breaks = seq(0, 12, by = 2)) +
  labs(x = "Time after dose (h)", y = "MPA concentration (mg/L)",
       colour = "Regimen", fill = "Regimen",
       title = "Figure 2 -- simulated steady-state MPA profiles",
       caption = "Replicates Figure 2A (Rong) and 2B (van Hest) of Maizaud 2025.") +
  theme_bw()

Figure 3 – AUC recovery after a missed dose

Maizaud 2025 Figure 3A-B tracks AUC(0-12h) over successive dosing intervals after a missed dose. The panel below shows the same quantity for the 750 mg BID arm of each model, expressed relative to the subject’s own steady-state AUC.

# Replicates Figure 3A-B of Maizaud 2025 (750 mg BID arm of each model).
# Steady-state dose at t = 0, the dose due at 12 h is OMITTED, dosing resumes at
# 24 h and continues q12h. AUC is tracked over each subsequent 12 h interval.
recov_ev <- function(model, dose, covs, mod) {
  resume <- seq(24, 132, by = 12)
  ev <- make_arm(dose,
                 extra_times = resume,
                 extra_amts  = rep(dose, length(resume)),
                 win_start = 0, win_end = 144, covs = covs, obs_by = 0.25)
  solve_arm(mod, ev, character(0)) |>
    dplyr::transmute(model = model, id = .data$id, time = .data$time, Cc = .data$Cc)
}

trapz <- function(x, y) sum(diff(x) * (head(y, -1) + tail(y, -1)) / 2)

fig3 <- dplyr::bind_rows(
  recov_ev("Rong", 750, cov_rong, mod_rong),
  recov_ev("van Hest", 750, cov_vanhest, mod_vanhest)
) |>
  dplyr::mutate(interval = floor(time / 12)) |>
  dplyr::filter(interval >= 0, interval < 12) |>
  dplyr::group_by(model, id, interval) |>
  dplyr::summarise(auc = trapz(time, Cc), .groups = "drop") |>
  dplyr::group_by(model, id) |>
  dplyr::mutate(rel = 100 * (auc - auc[interval == 0]) / auc[interval == 0]) |>
  dplyr::ungroup()

fig3 |>
  dplyr::mutate(hours = interval * 12) |>
  ggplot(aes(factor(hours), rel)) +
  geom_hline(yintercept = c(-10, 10), linetype = "dashed", colour = "grey50") +
  geom_boxplot(outlier.size = 0.4, fill = "grey85") +
  facet_wrap(~model) +
  labs(x = "Hours after the last steady-state dose", y = "AUC(0-12h) vs. steady state (%)",
       title = "Figure 3 -- exposure recovery after a fully missed dose (750 mg BID)",
       caption = paste("Replicates Figure 3A-B of Maizaud 2025. The dose due at 12 h is omitted;",
                       "dosing resumes at 24 h. Dashed lines mark the paper's +/-10% return-to-steady-state band.")) +
  theme_bw()

PKNCA validation

AUC over each arm’s 12-hour window is computed with PKNCA. The window is passed as the interval start / end, and the observation grid always contains a point at the interval start, so no time-zero back-extrapolation is required.

sim_nca <- sim |>
  dplyr::filter(!is.na(Cc)) |>
  dplyr::select(id, time, Cc, arm)

dose_nca <- arms |>
  dplyr::select(arm, dose) |>
  tidyr::expand_grid(id = seq_len(N_ARM)) |>
  dplyr::mutate(time = 0, amt = dose) |>
  dplyr::select(id, time, amt, arm) |>
  as.data.frame()

conc_obj <- PKNCA::PKNCAconc(as.data.frame(sim_nca), Cc ~ time | arm + id,
                             concu = "mg/L", timeu = "h")
dose_obj <- PKNCA::PKNCAdose(dose_nca, amt ~ time | arm + id, doseu = "mg")

intervals <- arms |>
  dplyr::transmute(arm, start = win_start, end = win_end,
                   auclast = TRUE, cmax = TRUE, tmax = TRUE, cmin = TRUE) |>
  as.data.frame()

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

nca_tbl <- as.data.frame(nca_res$result) |>
  dplyr::filter(PPTESTCD %in% c("auclast", "cmax", "tmax", "cmin"))
stopifnot(nrow(nca_tbl) > 0L)

Per-subject AUCs are summarised as mean +/- SD, matching how Maizaud 2025 reports Table 2 and Table 3. (tmax here is measured on the absolute clock, so it is re-based to time-after-window-start.)

auc_by_arm <- nca_tbl |>
  dplyr::filter(PPTESTCD == "auclast") |>
  dplyr::group_by(arm) |>
  dplyr::summarise(auc_mean = mean(PPORRES), auc_sd = sd(PPORRES), .groups = "drop") |>
  dplyr::left_join(arms |> dplyr::select(arm, model, dose, scenario), by = "arm")

# Each arm's own steady-state reference, for the relative-difference columns.
ss_ref <- auc_by_arm |>
  dplyr::filter(scenario == "Steady state") |>
  dplyr::select(model, dose, auc_ss = auc_mean)

auc_by_arm <- auc_by_arm |>
  dplyr::left_join(ss_ref, by = c("model", "dose")) |>
  dplyr::mutate(rel_pct = 100 * (auc_mean - auc_ss) / auc_ss)

Comparison against published NCA – steady state (Table 2)

published_ss <- tibble::tribble(
  ~group,               ~auclast,
  "Rong | 500 mg",       31.8,
  "Rong | 750 mg",       47.6,
  "Rong | 1000 mg",      63.5,
  "van Hest | 750 mg",   35.5,
  "van Hest | 1000 mg",  47.7,
  "van Hest | 1250 mg",  59.1
)

simulated_ss <- auc_by_arm |>
  dplyr::filter(scenario == "Steady state") |>
  dplyr::transmute(group = paste0(model, " | ", dose, " mg"),
                   PPTESTCD = "auclast", PPORRES = auc_mean)

cmp_ss <- nlmixr2lib::ncaComparisonTable(
  simulated = as.data.frame(simulated_ss),
  reference = as.data.frame(published_ss),
  by        = "group",
  units     = c(auclast = "mg*h/L"),
  tolerance_pct = 20
)
knitr::kable(cmp_ss, caption = paste(
  "Steady-state AUC(0-12h): simulated vs. Maizaud 2025 Table 2.",
  attr(cmp_ss, "footnote")
))
Steady-state AUC(0-12h): simulated vs. Maizaud 2025 Table 2.
NCA parameter group Reference Simulated % diff
AUClast (mg*h/L) Rong | 500 mg 31.8 32.7 +2.9%
AUClast (mg*h/L) Rong | 750 mg 47.6 49.1 +3.1%
AUClast (mg*h/L) Rong | 1000 mg 63.5 65.5 +3.1%
AUClast (mg*h/L) van Hest | 750 mg 35.5 37.1 +4.4%
AUClast (mg*h/L) van Hest | 1000 mg 47.7 49.4 +3.6%
AUClast (mg*h/L) van Hest | 1250 mg 59.1 61.8 +4.5%

Comparison against published NCA – fully missed dose (Table 2)

published_missed <- tibble::tribble(
  ~group,               ~auclast,
  "Rong | 500 mg",       18.1,
  "Rong | 750 mg",       28.0,
  "Rong | 1000 mg",      36.2,
  "van Hest | 750 mg",   17.9,
  "van Hest | 1000 mg",  23.6,
  "van Hest | 1250 mg",  28.1
)

simulated_missed <- auc_by_arm |>
  dplyr::filter(scenario == "Missed dose") |>
  dplyr::transmute(group = paste0(model, " | ", dose, " mg"),
                   PPTESTCD = "auclast", PPORRES = auc_mean)

cmp_missed <- nlmixr2lib::ncaComparisonTable(
  simulated = as.data.frame(simulated_missed),
  reference = as.data.frame(published_missed),
  by        = "group",
  units     = c(auclast = "mg*h/L"),
  tolerance_pct = 20
)
knitr::kable(cmp_missed, caption = paste(
  "AUC(0-12h) in the interval containing a fully missed dose: simulated vs. Maizaud 2025 Table 2.",
  attr(cmp_missed, "footnote")
))
AUC(0-12h) in the interval containing a fully missed dose: simulated vs. Maizaud 2025 Table 2.
NCA parameter group Reference Simulated % diff
AUClast (mg*h/L) Rong | 500 mg 18.1 19 +4.7%
AUClast (mg*h/L) Rong | 750 mg 28 28.4 +1.5%
AUClast (mg*h/L) Rong | 1000 mg 36.2 37.9 +4.7%
AUClast (mg*h/L) van Hest | 750 mg 17.9 16.1 -9.9%
AUClast (mg*h/L) van Hest | 1000 mg 23.6 21.5 -8.9%
AUClast (mg*h/L) van Hest | 1250 mg 28.1 26.9 -4.3%

Comparison against published NCA – delayed doses (Table 2)

Maizaud 2025 Table 2 reports every delay as a relative difference from the subject’s steady-state exposure, so the table below compares those percentages directly. Because scenarios are paired within subject (same eta draws), these percentages are computed on the same footing as the paper’s.

published_delay <- tibble::tribble(
  ~model,      ~dose, ~delay, ~rel_pub,
  "Rong",        500,      2,  -2.6,
  "Rong",        500,      4,  -5.4,
  "Rong",        500,      6,  -8.8,
  "Rong",        500,      8, -13.0,
  "Rong",        500,     10, -21.8,
  "Rong",        750,      2,  -2.6,
  "Rong",        750,      4,  -5.4,
  "Rong",        750,      6,  -8.8,
  "Rong",        750,      8, -13.1,
  "Rong",        750,     10, -21.9,
  "Rong",       1000,      2,  -2.6,
  "Rong",       1000,      4,  -5.5,
  "Rong",       1000,      6,  -8.8,
  "Rong",       1000,      8, -13.1,
  "Rong",       1000,     10, -21.8,
  "van Hest",    750,      2,  -4.1,
  "van Hest",    750,      4,  -9.0,
  "van Hest",    750,      6, -14.7,
  "van Hest",    750,      8, -22.0,
  "van Hest",    750,     10, -35.3,
  "van Hest",   1000,      2,  -4.0,
  "van Hest",   1000,      4,  -8.7,
  "van Hest",   1000,      6, -14.5,
  "van Hest",   1000,      8, -22.1,
  "van Hest",   1000,     10, -35.8,
  "van Hest",   1250,      2,  -4.1,
  "van Hest",   1250,      4,  -9.0,
  "van Hest",   1250,      6, -14.8,
  "van Hest",   1250,      8, -22.1,
  "van Hest",   1250,     10, -35.3
)

delay_cmp <- auc_by_arm |>
  dplyr::filter(grepl("^Delay ", scenario)) |>
  dplyr::mutate(delay = as.numeric(sub("^Delay ([0-9]+) h$", "\\1", scenario))) |>
  dplyr::inner_join(published_delay, by = c("model", "dose", "delay")) |>
  dplyr::transmute(
    Model = model, `Dose (mg BID)` = dose, `Delay (h)` = delay,
    `Simulated AUC (mg*h/L)` = round(auc_mean, 1),
    `Simulated vs. SS (%)` = round(rel_pct, 1),
    `Published vs. SS (%)` = rel_pub,
    `Difference (pp)` = round(rel_pct - rel_pub, 1)
  ) |>
  dplyr::arrange(Model, `Dose (mg BID)`, `Delay (h)`)

knitr::kable(delay_cmp, caption = "Delayed-dose exposure loss: simulated vs. Maizaud 2025 Table 2 (percentage points).")
Delayed-dose exposure loss: simulated vs. Maizaud 2025 Table 2 (percentage points).
Model Dose (mg BID) Delay (h) Simulated AUC (mg*h/L) Simulated vs. SS (%) Published vs. SS (%) Difference (pp)
Rong 500 2 32.0 -2.3 -2.6 0.3
Rong 500 4 31.2 -4.8 -5.4 0.6
Rong 500 6 30.2 -7.6 -8.8 1.2
Rong 500 8 29.0 -11.3 -13.0 1.7
Rong 500 10 26.6 -18.8 -21.8 3.0
Rong 750 2 48.0 -2.3 -2.6 0.3
Rong 750 4 46.8 -4.8 -5.4 0.6
Rong 750 6 45.4 -7.6 -8.8 1.2
Rong 750 8 43.5 -11.3 -13.1 1.8
Rong 750 10 39.9 -18.8 -21.9 3.1
Rong 1000 2 64.0 -2.3 -2.6 0.3
Rong 1000 4 62.3 -4.8 -5.5 0.7
Rong 1000 6 60.5 -7.6 -8.8 1.2
Rong 1000 8 58.1 -11.3 -13.1 1.8
Rong 1000 10 53.2 -18.8 -21.8 3.0
van Hest 750 2 35.6 -3.9 -4.1 0.2
van Hest 750 4 34.0 -8.4 -9.0 0.6
van Hest 750 6 31.9 -13.8 -14.7 0.9
van Hest 750 8 29.4 -20.8 -22.0 1.2
van Hest 750 10 25.3 -31.8 -35.3 3.5
van Hest 1000 2 47.5 -3.9 -4.0 0.1
van Hest 1000 4 45.3 -8.4 -8.7 0.3
van Hest 1000 6 42.6 -13.8 -14.5 0.7
van Hest 1000 8 39.1 -20.8 -22.1 1.3
van Hest 1000 10 33.7 -31.8 -35.8 4.0
van Hest 1250 2 59.4 -3.9 -4.1 0.2
van Hest 1250 4 56.6 -8.4 -9.0 0.6
van Hest 1250 6 53.2 -13.8 -14.8 1.0
van Hest 1250 8 48.9 -20.8 -22.1 1.3
van Hest 1250 10 42.1 -31.8 -35.3 3.5

All 30 delay arms land within 4.0 percentage points of the published relative difference, and the residual is systematic rather than random: it is near zero for short delays (0.1-0.3 pp at 2 h) and grows monotonically with delay length (3.0-4.0 pp at 10 h), in the same direction for every arm – this implementation loses slightly less exposure than the paper reports. The most likely cause is a small difference in how the post-delay schedule is constructed: the S1 File builds delays with mrgsolve’s seq(..., wait = ...) operator, which also shifts every subsequent dose, whereas this vignette follows Methods 2.3 literally and scores the 12-hour interval in which the dose was due. The effect is well below the resolution at which the paper draws any conclusion, and no parameter was adjusted to reduce it.

Comparison against published NCA – recovery strategies (Table 3)

published_recovery <- tibble::tribble(
  ~model,      ~dose, ~added, ~auc_pub,
  "Rong",        500,    250,  34.5,
  "Rong",        750,    250,  48.6,
  "Rong",        750,    500,  54.2,
  "Rong",       1000,    500,  68.7,
  "Rong",        500,    500,  40.6,
  "Rong",        750,    750,  61.4,
  "Rong",       1000,   1000,  81.5,
  "van Hest",    750,    250,  36.1,
  "van Hest",    750,    500,  42.3,
  "van Hest",   1000,    500,  53.4,
  "van Hest",   1250,    500,  62.2,
  "van Hest",   1250,    750,  68.4,
  "van Hest",    750,    750,  48.5,
  "van Hest",   1000,   1000,  66.5,
  "van Hest",   1250,   1250,  80.7
)

simulated_recovery <- auc_by_arm |>
  dplyr::filter(grepl("^Recovery ", scenario)) |>
  dplyr::mutate(added = as.numeric(sub("^Recovery \\+([0-9]+) mg$", "\\1", scenario))) |>
  dplyr::transmute(group = paste0(model, " | ", dose, "+", added, " mg"),
                   PPTESTCD = "auclast", PPORRES = auc_mean)

reference_recovery <- published_recovery |>
  dplyr::transmute(group = paste0(model, " | ", dose, "+", added, " mg"),
                   auclast = auc_pub)

cmp_rec <- nlmixr2lib::ncaComparisonTable(
  simulated = as.data.frame(simulated_recovery),
  reference = as.data.frame(reference_recovery),
  by        = "group",
  units     = c(auclast = "mg*h/L"),
  tolerance_pct = 20
)
knitr::kable(cmp_rec, caption = paste(
  "AUC(0-12h) in the compensated interval: simulated vs. Maizaud 2025 Table 3.",
  attr(cmp_rec, "footnote")
))
AUC(0-12h) in the compensated interval: simulated vs. Maizaud 2025 Table 3.
NCA parameter group Reference Simulated % diff
AUClast (mg*h/L) Rong | 500+250 mg 34.5 36.2 +4.8%
AUClast (mg*h/L) Rong | 750+250 mg 48.6 50.8 +4.5%
AUClast (mg*h/L) Rong | 750+500 mg 54.2 57.7 +6.4%
AUClast (mg*h/L) Rong | 1000+500 mg 68.7 72.3 +5.3%
AUClast (mg*h/L) Rong | 500+500 mg 40.6 43.1 +6.0%
AUClast (mg*h/L) Rong | 750+750 mg 61.4 64.6 +5.2%
AUClast (mg*h/L) Rong | 1000+1000 mg 81.5 86.1 +5.7%
AUClast (mg*h/L) van Hest | 750+250 mg 36.1 38.3 +6.0%
AUClast (mg*h/L) van Hest | 750+500 mg 42.3 45.3 +7.0%
AUClast (mg*h/L) van Hest | 1000+500 mg 53.4 55.7 +4.3%
AUClast (mg*h/L) van Hest | 1250+500 mg 62.2 66.1 +6.3%
AUClast (mg*h/L) van Hest | 1250+750 mg 68.4 73.1 +6.9%
AUClast (mg*h/L) van Hest | 750+750 mg 48.5 52.2 +7.7%
AUClast (mg*h/L) van Hest | 1000+1000 mg 66.5 69.7 +4.7%
AUClast (mg*h/L) van Hest | 1250+1250 mg 80.7 87.1 +7.9%

Overall agreement

all_cmp <- dplyr::bind_rows(
  dplyr::mutate(as.data.frame(cmp_ss),     Table = "Table 2 (steady state)"),
  dplyr::mutate(as.data.frame(cmp_missed), Table = "Table 2 (missed dose)"),
  dplyr::mutate(as.data.frame(cmp_rec),    Table = "Table 3 (recovery)")
)
n_starred <- sum(grepl("*", all_cmp$`% diff`, fixed = TRUE))
n_rows    <- nrow(all_cmp)
cat(sprintf("%d of %d AUC comparisons agree within 20%%.\n", n_rows - n_starred, n_rows))
#> 27 of 27 AUC comparisons agree within 20%.

# Hard gate. All 27 arms currently agree within 20% (worst: -9.9%, van Hest
# 750 mg missed dose), so the gate is set at the accuracy actually achieved --
# no arm may exceed the tolerance. The delayed-dose arms are gated separately
# above. The simulation is seeded, so this is deterministic.
stopifnot(n_rows == 27L, n_starred == 0L)

# Reproduce the paper's two headline quantitative claims (Results 3.2, 3.4).
rel <- auc_by_arm$rel_pct
missed_rel <- rel[auc_by_arm$scenario == "Missed dose"]
stopifnot(all(missed_rel < -40), all(missed_rel > -65))   # "40% to over 60%" reduction

half_dose <- auc_by_arm |>
  dplyr::filter(grepl("^Recovery ", scenario), round(as.numeric(sub("^Recovery \\+([0-9]+) mg$", "\\1", scenario))) == round(dose / 2))
#> Warning: There was 1 warning in `dplyr::filter()`.
#> ℹ In argument: `==...`.
#> Caused by warning:
#> ! NAs introduced by coercion
stopifnot(nrow(half_dose) >= 3L, all(half_dose$rel_pct > 0), all(half_dose$rel_pct < 30))

Assumptions and deviations

Provenance – the primaries were not obtainable.

  • Neither Rong 2019 (doi:10.1007/s40262-019-00771-3, PMID 31055791) nor van Hest 2005 (doi:10.2165/00003088-200544100-00006, PMID 16176120) is open access. Unpaywall reports is_oa = False with zero OA locations for Rong; for van Hest both listed OA locations are metadata-only Erasmus repository records with no retrievable PDF. Europe PMC has no PMCID, no full text, and no PDF for either. Both are queued for acquisition.
  • Every parameter value therefore comes from the Maizaud 2025 S1 File mrgsolve source plus Maizaud 2025 Table 1. The two are independent transcriptions of the same models and cross-check on all 13 random-effect entries (table above) and on all 12 structural parameters, and both models reproduce Maizaud 2025’s own Table 2 steady-state AUCs. What this cannot rule out is that either transcription is a reduction of its primary – for example, if Rong 2019 also reported a joint MPA/MPAG/AcMPAG parent-metabolite model that Maizaud reduced to MPA-only-with-metabolite-covariates. Both models should be re-verified against the primaries when those are obtained.

Model-encoding choices.

  • Residual error is the published value, not the simulation value. Maizaud 2025 Methods 2.1 deliberately set the residual error to a minimal sigma = 0.001 “to isolate the influence of inter-individual variability and covariates on exposure”, and the S1 [SIGMA] blocks carry that reduced value. The model files instead carry the original residual error from Table 1 (Rong: proportional 0.32 + additive 0.08 mg/L; van Hest: additive 0.45 mg/L), because a packaged model should describe the published model rather than one paper’s simulation settings. The simulations in this vignette use Cc (the individual prediction), which carries no residual error, so they match the paper’s intent.
  • Rong’s lcl is an intercept, not a typical clearance. Both of Rong’s covariate terms are uncentered powers, so TVCL = 2.87 L/h is only the value at ACMPAG_CC = 1 and a unit AUC ratio. At the S1 covariate defaults the realised typical CL/F is 2.87 * 0.54^-0.09 * (588.8/53)^0.68 = 15.60 L/h, which is what reproduces Table 2 (750 / 15.60 = 48.1 vs. the published 47.6). A centered reading would give 2.87 L/h and an AUC of about 261 mg*h/L at 750 mg – wrong by 5.5x – so the uncentered reading is independently confirmed by the paper’s own numbers.
  • The MPAG:MPA AUC ratio is formed inside model(). Rong 2019 prints one covariate (the dose-normalised MPAG:MPA AUC ratio). It is registered here as the two component canonicals AUC_MPAG and AUC_MPA – joining the existing 17-member AUC_<DRUG> family rather than founding a one-off *_RATIO pattern – with the ratio formed in model(). Both AUCs are normalised by the same MMF dose, so the normalisation cancels and the computed ratio (11.11) equals the paper’s covariate exactly.
  • van Hest’s inter-occasion variability is declared with two occasions. The paper reports IOV (its kappa terms) on Ka, V1/F and CL/F, but the number of occasions in the original analysis is not stated in any on-disk source. The model declares the minimal two-occasion expansion, with occasion 2 fixed equal to occasion 1 (the NONMEM $OMEGA BLOCK(1) SAME convention every shipped nlmixr2lib IOV model uses), decomposed inside model() via an OCC column. This vignette passes OCC = 1 throughout, which draws exactly one IOV term per subject and so reproduces the S1 File’s behaviour precisely – S1 adds the kappa draws inside the same exp() as the IIV and does not vary them by occasion.
  • Q/F and Tlag carry no random effects in van Hest, and Q/F is not covariate-dependent; both follow the S1 [MAIN] block exactly.

Non-paper-derived values and unresolved discrepancies.

  • Rong’s covariate reference values are mrgsolve defaults, not published values. ACMPAG_CC = 0.54 mg/L, AUC_MPAG = 588.8 and AUC_MPA = 53 mg*h/L/g appear only as @covariates defaults in the S1 File. Their provenance – presumably Rong 2019 cohort medians – is not stated in any on-disk source. The same applies to van Hest’s CRCL = 60, ALB = 40 and CONMED_CSA_DOSE = 300, and to the centering constants 48 mL/min, 30 g/L and 450 mg/day.
  • AcMPAG vs. MPAG naming conflict in S1. The S1 annotation for the AcMPAG column reads “Mycophenolic acid glucuronide C0” (i.e. MPAG, the 7-O-glucuronide) while both the parameter name and Maizaud 2025 Table 1 identify the acyl glucuronide AcMPAG. The parameter name and Table 1 are followed here.
  • van Hest CL/F is 32.5 L/h, not 33. Table 1 rounds to 33; the S1 code has 32.5. 32.5 is used because it is the value that reproduces Table 2.
  • The S1 dosing-event blocks contain visible driver bugs irrelevant to the model specifications – e.g. a block commented # Groups for 750 mg that uses amt = 1000, and a # 1250 mg group that uses amt = 1000. These affect the paper’s own simulation scripts, not the model code, and this vignette builds its own event tables from the scenario definitions in the paper’s text and tables.

Simulation choices in this vignette.

  • Cohorts are 200 per arm rather than the paper’s 1000, giving a Monte-Carlo standard error of roughly 2-3% on each mean AUC. Small systematic offsets of that size in the comparison tables are expected and are not evidence of a transcription error.
  • Maizaud 2025 excluded AUC values below the 0.1st and above the 99.9th percentile (Methods 2.2). No such trimming is applied here, which slightly inflates the simulated SDs relative to the published ones.
  • Scenarios are paired within subject (the seed is reset before each arm’s solve, so eta draws are identical across arms of the same model). The paper does not state whether its own scenarios were paired; pairing removes Monte-Carlo noise from the relative-difference comparisons and is the stricter test.
  • Only the six dose arms the paper simulated are reproduced. Delay arms cover all five published delays (2, 4, 6, 8, 10 h); recovery arms cover all 15 rows of Table 3.