Skip to contents

Model and source

  • Citation: Patel M, Xu H, Barriere O, Diderichsen P, Patwari P, Zhu AZX, Marier JF, Peyret T, Wang LT, Mellgard B, Wang W, Bhattacharya I. Use of PopPK and E-R Analyses toward Explaining Causal Link Between ADAMTS13 in Recombinant vs. Plasma-Based Therapies and Clinical Effects in cTTP. Clin Pharmacol Ther. 2025;118(4):813-822. doi:10.1002/cpt.3720
  • Article: https://doi.org/10.1002/cpt.3720
  • Supplement (Supplementary Methods S1 and Tables S1-S8): https://doi.org/10.1002/cpt.3720

Patel 2025 reports an integrated pharmacometric analysis of recombinant ADAMTS13 (rADAMTS13; TAK-755) in congenital thrombotic thrombocytopenic purpura (cTTP). It contains one population PK model and three distinct exposure-response analyses, each fitted separately, and each of the exposure-response analyses was fitted independently to two endpoints (thrombocytopenia and elevated lactate dehydrogenase, LDH). Following the library’s policy of replicating the authors’ modelling structure, the paper is packaged as five model files sharing this one vignette:

Model Role
Patel_2025_radamts13 Two-compartment population PK of plasma ADAMTS13 activity (Table 1)
Patel_2025_radamts13_count_thrombocytopenia Poisson count exposure-response, sigmoid Emax (Table S5)
Patel_2025_radamts13_count_ldh Poisson count exposure-response, log-linear slope (Table S5)
Patel_2025_radamts13_rtte_thrombocytopenia Repeated time-to-event hazard, sigmoid Emax (Table S6)
Patel_2025_radamts13_rtte_ldh Repeated time-to-event hazard, sigmoid Emax (Table S6)

The paper’s third exposure-response analysis, a Cox proportional hazards model, is deliberately not packaged as a model file: it is semiparametric, so its baseline hazard h0(t) is never estimated and there is nothing to simulate. Its reported coefficients are reproduced arithmetically in the “Cox proportional hazards” section below so the paper’s third analysis is still covered by this vignette.

pk       <- readModelDb("Patel_2025_radamts13")
cnt_thr  <- readModelDb("Patel_2025_radamts13_count_thrombocytopenia")
cnt_ldh  <- readModelDb("Patel_2025_radamts13_count_ldh")
rtte_thr <- readModelDb("Patel_2025_radamts13_rtte_thrombocytopenia")
rtte_ldh <- readModelDb("Patel_2025_radamts13_rtte_ldh")

Units: the IU/L vs IU/mL boundary

ADAMTS13 is dosed and assayed in international units of enzyme activity. 1 IU/mL = 1000 IU/L = 100% of normal plasma ADAMTS13 activity.

The PK model works in IU/L, because its dose amounts are in IU and its volumes in L. That is also the unit in which the paper reports the additive residual error (Table 1, “Additive (IU/L): 79.9”), so every ini() value is the printed one.

The four exposure-response models take their CAV covariate in IU/mL, which is the unit the paper uses for every activity summary (Cmax, Cave, EC50).

Every hand-off from the PK model to an exposure-response model therefore divides by 1000. This vignette does that explicitly and marks each site.

IU_L_PER_IU_ML <- 1000

Population

The PK analysis pooled 65 patients with cTTP from three rADAMTS13 trials: a phase I dose-escalation PK/safety study (NCT02216084), the pivotal phase III randomised open-label crossover study (NCT03393975), and a phase IIIb open-label continuation study (NCT04683003). Together they contributed 2,462 samples with measurable ADAMTS13 activity, assayed by FRETS-VWF73; samples below the limit of quantitation were set to missing rather than imputed, so the model carries no BLQ handling and no endogenous ADAMTS13 baseline term.

Baseline characteristics (paper Table 2): median body weight 68.7 kg (range 18.3-130.0), 60% female, 61.5% White / 16.9% Asian / 3.1% Black or African American / 16.9% missing. Both phase III trials enrolled ages 0-70 years; the age split was 8 patients (12.3%) under 12 years, of whom 4 were under 6 years, and 57 (87.7%) aged 12 or older. Median baseline platelet count was 205 x 10^9/L and median LDH 178 U/L.

The exposure-response analyses use only the pivotal phase III study, Periods 1 and 2, giving N = 41 for the count and Cox analyses. Figure 1 footnote a notes that two patients included in the repeated time-to-event modelling were not part of that prophylaxis count cohort, so the RTTE cohort is 43.

The same information is available programmatically:

str(pk()$population, max.level = 1)
#> List of 12
#>  $ species       : chr "human"
#>  $ n_subjects    : int 65
#>  $ n_studies     : int 3
#>  $ age_range     : chr "0-70 years at enrolment (both phase III trials enrolled ages 0-70). Age-group split of the 65-patient PK analys"| __truncated__
#>  $ weight_range  : chr "18.3-130.0 kg (Table 2, overall)"
#>  $ weight_median : chr "68.7 kg (Table 2, overall median; also the allometric reference weight)"
#>  $ sex_female_pct: num 60
#>  $ race_ethnicity: Named num [1:5] 61.5 3.1 16.9 1.5 16.9
#>   ..- attr(*, "names")= chr [1:5] "White" "Black_African_American" "Asian" "Multiple" ...
#>  $ disease_state : chr "Congenital thrombotic thrombocytopenic purpura (cTTP), an ultra-rare hereditary ADAMTS13 deficiency diagnosed b"| __truncated__
#>  $ dose_range    : chr "rADAMTS13 40 IU/kg IV once weekly (Q1W) or once every 2 weeks (Q2W) for prophylaxis (the phase I dose-escalatio"| __truncated__
#>  $ regions       : chr "Multinational (NCT02216084 phase I; NCT03393975 phase III crossover; NCT04683003 phase IIIb continuation). Per-"| __truncated__
#>  $ notes         : chr "Baseline characteristics are in Table 2 of the paper. The PopPK analysis set is 65 unique patients contributing"| __truncated__

Source trace

Every ini() value carries an in-file comment naming its source location. They are collected here for review. Table numbers prefixed S are in the Supplementary Information.

Population PK (Patel_2025_radamts13)

Parameter Value Source location
lcl 0.0398 L/h Table 1, “Clearance, L/h” (RSE 10.7%)
lvc 2.69 L Table 1, “Central volume of distribution, L” (RSE 4.99%)
lq 0.0456 L/h Table 1, “Peripheral clearance, L/h” (RSE 12.1%)
lvp 3.71 L Table 1, “Peripheral volume of distribution, L” (RSE 51.4%)
e_wt_cl 0.75, fixed Table 1, “x (WT/68.7)^0.75” rows; Methods “Fixed allometric exponents were used”
e_wt_vc 1.0, fixed Table 1, “x (WT/68.7)^1.0” rows
e_trt_pbt -0.390 Table 1, “x (1-0.390) if PBT” (RSE 6.14%)
e_trt_pdvw -0.933 Table 1, “x (1-0.933) if FVIII:VWF concentrates” (RSE 2.06%)
etalcl 0.1238 (var) Table 1, IIV on CL = 36.3% CV, back-transformed via Table 1 footnote a
etalvc 0.0625 (var) Table 1, IIV on Vc = 25.4% CV, back-transformed via Table 1 footnote a
addSd 79.9 IU/L Table 1, “Error model, Additive (IU/L): 79.9”
propSd 0.204 Table 1, “Error model, Proportional (Fraction): 0.204”
Allometric reference weight 68.7 kg Table 1 footnote, “The reference patient is a 68.7-kg patient who received rADAMTS13”
2-compartment, zero-order infusion, first-order elimination Results, “described using a two-compartment model with zero-order infusion and first-order linear elimination from the central compartment”

Exposure-response count models (Table S5)

Parameter Thrombocytopenia Elevated LDH Source location
b1 1.40 (log), 4.05 untransformed -0.886 (log), 0.412 untransformed Table S5, “B1”
emax_count 0.918 n/a Table S5, “Emax”; Results “Emax: 91.8% reduction”
ec50 0.0149 IU/mL n/a Table S5, “EC50”; Results “ECave50: 0.0149 IU/mL”
gamma 2.58 n/a Table S5, “Gamma”; Results “gamma: 2.58”
slope_cav n/a -5.12 per IU/mL Table S5, “Slope”
etab1 3.67 (var) 1.81 (var) Table S5, “BSV on B1”

Repeated time-to-event models (Table S6)

Parameter Thrombocytopenia Elevated LDH Source location
llambda0 log(0.0349) per day log(0.00923) per day Table S6, “Lambda0”
emax_haz -3.71 -2.84 Table S6, “Emax”
ec50 0.0113 IU/mL 0.0133 IU/mL Table S6, “EC50 (IU/mL)”
gamma 2.58, fixed 2.58, fixed Table S6, “Gamma … Fixed”
etallambda0 1.0946 (var) 0.9018 (var) Table S6, “IIV Lambda0” 141% / 121% CV, back-transformed via the Table S6 note
Constant baseline hazard, sigmoid Emax on the log hazard Supplementary Methods S1, “Longitudinal repeated time-to-event exposure-response modeling”

Part 1 - Population PK

Steady-state exposure reproduces Table S2

Table S2 reports simulated steady-state PK parameters by treatment for the 65-patient analysis set. Reproducing it is the primary structural gate on the PK model: a mis-transcribed clearance, volume, dose or relative-activity factor moves these numbers immediately.

Simulation uses rxode2::zeroRe() so the comparison is typical-value against the paper’s geometric mean (for a log-normal parameter the geometric mean and the typical value coincide), and reaches steady state with ii/addl rather than ss = 1 because the terminal half-life is long relative to the dosing interval.

pk_typ <- rxode2::zeroRe(pk)
#> ℹ parameter labels from comments will be replaced by 'label()'

#' Simulate one steady-state dosing interval of the Patel 2025 PK model.
#'
#' Returns Cmax and Cave over the LAST of `n_dose` intervals, converted from
#' the model's IU/L to the paper's IU/mL.
simulate_ss <- function(wt, iu_per_kg, tau_h, pbt = 0, pdvw = 0,
                        infusion_h = 0.5, n_dose = 12L) {
  ev <- rxode2::et(amt = iu_per_kg * wt, cmt = "central", dur = infusion_h,
                   ii = tau_h, addl = n_dose - 1L) |>
    rxode2::et(seq(0, tau_h * n_dose, by = 0.5), cmt = "central")
  dat <- as.data.frame(ev)
  dat$WT <- wt
  dat$TRT_PBT <- pbt
  dat$TRT_PDFVIII_VWF <- pdvw
  res <- rxode2::rxSolve(pk_typ, dat, returnType = "data.frame")
  last <- res |>
    dplyr::filter(!is.na(Cc), time >= tau_h * (n_dose - 1), time <= tau_h * n_dose) |>
    dplyr::arrange(time)
  auc_tau <- sum(diff(last$time) *
                   (utils::head(last$Cc, -1) + utils::tail(last$Cc, -1)) / 2)
  c(cmax = max(last$Cc) / IU_L_PER_IU_ML,
    cave = auc_tau / tau_h / IU_L_PER_IU_ML)
}

ss_arms <- tibble::tribble(
  ~treatment,             ~interval, ~iu_per_kg, ~tau_h, ~pbt, ~pdvw, ~ref_cmax, ~ref_cave,
  "rADAMTS13 40 IU/kg",   "Q1W",     40,         168,    0,    0,     1.23,      0.405,
  "rADAMTS13 40 IU/kg",   "Q2W",     40,         336,    0,    0,     1.09,      0.203,
  "PBT 10 IU/kg",         "Q1W",     10,         168,    1,    0,     0.180,     0.0613,
  "PBT 10 IU/kg",         "Q2W",     10,         336,    1,    0,     0.160,     0.0308
)

ss <- ss_arms |>
  dplyr::rowwise() |>
  dplyr::mutate(
    sim = list(simulate_ss(68.7, iu_per_kg, tau_h, pbt, pdvw)),
    sim_cmax = sim[["cmax"]],
    sim_cave = sim[["cave"]]
  ) |>
  dplyr::ungroup() |>
  dplyr::select(-sim, -pbt, -pdvw, -iu_per_kg, -tau_h) |>
  dplyr::mutate(
    pct_cmax = 100 * (sim_cmax - ref_cmax) / ref_cmax,
    pct_cave = 100 * (sim_cave - ref_cave) / ref_cave
  )
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'

ss |>
  dplyr::rename(
    "Treatment" = treatment, "Interval" = interval,
    "Cmax sim (IU/mL)" = sim_cmax, "Cmax Table S2 (IU/mL)" = ref_cmax, "Cmax % diff" = pct_cmax,
    "Cave sim (IU/mL)" = sim_cave, "Cave Table S2 (IU/mL)" = ref_cave, "Cave % diff" = pct_cave
  ) |>
  knitr::kable(digits = c(0, 0, 4, 4, 4, 4, 1, 1))
Treatment Interval Cmax Table S2 (IU/mL) Cave Table S2 (IU/mL) Cmax sim (IU/mL) Cave sim (IU/mL) Cmax % diff Cave % diff
rADAMTS13 40 IU/kg Q1W 1.23 0.4050 1.2068 0.4110 -1.9 1.5
rADAMTS13 40 IU/kg Q2W 1.09 0.2030 1.0742 0.2055 -1.5 1.2
PBT 10 IU/kg Q1W 0.18 0.0613 0.1840 0.0627 2.2 2.2
PBT 10 IU/kg Q2W 0.16 0.0308 0.1638 0.0313 2.4 1.7

The typical-value simulation reproduces all eight Table S2 entries to within 3%. The residual difference is expected and has a known direction: the published values are geometric means over 65 real patients whose weights span 18.3-130 kg, whereas this simulation is a single 68.7 kg typical subject, and Cave scales as WT^0.25 (see below), so a cohort mean does not land exactly on the median subject.

stopifnot(
  # Structural: any mis-transcribed clearance, volume, dose or relative-activity
  # factor moves these by tens of percent.
  max(abs(ss$pct_cmax)) < 8,
  max(abs(ss$pct_cave)) < 8
)

Closed-form check on Cave and on the relative-activity factor

At steady state for a linear model, Cave = F * Dose / (CL * tau) exactly. This is an independent algebraic check that also isolates the PBT relative-activity multiplier, which is the paper’s headline covariate finding.

cl_ref <- 0.0398                        # Table 1
frel <- c("rADAMTS13" = 1,
          "PBT" = 1 - 0.390,            # Table 1, x (1 - 0.390)
          "pdFVIII:VWF" = 1 - 0.933)    # Table 1, x (1 - 0.933)

closed_form_cave <- function(iu_per_kg, tau_h, f) {
  iu_per_kg * 68.7 * f / (cl_ref * tau_h) / IU_L_PER_IU_ML
}

cf <- tibble::tibble(
  arm = c("rADAMTS13 40 IU/kg Q1W", "rADAMTS13 40 IU/kg Q2W",
          "PBT 10 IU/kg Q1W", "PBT 10 IU/kg Q2W"),
  closed_form = c(closed_form_cave(40, 168, frel[["rADAMTS13"]]),
                  closed_form_cave(40, 336, frel[["rADAMTS13"]]),
                  closed_form_cave(10, 168, frel[["PBT"]]),
                  closed_form_cave(10, 336, frel[["PBT"]])),
  solved = ss$sim_cave
) |>
  dplyr::mutate(pct = 100 * (solved - closed_form) / closed_form)

cf |>
  dplyr::rename("Arm" = arm, "Closed form (IU/mL)" = closed_form,
                "Solved (IU/mL)" = solved, "% diff" = pct) |>
  knitr::kable(digits = c(0, 5, 5, 3))
Arm Closed form (IU/mL) Solved (IU/mL) % diff
rADAMTS13 40 IU/kg Q1W 0.41098 0.41096 -0.006
rADAMTS13 40 IU/kg Q2W 0.20549 0.20549 0.000
PBT 10 IU/kg Q1W 0.06267 0.06267 -0.006
PBT 10 IU/kg Q2W 0.03134 0.03134 0.000

stopifnot(
  # Both sides use the same drawn parameters, so the only difference is
  # trapezoidal error on the observation grid. A tight bound is correct here.
  max(abs(cf$pct)) < 0.5
)

The paper states that the ADAMTS13 content of PBT preparations “was estimated to be 40% to 94% lower than that measured in rADAMTS13” (Results). The two fitted multipliers bracket exactly that range:

stopifnot(
  abs((1 - frel[["PBT"]]) - 0.390) < 1e-12,
  abs((1 - frel[["pdFVIII:VWF"]]) - 0.933) < 1e-12
)

Weight-based dosing makes Cmax nearly weight-invariant

The paper’s central dosing claim is that “besides body weight-based dosing, no further dose adjustment was required based on age or race” (Abstract). That is a structural consequence of the model: with the dose proportional to weight and the central volume exponent fixed at exactly 1.0, the weight term cancels from Dose / Vc, so Cmax is weight-invariant. Cave, in contrast, scales as WT^(1 - 0.75) = WT^0.25, because clearance carries the 0.75 exponent.

wt_grid <- c(15, 25, 40, 68.7, 100, 130)
wt_scan <- tibble::tibble(WT = wt_grid) |>
  dplyr::rowwise() |>
  dplyr::mutate(s = list(simulate_ss(WT, 40, 336)),
                Cmax = s[["cmax"]], Cave = s[["cave"]]) |>
  dplyr::ungroup() |>
  dplyr::select(-s) |>
  dplyr::mutate(
    `Cmax rel. to 68.7 kg` = Cmax / Cmax[WT == 68.7],
    `Cave rel. to 68.7 kg` = Cave / Cave[WT == 68.7],
    `WT^0.25 prediction`   = (WT / 68.7)^0.25
  )
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
knitr::kable(wt_scan, digits = 4)
WT Cmax Cave Cmax rel. to 68.7 kg Cave rel. to 68.7 kg WT^0.25 prediction
15.0 1.0358 0.1405 0.9643 0.6836 0.6836
25.0 1.0466 0.1596 0.9744 0.7767 0.7767
40.0 1.0583 0.1795 0.9852 0.8735 0.8735
68.7 1.0742 0.2055 1.0000 1.0000 1.0000
100.0 1.0868 0.2257 1.0118 1.0984 1.0984
130.0 1.0966 0.2410 1.0209 1.1729 1.1729

stopifnot(
  # Cmax varies by less than 10% over an 8.7-fold weight range. It is not
  # exactly flat because steady-state accumulation depends on CL/Vc, which
  # carries a residual WT^-0.25.
  max(wt_scan$`Cmax rel. to 68.7 kg`) / min(wt_scan$`Cmax rel. to 68.7 kg`) < 1.10,
  # Cave follows the WT^0.25 allometric prediction closely.
  max(abs(wt_scan$`Cave rel. to 68.7 kg` - wt_scan$`WT^0.25 prediction`)) < 0.03
)

The paper’s Discussion quantifies the pediatric consequence: body-weight-based dosing “would result in approximately 20% to 30% lower average exposures in the youngest patients with body weight <10-15 kg vs. typical adults.”

cave_15 <- simulate_ss(15, 40, 336)[["cave"]]
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
cave_ad <- simulate_ss(68.7, 40, 336)[["cave"]]
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
pct_lower <- 100 * (1 - cave_15 / cave_ad)
cat(sprintf("Cave at 15 kg is %.1f%% lower than at 68.7 kg\n", pct_lower))
#> Cave at 15 kg is 31.6% lower than at 68.7 kg

stopifnot(
  # Directional reproduction of a rounded prose claim ("approximately 20% to
  # 30%"), gated as a band rather than to the quoted figures.
  pct_lower > 20, pct_lower < 40
)

Concentration-time profiles

Replicates the shape of Figure 3 (prediction-corrected visual predictive checks for PBT and rADAMTS13 in the phase III study) and the Discussion statement that rADAMTS13 40 IU/kg keeps activity above 10% of normal for approximately 5 days.

profile <- function(label, iu_per_kg, tau_h, pbt) {
  n_dose <- 12L
  ev <- rxode2::et(amt = iu_per_kg * 68.7, cmt = "central", dur = 0.5,
                   ii = tau_h, addl = n_dose - 1L) |>
    rxode2::et(seq(0, tau_h * n_dose, by = 2), cmt = "central")
  dat <- as.data.frame(ev)
  dat$WT <- 68.7
  dat$TRT_PBT <- pbt
  dat$TRT_PDFVIII_VWF <- 0
  rxode2::rxSolve(pk_typ, dat, returnType = "data.frame") |>
    dplyr::filter(!is.na(Cc), time >= tau_h * (n_dose - 1)) |>
    dplyr::transmute(arm = label,
                     day = (time - tau_h * (n_dose - 1)) / 24,
                     activity = Cc / IU_L_PER_IU_ML)
}

prof <- dplyr::bind_rows(
  profile("rADAMTS13 40 IU/kg Q2W", 40, 336, 0),
  profile("PBT 10 IU/kg Q2W", 10, 336, 1)
)
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'

ggplot2::ggplot(prof, ggplot2::aes(day, activity, colour = arm)) +
  ggplot2::geom_line(linewidth = 0.9) +
  ggplot2::geom_hline(yintercept = 0.1, linetype = "dashed") +
  ggplot2::scale_y_log10() +
  ggplot2::labs(x = "Days since dose", y = "ADAMTS13 activity (IU/mL)",
                colour = NULL) +
  ggplot2::theme_bw() +
  ggplot2::theme(legend.position = "top")
Typical steady-state ADAMTS13 activity profiles over one dosing interval. The dashed line is 10% of normal activity (0.1 IU/mL), the threshold used throughout Patel 2025. Compare with Figure 3 of the paper.

Typical steady-state ADAMTS13 activity profiles over one dosing interval. The dashed line is 10% of normal activity (0.1 IU/mL), the threshold used throughout Patel 2025. Compare with Figure 3 of the paper.

days_above <- prof |>
  dplyr::group_by(arm) |>
  dplyr::summarise(days_above_10pct = max(day[activity >= 0.1]), .groups = "drop")
knitr::kable(days_above, digits = 2)
arm days_above_10pct
PBT 10 IU/kg Q2W 0.75
rADAMTS13 40 IU/kg Q2W 9.50

stopifnot(
  # Table S2: rADAMTS13 40 IU/kg Q2W keeps activity above 10% for a geometric
  # mean of 8.66 days; PBT 10 IU/kg Q2W for 0.643 days. Both are typical-value
  # comparisons against a cohort geometric mean, so the bounds are bands.
  days_above$days_above_10pct[days_above$arm == "rADAMTS13 40 IU/kg Q2W"] > 6,
  days_above$days_above_10pct[days_above$arm == "rADAMTS13 40 IU/kg Q2W"] < 11,
  days_above$days_above_10pct[days_above$arm == "PBT 10 IU/kg Q2W"] < 2
)

PKNCA validation on a virtual cohort

The checks above are typical-value. This section runs a stochastic cohort through PKNCA and compares the resulting NCA parameters against Table S2. Cohort size is 100 subjects per arm (under the library’s 200-per-arm cap).

n_per_arm <- 100L

arms <- tibble::tribble(
  ~treatment,           ~iu_per_kg, ~pbt,
  "rADAMTS13 40 IU/kg", 40,         0,
  "PBT 10 IU/kg",       10,         1
)
tau_h <- 336                      # Q2W
n_dose <- 12L
t_last <- tau_h * (n_dose - 1L)

# Weight distribution: log-normal matched to the Table 2 median (68.7 kg) and
# truncated to the observed 18.3-130.0 kg range. The paper does not publish
# individual weights, so this is an assumption (see Assumptions and deviations).
set.seed(20250909)
wt_pool <- pmin(pmax(stats::rlnorm(n_per_arm, log(68.7), 0.35), 18.3), 130.0)

build_arm <- function(treatment, iu_per_kg, pbt) {
  doses <- tidyr::expand_grid(id = seq_len(n_per_arm),
                              time = seq(0, t_last, by = tau_h)) |>
    dplyr::mutate(WT = wt_pool[id], amt = iu_per_kg * WT, evid = 1,
                  cmt = "central", dur = 0.5)
  obs <- tidyr::expand_grid(
    id = seq_len(n_per_arm),
    # dense over the final interval, sparse before it
    time = sort(unique(c(seq(0, t_last, by = 24), t_last + c(0.5, seq(2, tau_h, by = 4)))))
  ) |>
    dplyr::mutate(WT = wt_pool[id], amt = NA_real_, evid = 0,
                  cmt = "central", dur = NA_real_)
  dplyr::bind_rows(doses, obs) |>
    dplyr::mutate(TRT_PBT = pbt, TRT_PDFVIII_VWF = 0, treatment = treatment) |>
    dplyr::arrange(id, time, dplyr::desc(evid))
}

events <- purrr_map_dfr <- dplyr::bind_rows(
  build_arm(arms$treatment[1], arms$iu_per_kg[1], arms$pbt[1]) |> dplyr::mutate(id = id),
  build_arm(arms$treatment[2], arms$iu_per_kg[2], arms$pbt[2]) |> dplyr::mutate(id = id + n_per_arm)
)

sim <- rxode2::rxSolve(pk, events, returnType = "data.frame") |>
  dplyr::mutate(treatment = events$treatment[match(id, events$id)])
#> ℹ parameter labels from comments will be replaced by 'label()'

rxSolve() returns Cc as the individual prediction without residual error and sim as the value carrying residual error. NCA is run on Cc. That is the faithful comparison here, for two reasons:

  • Table S2 states that it was built from “final PopPK model-based individual post hoc parameters”, so the published numbers are themselves noise-free model predictions, not an NCA of observed concentrations.
  • Cmax computed from noisy observations is upward-biased, because it is the maximum over many draws. The bias is severe for the PBT arm specifically: the additive residual SD is 79.9 IU/L against a PBT peak of only about 160 IU/L, and running this same NCA on sim returns a PBT Cmax of 0.265 IU/mL against the published 0.160 - a 65% overstatement that is an artefact of the summary statistic, not a model error. Cave, being an average rather than an extreme, is almost unaffected (0.0284 vs 0.0306).
conc <- sim |>
  dplyr::filter(!is.na(Cc)) |>
  dplyr::transmute(
    id, treatment,
    # last interval only, re-based to 0 so PKNCA sees a clean single interval
    tad = time - t_last,
    conc = Cc / IU_L_PER_IU_ML
  ) |>
  dplyr::filter(tad >= 0, tad <= tau_h)

stopifnot(
  # Guard against a silently empty NCA input (a zero-row filter makes every
  # downstream all() vacuously TRUE).
  nrow(conc) > 0,
  dplyr::n_distinct(conc$id) == 2L * n_per_arm,
  !anyNA(conc$conc),
  # a time-zero anchor must exist for every subject
  all(tapply(conc$tad, conc$id, min) == 0)
)

dose_df <- events |>
  dplyr::filter(evid == 1, time == t_last) |>
  dplyr::transmute(id, treatment, tad = 0, amt)

o_conc <- PKNCA::PKNCAconc(conc, conc ~ tad | treatment + id,
                           concu = "IU/mL", timeu = "h")
o_dose <- PKNCA::PKNCAdose(dose_df, amt ~ tad | treatment + id,
                           doseu = "IU")

intervals <- data.frame(start = 0, end = tau_h, cmax = TRUE, cav = TRUE,
                        auclast = TRUE, tmax = TRUE)
o_data <- PKNCA::PKNCAdata(o_conc, o_dose, intervals = intervals)
res <- suppressWarnings(PKNCA::pk.nca(o_data))
nca_sim <- as.data.frame(res) |>
  dplyr::filter(PPTESTCD %in% c("cmax", "cav")) |>
  dplyr::group_by(treatment, PPTESTCD) |>
  # geometric mean, matching Table S2's summary statistic
  dplyr::summarise(value = exp(mean(log(PPORRES))), .groups = "drop") |>
  tidyr::pivot_wider(names_from = PPTESTCD, values_from = value)

nca_ref <- tibble::tibble(
  treatment = c("rADAMTS13 40 IU/kg", "PBT 10 IU/kg"),
  cmax = c(1.09, 0.160),      # Table S2, Q2W column
  cav  = c(0.203, 0.0308)     # Table S2, Q2W column
)

nlmixr2lib::ncaComparisonTable(
  simulated = nca_sim, reference = nca_ref, by = "treatment",
  params = c("cmax", "cav"), tolerance_pct = 20,
  label_first_column = "NCA parameter"
)
#>   NCA parameter          treatment Reference Simulated % diff
#> 1          Cmax rADAMTS13 40 IU/kg      1.09      1.08  -1.3%
#> 2          Cmax       PBT 10 IU/kg      0.16     0.166  +3.6%
#> 3          Cavg rADAMTS13 40 IU/kg     0.203       0.2  -1.5%
#> 4          Cavg       PBT 10 IU/kg    0.0308    0.0307  -0.5%
chk <- nca_sim |>
  dplyr::inner_join(nca_ref, by = "treatment", suffix = c("_sim", "_ref")) |>
  dplyr::mutate(pct_cmax = 100 * (cmax_sim - cmax_ref) / cmax_ref,
                pct_cav  = 100 * (cav_sim  - cav_ref)  / cav_ref)

stopifnot(
  # Cohort-level comparison against a published cohort geometric mean. The
  # bound is on the CENTRE (a geometric mean over 100 subjects), not on any
  # subject's extreme, so it is reproducible across rxode2 builds and thread
  # counts. It is slightly wider than the typical-value gate above because the
  # weight distribution is an assumption, not a published one.
  max(abs(chk$pct_cmax)) < 10,
  max(abs(chk$pct_cav)) < 10
)

Part 2 - Exposure-response: count models

Both count models predict a probability of zero events over a prophylaxis period as exp(-lambda) at the typical value. Table S7 tabulates exactly that quantity for both endpoints, at seven percentiles of the Cave distribution, for three treatment arms: 42 published values, which makes it a strong multi-point regression test of both count models at once.

#' Typical-value expected count from a Patel 2025 count model at given CAV.
count_lambda <- function(model, cav) {
  m <- rxode2::zeroRe(model)
  ev <- data.frame(id = seq_along(cav), time = 0, amt = NA_real_,
                   evid = 0, CAV = cav)
  res <- rxode2::rxSolve(m, ev, returnType = "data.frame")
  # rxode2 omits the `id` column when a single subject is solved, so only
  # re-order when it is present.
  if (!is.null(res$id)) res <- res[order(res$id), , drop = FALSE]
  stopifnot(nrow(res) == length(cav))
  res$lambda
}

s7_cav <- c(0.0155, 0.0184, 0.0277, 0.0398, 0.0491, 0.0863, 0.121,   # PBT
            0.130,  0.137,  0.155,  0.176,  0.206,  0.249,  0.396,   # rADAMTS13 Q2W
            0.239,  0.256,  0.288,  0.332,  0.351,  0.424,  0.427)   # rADAMTS13 Q1W
s7_arm <- rep(c("PBT (10 IU/kg)", "rADAMTS13 Q2W (40 IU/kg)",
                "rADAMTS13 Q1W (40 IU/kg)"), each = 7)
s7_pct <- rep(c("5%", "10%", "25%", "50%", "75%", "90%", "95%"), times = 3)

s7 <- dplyr::bind_rows(
  tibble::tibble(endpoint = "Thrombocytopenia", arm = s7_arm, percentile = s7_pct,
                 cav = s7_cav,
                 published = c(12.2, 18.3, 38.3, 54.6, 60.9, 69.0, 70.6,
                               70.8, 70.9, 71.2, 71.3, 71.5, 71.6, 71.7,
                               71.6, 71.6, 71.7, 71.7, 71.7, 71.8, 71.8),
                 predicted = 100 * exp(-count_lambda(cnt_thr, s7_cav))),
  tibble::tibble(endpoint = "Elevated LDH", arm = s7_arm, percentile = s7_pct,
                 cav = s7_cav,
                 published = c(68.3, 68.7, 69.9, 71.4, 72.6, 76.7, 80.1,
                               80.9, 81.5, 83.0, 84.6, 86.6, 89.1, 94.7,
                               88.6, 89.5, 91.0, 92.7, 93.4, 95.4, 95.5),
                 predicted = 100 * exp(-count_lambda(cnt_ldh, s7_cav)))
) |>
  dplyr::mutate(abs_diff = abs(predicted - published))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etab1'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etab1'
s7 |>
  dplyr::filter(percentile %in% c("5%", "50%", "95%")) |>
  dplyr::select(endpoint, arm, percentile, cav, published, predicted, abs_diff) |>
  dplyr::rename("Endpoint" = endpoint, "Arm" = arm, "Percentile" = percentile,
                "Cave (IU/mL)" = cav, "P(0 events) Table S7 (%)" = published,
                "P(0 events) model (%)" = predicted, "Abs diff (pp)" = abs_diff) |>
  knitr::kable(digits = c(0, 0, 0, 4, 1, 2, 3),
               caption = "Probability of zero events, model vs Table S7 (5th, 50th and 95th percentiles shown; all 42 rows are gated below).")
Probability of zero events, model vs Table S7 (5th, 50th and 95th percentiles shown; all 42 rows are gated below).
Endpoint Arm Percentile Cave (IU/mL) P(0 events) Table S7 (%) P(0 events) model (%) Abs diff (pp)
Thrombocytopenia PBT (10 IU/kg) 5% 0.0155 12.2 12.26 0.056
Thrombocytopenia PBT (10 IU/kg) 50% 0.0398 54.6 54.56 0.045
Thrombocytopenia PBT (10 IU/kg) 95% 0.1210 70.6 70.52 0.075
Thrombocytopenia rADAMTS13 Q2W (40 IU/kg) 5% 0.1300 70.8 70.72 0.077
Thrombocytopenia rADAMTS13 Q2W (40 IU/kg) 50% 0.1760 71.3 71.26 0.044
Thrombocytopenia rADAMTS13 Q2W (40 IU/kg) 95% 0.3960 71.7 71.65 0.045
Thrombocytopenia rADAMTS13 Q1W (40 IU/kg) 5% 0.2390 71.6 71.50 0.096
Thrombocytopenia rADAMTS13 Q1W (40 IU/kg) 50% 0.3320 71.7 71.62 0.078
Thrombocytopenia rADAMTS13 Q1W (40 IU/kg) 95% 0.4270 71.8 71.66 0.135
Elevated LDH PBT (10 IU/kg) 5% 0.0155 68.3 68.33 0.028
Elevated LDH PBT (10 IU/kg) 50% 0.0398 71.4 71.44 0.041
Elevated LDH PBT (10 IU/kg) 95% 0.1210 80.1 80.10 0.001
Elevated LDH rADAMTS13 Q2W (40 IU/kg) 5% 0.1300 80.9 80.90 0.004
Elevated LDH rADAMTS13 Q2W (40 IU/kg) 50% 0.1760 84.6 84.58 0.017
Elevated LDH rADAMTS13 Q2W (40 IU/kg) 95% 0.3960 94.7 94.72 0.016
Elevated LDH rADAMTS13 Q1W (40 IU/kg) 5% 0.2390 88.6 88.58 0.021
Elevated LDH rADAMTS13 Q1W (40 IU/kg) 50% 0.3320 92.7 92.74 0.043
Elevated LDH rADAMTS13 Q1W (40 IU/kg) 95% 0.4270 95.5 95.47 0.026

cat(sprintf("All %d Table S7 cells: max absolute difference = %.3f percentage points\n",
            nrow(s7), max(s7$abs_diff)))
#> All 42 Table S7 cells: max absolute difference = 0.136 percentage points

stopifnot(
  nrow(s7) == 42L,
  # Deterministic typical-value reproduction of a published table: the only
  # source of difference is the rounding of the published cells to 3 significant
  # figures, so a tight bound is correct.
  max(s7$abs_diff) < 0.2
)

Both count models reproduce every published cell of Table S7 to better than 0.2 percentage points. This simultaneously confirms the baseline counts, the sigmoid Emax parameters for thrombocytopenia, the linear slope for LDH, and that the published table was computed at the typical value rather than integrated over the between-subject random effect.

Figure 5 hazard table

The table inset in Figure 5 reports the model-predicted thrombocytopenia hazard (the expected count lambda) at each arm’s mean Cave.

fig5 <- tibble::tibble(
  treatment = c("PBT (10 IU/kg)", "rADAMTS13 (40 IU/kg) Q2W", "rADAMTS13 (40 IU/kg) Q1W"),
  cav = c(0.0447, 0.202, 0.325),
  published = c(0.538, 0.336, 0.333)
) |>
  dplyr::mutate(predicted = count_lambda(cnt_thr, cav),
                abs_diff = abs(predicted - published))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etab1'
#> Warning: There was 1 warning in `dplyr::mutate()`.
#> ℹ In argument: `predicted = count_lambda(cnt_thr, cav)`.
#> Caused by warning:
#> ! No sigma parameters in the model

fig5 |>
  dplyr::rename("Treatment" = treatment, "Mean Cave (IU/mL)" = cav,
                "Hazard, Figure 5" = published, "Hazard, model" = predicted,
                "Abs diff" = abs_diff) |>
  knitr::kable(digits = c(0, 4, 3, 4, 4))
Treatment Mean Cave (IU/mL) Hazard, Figure 5 Hazard, model Abs diff
PBT (10 IU/kg) 0.0447 0.538 0.5391 0.0011
rADAMTS13 (40 IU/kg) Q2W 0.2020 0.336 0.3370 0.0010
rADAMTS13 (40 IU/kg) Q1W 0.3250 0.333 0.3338 0.0008

stopifnot(max(fig5$abs_diff) < 0.005)

Part 3 - Exposure-response: repeated time-to-event

The RTTE models integrate the hazard into a cumulative hazard state, so event-free survival is sur = exp(-cumhaz). Table S8b publishes the model-predicted probability of being elevated-LDH-event-free at Month 6 and Month 12 for four treatment arms: eight values that gate the LDH RTTE model end to end, including the units of the baseline hazard.

#' Solve an RTTE model at a constant CAV and return event-free survival.
rtte_survival <- function(model, cav, days = c(183, 365)) {
  m <- rxode2::zeroRe(model)
  ev <- rxode2::et(seq(0, max(days), by = 1)) |> rxode2::et(id = seq_along(cav))
  dat <- as.data.frame(ev)
  dat$CAV <- cav[dat$id]
  res <- rxode2::rxSolve(m, dat, returnType = "data.frame")
  res |>
    dplyr::filter(time %in% days) |>
    dplyr::select(id, time, sur) |>
    dplyr::arrange(id, time)
}

s8_arms <- tibble::tibble(
  arm = c("PBT (10 IU/kg) Q2W", "PBT (10 IU/kg) Q1W",
          "rADAMTS13 (40 IU/kg) Q2W", "rADAMTS13 (40 IU/kg) Q1W"),
  cav = c(0.0291, 0.0582, 0.192, 0.384),      # Table S8a, median Cave
  m6  = c(0.872, 0.901, 0.906, 0.906),        # Table S8b
  m12 = c(0.760, 0.811, 0.821, 0.822)         # Table S8b
)

surv <- rtte_survival(rtte_ldh, s8_arms$cav)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etallambda0'
s8 <- s8_arms |>
  dplyr::mutate(
    pred_m6  = surv$sur[surv$time == 183],
    pred_m12 = surv$sur[surv$time == 365],
    d6  = abs(pred_m6 - m6),
    d12 = abs(pred_m12 - m12)
  )

s8 |>
  dplyr::select(arm, cav, m6, pred_m6, d6, m12, pred_m12, d12) |>
  dplyr::rename("Arm" = arm, "Median Cave (IU/mL)" = cav,
                "Month 6, Table S8b" = m6, "Month 6, model" = pred_m6, "Abs diff (M6)" = d6,
                "Month 12, Table S8b" = m12, "Month 12, model" = pred_m12, "Abs diff (M12)" = d12) |>
  knitr::kable(digits = c(0, 4, 3, 4, 5, 3, 4, 5))
Arm Median Cave (IU/mL) Month 6, Table S8b Month 6, model Abs diff (M6) Month 12, Table S8b Month 12, model Abs diff (M12)
PBT (10 IU/kg) Q2W 0.0291 0.872 0.8714 0.00058 0.760 0.7600 0.00005
PBT (10 IU/kg) Q1W 0.0582 0.901 0.9004 0.00064 0.811 0.8111 0.00011
rADAMTS13 (40 IU/kg) Q2W 0.1920 0.906 0.9058 0.00023 0.821 0.8209 0.00014
rADAMTS13 (40 IU/kg) Q1W 0.3840 0.906 0.9060 0.00002 0.822 0.8212 0.00075

stopifnot(
  # Deterministic reproduction of a published table; differences are the
  # rounding of the published cells to 3 decimal places.
  max(c(s8$d6, s8$d12)) < 0.002
)

Reproducing Table S8b exactly also pins down two things the tables do not state explicitly: that Lambda0 is a per-day rate (a per-hour reading would be off by a factor of 24), and that the published simulation held Cave constant at each arm’s median rather than driving the hazard with a time-varying daily Cave.

Maximum hazard reduction

The Results text quotes the maximum achievable hazard reduction for thrombocytopenia. It is a back-transform of Emax on the log-hazard scale.

emax_reduction <- function(model) {
  th <- rxode2::rxode(model)$theta
  100 * (1 - exp(unname(th[["emax_haz"]])))
}
red <- tibble::tibble(
  Endpoint = c("Thrombocytopenia", "Elevated LDH"),
  `Max hazard reduction (%)` = c(emax_reduction(rtte_thr), emax_reduction(rtte_ldh)),
  `Published (%)` = c(97.5, NA_real_)
)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ parameter labels from comments will be replaced by 'label()'
knitr::kable(red, digits = 2)
Endpoint Max hazard reduction (%) Published (%)
Thrombocytopenia 97.55 97.5
Elevated LDH 94.16 NA

stopifnot(
  # Results: "an Emax corresponding to a maximum 97.5% reduction in the hazard
  # of thrombocytopenia".
  abs(emax_reduction(rtte_thr) - 97.5) < 0.1
)
#> ℹ parameter labels from comments will be replaced by 'label()'

Part 4 - Cox proportional hazards (not packaged as a model)

The paper’s third exposure-response analysis is a Cox model with the log of the 1-day-lagged mean Cave as the predictor. Because the baseline hazard h0(t) is never estimated in a semiparametric fit, there is no self-contained model to simulate and no model file is shipped. The reported hazard ratios are, however, a check on the fitted coefficient, since HR = (Cave_a / Cave_b)^coefficient.

coef_ldh <- -1.5037                    # Table S6, Cox, Elevated LDH
cox <- tibble::tibble(
  arm = c("PBT Q1W", "rADAMTS13 Q2W", "rADAMTS13 Q1W"),
  cav = c(0.0582, 0.192, 0.384),
  published_hr = c(0.353, 0.0585, 0.0207)     # Table S8a, vs PBT Q2W reference
) |>
  dplyr::mutate(predicted_hr = (cav / 0.0291)^coef_ldh,   # reference = PBT Q2W
                pct = 100 * (predicted_hr - published_hr) / published_hr)

cox |>
  dplyr::rename("Arm" = arm, "Median Cave (IU/mL)" = cav,
                "HR, Table S8a" = published_hr, "HR from coefficient" = predicted_hr,
                "% diff" = pct) |>
  knitr::kable(digits = c(0, 4, 4, 4, 1))
Arm Median Cave (IU/mL) HR, Table S8a HR from coefficient % diff
PBT Q1W 0.0582 0.3530 0.3526 -0.1
rADAMTS13 Q2W 0.1920 0.0585 0.0586 0.2
rADAMTS13 Q1W 0.3840 0.0207 0.0207 -0.2

stopifnot(
  # Table S8a's HRs are rounded to 3 significant figures and its Cave values to
  # 3, so a few percent is the expected agreement.
  max(abs(cox$pct)) < 5
)

The exponentiated coefficient printed in Table S6 (Exp (Coefficient) = 0.2223 for LDH) is exp(-1.5037), confirming the sign and scale:

stopifnot(abs(exp(coef_ldh) - 0.2223) < 5e-4)

Part 5 - Linking PK to exposure-response

The paper’s headline clinical claim is that “over 90% of patients treated with 40 IU/kg rADAMTS13 (Q2W or Q1W) were predicted to have ADAMTS13 Cave >0.13 IU/mL (13% activity), and this was associated with >70% protection against thrombocytopenia” (Results), while “a similar level of protection was expected to be observed in only about <10% of cTTP patients treated with PBT.”

This section runs the packaged PK model’s cohort output through the packaged count model, which is the integration the paper performs.

cohort_cave <- as.data.frame(res) |>
  dplyr::filter(PPTESTCD == "cav") |>
  dplyr::select(id, treatment, cav = PPORRES)

frac_above <- cohort_cave |>
  dplyr::group_by(treatment) |>
  dplyr::summarise(pct_above_0.13 = 100 * mean(cav > 0.13),
                   median_cav = median(cav), .groups = "drop")
knitr::kable(frac_above, digits = c(0, 1, 4))
treatment pct_above_0.13 median_cav
PBT 10 IU/kg 0 0.0302
rADAMTS13 40 IU/kg 87 0.2064

# Protection relative to zero ADAMTS13 activity, at each arm's median Cave.
protection <- frac_above |>
  dplyr::mutate(
    lambda   = count_lambda(cnt_thr, median_cav),
    lambda_0 = count_lambda(cnt_thr, 0),
    pct_protection = 100 * (1 - lambda / lambda_0)
  )
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etab1'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etab1'
#> Warning: There were 2 warnings in `dplyr::mutate()`.
#> The first warning was:
#> ℹ In argument: `lambda = count_lambda(cnt_thr, median_cav)`.
#> Caused by warning:
#> ! No sigma parameters in the model
#> ℹ Run `dplyr::last_dplyr_warnings()` to see the 1 remaining warning.
knitr::kable(protection |> dplyr::select(treatment, median_cav, pct_protection),
             digits = c(0, 4, 1))
treatment median_cav pct_protection
PBT 10 IU/kg 0.0302 79.1
rADAMTS13 40 IU/kg 0.2064 91.7

stopifnot(
  # Directional reproduction of the paper's clinical claim.
  #
  # NOTE the bound is 85, not the paper's own 90. This cohort realises 87-89%,
  # and that is NOT Monte Carlo noise that a larger cohort removes: measured at
  # 2 and 8 solver threads with n_per_arm 100 and again at n_per_arm 500, it
  # converged to 89, 89 and 87 rather than toward 90. The reproduction is
  # therefore about 1-3 percentage points short of the published ">90%", which
  # is recorded under "Assumptions and deviations" below. Asserting 90 here
  # would make the gate encode a figure this model does not produce; asserting
  # 85 gates what it does produce while still going red if the arm separation
  # collapses.
  frac_above$pct_above_0.13[frac_above$treatment == "rADAMTS13 40 IU/kg"] > 85,
  frac_above$pct_above_0.13[frac_above$treatment == "PBT 10 IU/kg"] < 10,
  protection$pct_protection[protection$treatment == "rADAMTS13 40 IU/kg"] > 70
)

Assumptions and deviations

Facts the paper does not state, which this extraction or vignette had to supply:

  1. The >90% protection threshold is reproduced as 87-89%, not >90%. The paper’s headline claim is that “over 90% of patients treated with 40 IU/kg rADAMTS13 (Q2W or Q1W) were predicted to have ADAMTS13 Cave >0.13 IU/mL”. The packaged model driven through the packaged count model puts 87-89% of the virtual cohort above that threshold - directionally the same claim, and the companion claims reproduce cleanly (0% of the PBT arm above threshold against the paper’s “<10%”, and 92% protection against its “>70%”), but short of 90 by 1-3 points. The shortfall is stable rather than noisy: it converged to 89, 89 and 87 across two solver-thread counts and a five-fold cohort increase. The most likely source is item 2 below - the paper publishes only summary statistics for body weight, and Cave in this model is weight-dependent, so a virtual cohort reconstructed from the median and range cannot reproduce the published weight distribution exactly. The vignette gate therefore asserts >85%, and this deviation is recorded rather than tuned away.

  2. Infusion duration. The PK model is described as having a zero-order infusion but no duration is reported anywhere in the paper or supplement. The model itself does not fix one - duration is supplied by the event table - and this vignette uses 0.5 h. Because the terminal half-life is around 48 h and dosing intervals are 168-336 h, Cave is completely insensitive to this choice and Cmax only weakly so.

  3. Body-weight distribution of the virtual cohort. Only the median (68.7 kg), mean (70.0), SD (24.7) and range (18.3-130.0) are published, not individual weights. The PKNCA cohort uses a log-normal centred on the published median and truncated to the published range. This is why the cohort-level PKNCA gate is looser than the typical-value gates.

  4. Assay lower limit of quantitation, and NCA on predictions rather than simulated observations. The paper states that samples below the LLOQ were set to missing but never prints the LLOQ of the FRETS-VWF73 assay, so no BLQ rule can be reproduced. This does not affect the PKNCA section, because NCA there is run on the individual prediction Cc rather than on the residual-error-carrying sim: Table S2 is itself model-derived (from “individual post hoc parameters”), so predictions are the like-for-like comparison. It is also the only defensible choice for Cmax, which is an extreme and is therefore upward-biased by residual error - severely so for the PBT arm, whose peak (about 160 IU/L) is only twice the additive residual SD (79.9 IU/L). Users who want an observed-data NCA should run it on sim and expect that bias.

  5. Scale of the count models’ between-subject variability. Table S5 reports “BSV on B1” as 3.67 (thrombocytopenia) and 1.81 (LDH) with no footnote declaring a back-transform, unlike Table S6, which explicitly states that its IIV is a percent CV. These are therefore read as NONMEM OMEGA variances on the log-count scale. The alternative readings are excluded on magnitude: as a percent CV, 3.67 would be a 3.67% between-subject spread, which cannot produce the “count zero as well as the long-tailed distribution of PBT” that the paper credits this random effect with describing. Note that this choice does not affect any check in this vignette, because Table S7 (the 42-point gate) is computed at the typical value.

  6. RTTE cohort size. The paper prints N = 41 for the count analysis (Table S5, Table S7) but no explicit N for the RTTE analysis. Figure 1 footnote a states that two patients in the RTTE analysis were excluded from the prophylaxis count cohort, so population$n_subjects is recorded as 43 for both RTTE models. This is inferred, not printed.

  7. RTTE exposure driver. The Methods describe the RTTE hazard as driven by a time-varying daily Cave. The CAV covariate is per-record and so can be driven time-varying, but the paper’s own published simulations (Table S8b) hold it constant at each arm’s median Cave - which this vignette confirms, by reproducing all eight cells to within 0.0008 with a constant CAV. Users wanting the paper’s nominal time-varying form should supply a time-varying CAV column; nothing in the model file prevents it.

  8. Cox proportional hazards model not packaged. As described in Part 4, a semiparametric model with an unestimated baseline hazard cannot be expressed as a self-contained rxode2 model. Its coefficients are validated arithmetically here instead.

  9. Unit boundary between the PK and exposure-response models. The PK model is in IU/L and the exposure-response models take CAV in IU/mL, each matching its own source table. Every hand-off in this vignette divides by 1000 and is marked; a user wiring these models together must do the same.

  10. New canonical names registered with this extraction. Two covariate columns, TRT_PBT and TRT_PDFVIII_VWF (conforming members of the existing TRT_<arm> treatment-arm-indicator family), and two PD-output compartment names, thrombocytopenia_count and ldh_elevation_count (conforming members of the existing per-interval event-count PD-output family alongside seizure_count, hae_attacks and cel_count).

Not deviations, but worth recording: the paper reports no IIV on intercompartmental clearance or peripheral volume and no eta correlations (Table 1 leaves those cells blank), so the OMEGA matrix is diagonal with two elements; and there is no endogenous ADAMTS13 baseline term, because cTTP patients have activity below 10% of normal and sub-LLOQ samples were set to missing rather than imputed.