TMDD high vs low-affinity approximations: omalizumab, caplacizumab and linagliptin (Straube 2025)
Source:vignettes/articles/Straube_2025_TMDD_high_vs_low_affinity.Rmd
Straube_2025_TMDD_high_vs_low_affinity.RmdModel and source
Straube (2025) revisits the classical Mager & Jusko target-mediated drug disposition (TMDD) model and derives high-affinity () and low-affinity () approximations of it. Section 3.2 of the paper then fits the full TMDD model to published PK data for three molecules, and those fits are what this package carries: the approximations are analytical results that the vignette checks the fitted models against, not separate models.
Each drug was fitted twice: a one-compartment fit in the main text (Table 2, Figure 5) and a two-compartment refit in the supplement (Table S1, Figures S2-S3). Both are reported by the author with complete parameter tables and dedicated figures, so both are packaged.
mods <- c(
"Straube_2025_omalizumab_1cmt", "Straube_2025_omalizumab_2cmt",
"Straube_2025_caplacizumab_1cmt", "Straube_2025_caplacizumab_2cmt",
"Straube_2025_linagliptin_1cmt", "Straube_2025_linagliptin_2cmt"
)
# readModelDb() returns the model FUNCTION; rxode2::rxode() resolves it to the
# ui exactly once so every accessor below works.
ui <- lapply(mods, function(n) rxode2::rxode(readModelDb(n)))
names(ui) <- mods
tibble::tibble(
Model = mods,
Structure = vapply(ui, function(u) paste(u$state, collapse = ", "), character(1)),
`Source table` = c("Table 2", "Table S1", "Table 2", "Table S1", "Table 2", "Table S1")
) |>
knitr::kable(caption = "The six packaged fits and their ODE states.")| Model | Structure | Source table |
|---|---|---|
| Straube_2025_omalizumab_1cmt | depot, central, target, complex | Table 2 |
| Straube_2025_omalizumab_2cmt | depot, central, peripheral1, target, complex | Table S1 |
| Straube_2025_caplacizumab_1cmt | central, target, complex | Table 2 |
| Straube_2025_caplacizumab_2cmt | central, peripheral1, target, complex | Table S1 |
| Straube_2025_linagliptin_1cmt | central, target, complex | Table 2 |
| Straube_2025_linagliptin_2cmt | central, peripheral1, target, complex | Table S1 |
- Citation: Straube R. Target-Mediated Drug Disposition (TMDD) Revisited: High Versus Low-Affinity Approximations of the TMDD Model. CPT Pharmacometrics Syst Pharmacol. 2025;14(7):1262-1272. doi:10.1002/psp4.70048. Parameters in Table 2 were estimated by Straube by fitting the one-compartment TMDD model in Equation (2) to omalizumab total-drug, total-target and free-target time courses digitised from Meno-Tetang GML, Lowe PJ. Basic Clin Pharmacol Toxicol. 2005;96:182-192.
- Article: https://doi.org/10.1002/psp4.70048
- Supplement (Data S1: derivations, Table S1, Figures S1-S3): https://www.ebi.ac.uk/europepmc/webservices/rest/PMC12256580/supplementaryFiles
Population
The three datasets are all previously published profiles that Straube digitised and re-fitted; no new subjects were studied.
-
Omalizumab (human, n = 2) – total drug, total
target and free target for two patients in a phase I study after a
single subcutaneous dose, digitised from Meno-Tetang & Lowe (2005).
Vc,CLandRbwere estimated patient-specific; everything else is pooled across the two patients. Omalizumab is an anti-IgE monoclonal antibody; this is the paper’s low-affinity example. - Caplacizumab / ALX-0081 – a single-domain antibody against von Willebrand factor, dosed intravenously at 0.02, 0.4 and 8 mg/kg, digitised from Glassman & Muzykantov (2020). The paper’s high-affinity example with target-mediated exposure enhancement (TMEE).
- Linagliptin – a small-molecule DPP-4 inhibitor dosed intravenously at 0.5, 2.5 and 10 mg, also from Glassman & Muzykantov (2020). A second high-affinity / TMEE example, but one where the paper reports the approximations work markedly less well.
These are deterministic individual / typical-value fits: Straube reports no between-subject variability and no residual error, so none is encoded. The full metadata is available programmatically:
str(ui[["Straube_2025_caplacizumab_1cmt"]]$population, max.level = 1)
#> List of 7
#> $ species : chr "not stated in the source"
#> $ n_subjects : int NA
#> $ n_studies : int 1
#> $ disease_state: chr "ALX-0081 is a single-domain antibody against von Willebrand factor (vWF) developed to treat acquired thrombotic"| __truncated__
#> $ dose_range : chr "Single intravenous doses of 0.02, 0.4 and 8 mg/kg (Figure 5b)."
#> $ regions : chr NA
#> $ notes : chr "SPECIES AND BODY WEIGHT ARE NOT STATED. Straube 2025 gives only the mg/kg dose levels and reports Vc = 0.046 L,"| __truncated__Source trace
Every ini() entry carries an in-file comment naming its
source location. The table below collects them.
| Symbol | Parameter | 1-cmt source | 2-cmt source |
|---|---|---|---|
lvc |
Table 2 | Table S1 | |
lvp |
n/a | Table S1 | |
lcl |
Table 2 | Table S1 | |
lq |
n/a | Table S1 | |
lka (omalizumab) |
Table 2 | Table S1 | |
lfdepot (omalizumab) |
Table 2, footnote * (fixed) |
Table S1, footnote * (fixed) |
|
lk2 |
Table 2 | Table S1 | |
lkd |
Table 2 | Table S1 | |
lrbase |
Table 2 | Table S1 | |
lkdeg |
Table 2 | Table S1 | |
lkint |
Table 2 | Table S1 |
Quantities the tables print but that are derived in
model() rather than stored, exactly as the tables’ own row
labels define them:
,
(Equation 7),
(Figure 1), and the target accumulation ratio
(Equation 14).
| Equation | Source |
|---|---|
d/dt(central), d/dt(target),
d/dt(complex)
|
Equation (2) |
target(0) <- rbase * vc |
Equation (3) |
depot + f(depot) (omalizumab) |
Equation (S20)-(S21); see Errata |
peripheral1 distribution (2-cmt) |
Equation (S18) |
Cc <- (central + complex)/vc
() |
Section 2.1, |
ftbr <- freeTarget/rbase |
Equation (28) |
Table consistency
The packaged parameters must reproduce every derived quantity the paper prints. This is a closed-form check on the paper’s own tables and needs no simulation.
printed <- tibble::tribble(
~Model, ~keD, ~ksyn, ~kon, ~Tacc, ~KM_Kd,
"Straube_2025_omalizumab_1cmt", 0.0025, 1.130, 1.430, 5.058, 1.051,
"Straube_2025_omalizumab_2cmt", 0.13, 1.15, 1.01, 5.7, 1.08,
"Straube_2025_caplacizumab_1cmt", 58.80, 10.394, 2.3e4, 0.334, 1.01,
"Straube_2025_caplacizumab_2cmt", 65.6, 10.24, 2.3e4, 0.334, 1.01,
"Straube_2025_linagliptin_1cmt", 8.94, 18.529, 22.73, 86.804, 1.069,
"Straube_2025_linagliptin_2cmt", 11.37, 1.573, 22.73, 5.399, 1.061
)
encoded <- lapply(mods, function(n) {
g <- function(p) unname(exp(ui[[n]]$theta[[p]]))
tibble::tibble(
Model = n,
keD = g("lcl") / g("lvc"),
ksyn = g("lkdeg") * g("lrbase"),
kon = g("lk2") / g("lkd"),
Tacc = g("lkdeg") / g("lkint"),
KM_Kd = 1 + g("lkint") / g("lk2")
)
}) |> dplyr::bind_rows()
consistency <- printed |>
tidyr::pivot_longer(-Model, names_to = "Quantity", values_to = "Printed") |>
dplyr::inner_join(
encoded |> tidyr::pivot_longer(-Model, names_to = "Quantity", values_to = "Encoded"),
by = c("Model", "Quantity")
) |>
dplyr::mutate(`% diff` = 100 * (Encoded - Printed) / Printed)
# Deterministic: no simulation, no RNG, so a tight bound is the right bound.
# Every residual is a printed-rounding artefact -- the largest (omalizumab keD,
# 1.9%) comes from Table 2 printing keD to two significant figures (0.0025 for
# 0.002548). 2.5% still goes red on a mis-transcribed digit.
stopifnot(max(abs(consistency$`% diff`)) < 2.5)
consistency |>
dplyr::arrange(dplyr::desc(abs(`% diff`))) |>
head(8) |>
knitr::kable(digits = c(0, 0, 5, 5, 2),
caption = "Largest deviations between encoded and printed derived quantities.")| Model | Quantity | Printed | Encoded | % diff |
|---|---|---|---|---|
| Straube_2025_omalizumab_1cmt | keD | 2.500e-03 | 0.00255 | 1.91 |
| Straube_2025_omalizumab_2cmt | keD | 1.300e-01 | 0.13235 | 1.81 |
| Straube_2025_omalizumab_2cmt | Tacc | 5.700e+00 | 5.62500 | -1.32 |
| Straube_2025_caplacizumab_2cmt | keD | 6.560e+01 | 64.83333 | -1.17 |
| Straube_2025_caplacizumab_1cmt | kon | 2.300e+04 | 23266.66667 | 1.16 |
| Straube_2025_caplacizumab_2cmt | kon | 2.300e+04 | 23266.66667 | 1.16 |
| Straube_2025_caplacizumab_1cmt | keD | 5.880e+01 | 59.30435 | 0.86 |
| Straube_2025_linagliptin_1cmt | kon | 2.273e+01 | 22.63514 | -0.42 |
Dose levels
There is no virtual cohort here: the models are deterministic fits with no IIV, so the “cohort” is the set of dose levels the paper itself simulated. Doses are in nmol, the unit Equation (3) uses ( is “the drug dose (in nanomole)”).
MW <- c(omalizumab = 149000, caplacizumab = 28000, linagliptin = 472.54) # Table 2, MW row
# Omalizumab and linagliptin doses are absolute mass, so the nmol dose follows
# from the molecular weight alone: mg / (g/mol) = 1e-3 mol, i.e. 1e6 nmol.
mg_to_nmol <- function(mg, mw) mg / mw * 1e6
# Caplacizumab doses are reported only as mg/kg and the paper states neither the
# species nor the body weight (see Errata). The nmol doses are therefore
# back-computed from the paper's OWN Vc and the initial total-drug concentration
# read off Figure 5b (~133 ug/mL for 8 mg/kg), then scaled by the mg/kg ratio.
# ug/mL / (g/mol) * 1e6 gives nM; nM * L is already nmol.
cap_top_ugml <- 133
cap_top_nmol <- cap_top_ugml / MW[["caplacizumab"]] * 1e6 *
exp(ui[["Straube_2025_caplacizumab_1cmt"]]$theta[["lvc"]])
doses <- dplyr::bind_rows(
tibble::tibble(drug = "omalizumab", treatment = c("90 mg SC", "270 mg SC"),
amt = mg_to_nmol(c(90, 270), MW[["omalizumab"]]), cmt = "depot"),
tibble::tibble(drug = "caplacizumab", treatment = c("0.02 mg/kg", "0.4 mg/kg", "8 mg/kg"),
amt = cap_top_nmol * c(0.02, 0.4, 8) / 8, cmt = "central"),
tibble::tibble(drug = "linagliptin", treatment = c("0.5 mg", "2.5 mg", "10 mg"),
amt = mg_to_nmol(c(0.5, 2.5, 10), MW[["linagliptin"]]), cmt = "central")
)
knitr::kable(doses, digits = 3, caption = "Dose levels simulated, in nmol.")| drug | treatment | amt | cmt |
|---|---|---|---|
| omalizumab | 90 mg SC | 604.027 | depot |
| omalizumab | 270 mg SC | 1812.081 | depot |
| caplacizumab | 0.02 mg/kg | 0.546 | central |
| caplacizumab | 0.4 mg/kg | 10.925 | central |
| caplacizumab | 8 mg/kg | 218.500 | central |
| linagliptin | 0.5 mg | 1058.111 | central |
| linagliptin | 2.5 mg | 5290.557 | central |
| linagliptin | 10 mg | 21162.230 | central |
The linagliptin conversion is self-checking: 10 mg / 472.54 g/mol / 104.854 L = 201.8 nM, matching the ~200 nM intercept of the 10 mg curve in Figure 5c.
Simulation
# Solve one (model, dose) pair over a window scaled to that model's own
# complex-elimination time constant.
solve_one <- function(model, amt, cmt, treatment, tmax, n = 1500) {
ev <- rxode2::et(amt = amt, cmt = cmt)
ev <- rxode2::et(ev, seq(0, tmax, length.out = n))
# useLinCmt = FALSE: rxode2's automatic ODE -> linCmt conversion must not be
# allowed to reinterpret the cl/vc pair and discard the explicit binding ODEs.
s <- rxode2::rxSolve(ui[[model]], ev, returnType = "data.frame", useLinCmt = FALSE)
if (is.null(s$id)) s$id <- 1L # rxSolve omits `id` for a single subject
s$model <- model
s$treatment <- treatment
s
}Replicate published figures
Figure 5a – omalizumab (low-affinity example)
# Replicates Figure 5a of Straube 2025: total drug (upper) and total / free
# target (lower) after a single 90 mg subcutaneous dose.
oma <- solve_one("Straube_2025_omalizumab_1cmt",
doses$amt[doses$treatment == "90 mg SC"], "depot", "90 mg SC", tmax = 60)
oma |>
dplyr::select(time, `Total drug (D_T)` = Cc, `Total target (R_T)` = totalTarget,
`Free target (R)` = freeTarget) |>
tidyr::pivot_longer(-time) |>
ggplot(aes(time, value)) +
geom_line(linewidth = 0.8) +
facet_wrap(~name, scales = "free_y") +
labs(x = "Time (days)", y = "Concentration (nM)",
caption = "Replicates Figure 5a of Straube 2025 (90 mg SC omalizumab).") +
theme_bw()
The published Figure 5a peaks at roughly 60 nM around day 4-6 and falls to about 20 nM by day 60; total target rises above its 1.34 nM baseline because , while free target is suppressed and then recovers.
peak <- max(oma$Cc)
rb_oma <- exp(ui[["Straube_2025_omalizumab_1cmt"]]$theta[["lrbase"]])
# Deterministic solve of a fixed model at a fixed dose -- no cohort, no RNG.
stopifnot(
peak > 55 && peak < 70, # Figure 5a peak ~60 nM
oma$time[which.max(oma$Cc)] > 2 && oma$time[which.max(oma$Cc)] < 8,
max(oma$totalTarget) > rb_oma, # Tacc > 1 -> RT rises
min(oma$freeTarget) < 0.2 * rb_oma # free target suppressed
)Figures 5b and 5c – caplacizumab and linagliptin (high-affinity examples)
# Replicates Figures 5b and 5c of Straube 2025. Both panels are plotted on the
# paper's x-axis units (hours) although the models work in days.
hi <- dplyr::bind_rows(
lapply(which(doses$drug == "caplacizumab"), function(i)
solve_one("Straube_2025_caplacizumab_1cmt", doses$amt[i], "central",
doses$treatment[i], tmax = 80 / 24)),
lapply(which(doses$drug == "linagliptin"), function(i)
solve_one("Straube_2025_linagliptin_1cmt", doses$amt[i], "central",
doses$treatment[i], tmax = 100 / 24))
)
hi <- hi |> dplyr::mutate(
drug = ifelse(grepl("caplacizumab", model), "ALX-0081 (caplacizumab)", "Linagliptin"),
# Figure 5b plots ug/mL; Figure 5c plots nM.
y = ifelse(grepl("caplacizumab", model), Cc * MW[["caplacizumab"]] / 1e6, Cc),
treatment = factor(treatment,
levels = c("0.02 mg/kg", "0.4 mg/kg", "8 mg/kg",
"0.5 mg", "2.5 mg", "10 mg"))
)
ggplot(hi, aes(time * 24, y, colour = treatment)) +
geom_line(linewidth = 0.8) +
facet_wrap(~drug, scales = "free") +
scale_y_log10() +
labs(x = "Time (h)", y = "Total drug (ug/mL for ALX-0081; nM for linagliptin)",
colour = NULL,
caption = "Replicates Figures 5b and 5c of Straube 2025 (upper panels).") +
theme_bw()
# Lower panels of Figures 5b and 5c: free target to baseline ratio (Equation 28).
ftbr <- dplyr::bind_rows(
lapply(which(doses$drug == "caplacizumab"), function(i)
solve_one("Straube_2025_caplacizumab_1cmt", doses$amt[i], "central",
doses$treatment[i], tmax = 500 / 24)),
lapply(which(doses$drug == "linagliptin"), function(i)
solve_one("Straube_2025_linagliptin_1cmt", doses$amt[i], "central",
doses$treatment[i], tmax = 25 / 24))
) |>
dplyr::mutate(drug = ifelse(grepl("caplacizumab", model),
"ALX-0081 (caplacizumab)", "Linagliptin"))
ggplot(ftbr, aes(time * 24, ftbr, colour = treatment)) +
geom_line(linewidth = 0.8) +
facet_wrap(~drug, scales = "free_x") +
labs(x = "Time (h)", y = "FTBR", colour = NULL,
caption = "Replicates Figures 5b and 5c of Straube 2025 (lower panels).") +
theme_bw()
Mechanistic validation
The paper’s analytical results give several closed-form predictions that the packaged full-TMDD models must satisfy. All are deterministic, so the bounds below are tight by design (see pattern 11 of the skill’s failure-pattern notes).
Drug-free steady state (Equation 3)
With no dose, the target must sit exactly at its basal level
for all time – this is what ties target(0) <- rbase * vc
to ksyn <- kdeg * rbase.
ss <- vapply(mods, function(n) {
s <- rxode2::rxSolve(ui[[n]], rxode2::et(seq(0, 30, length.out = 200)),
returnType = "data.frame", useLinCmt = FALSE)
max(abs(s$freeTarget - exp(ui[[n]]$theta[["lrbase"]])))
}, numeric(1))
stopifnot(max(ss) < 1e-8)
knitr::kable(tibble::tibble(Model = mods, `Max |R - Rb| (nM)` = ss),
caption = "Drug-free steady-state hold. Exact by construction.")| Model | Max |R - Rb| (nM) |
|---|---|
| Straube_2025_omalizumab_1cmt | 0 |
| Straube_2025_omalizumab_2cmt | 0 |
| Straube_2025_caplacizumab_1cmt | 0 |
| Straube_2025_caplacizumab_2cmt | 0 |
| Straube_2025_linagliptin_1cmt | 0 |
| Straube_2025_linagliptin_2cmt | 0 |
Sub-saturating high-affinity kinetics (Equation 12)
For a high-affinity drug dosed below the target level (), Equation (12) predicts that total drug decays as a single exponential with half-life , and hence that and . The 0.02 mg/kg caplacizumab dose is exactly this case – and the paper singles it out in the Figure 5 caption (“for 0.02 mg/kg the cross-over time does not exist since the initial drug concentration is lower than the target concentration”).
u <- ui[["Straube_2025_caplacizumab_1cmt"]]
g <- function(p) unname(exp(u$theta[[p]]))
D0 <- doses$amt[doses$treatment == "0.02 mg/kg"]
sub <- solve_one("Straube_2025_caplacizumab_1cmt", D0, "central", "0.02 mg/kg",
tmax = 8 * log(2) / g("lkint"), n = 4000)
stopifnot(sub$Cc[1] < g("lrbase")) # confirms Equation (12) applies at all
w <- sub$time > 6 * log(2) / g("lkint") # terminal window, clear of the noise floor
slope <- coef(lm(log(sub$Cc[w]) ~ sub$time[w]))[2]
eq12 <- tibble::tibble(
Quantity = c("DT(0) (nM)", "terminal t1/2 (d)"),
Predicted = c(D0 / g("lvc"), log(2) / g("lkint")),
Observed = c(sub$Cc[1], -log(2) / slope)
) |> dplyr::mutate(`% diff` = 100 * (Observed - Predicted) / Predicted)
# Achieved -0.7%; 3 still goes red on a mis-transcribed keDR, Vc or dose.
stopifnot(max(abs(eq12$`% diff`)) < 3)
knitr::kable(eq12, digits = 4,
caption = "Equation (12) closed form vs the full TMDD solve.")| Quantity | Predicted | Observed | % diff |
|---|---|---|---|
| DT(0) (nM) | 11.8750 | 11.8750 | 0.0000 |
| terminal t1/2 (d) | 0.7312 | 0.7259 | -0.7218 |
Cross-over time (Equation 15)
Above saturation, Equation (15) predicts the time at which falls to meet .
crossover <- lapply(
list(c("Straube_2025_caplacizumab_1cmt", "8 mg/kg", "3"),
c("Straube_2025_linagliptin_1cmt", "10 mg", "5")),
function(x) {
uu <- ui[[x[1]]]; gg <- function(p) unname(exp(uu$theta[[p]]))
d0 <- doses$amt[doses$treatment == x[2]]
keD <- gg("lcl") / gg("lvc"); ksyn <- gg("lkdeg") * gg("lrbase")
tc <- (1 / keD) * log((ksyn / keD + d0 / gg("lvc") - gg("lrbase")) / (ksyn / keD))
s <- solve_one(x[1], d0, "central", x[2], tmax = as.numeric(x[3]), n = 20000)
tibble::tibble(Model = x[1], Dose = x[2],
`Eq (15) tc (h)` = tc * 24,
`Simulated DT=RT (h)` = s$time[which(s$Cc <= s$totalTarget)[1]] * 24)
}) |> dplyr::bind_rows() |>
dplyr::mutate(`% diff` = 100 * (`Simulated DT=RT (h)` - `Eq (15) tc (h)`) / `Eq (15) tc (h)`)
# Eq (15) comes from the high-affinity APPROXIMATION (Eq 13), so a few percent
# of approximation error is expected and is itself the paper's subject; the
# larger caplacizumab residual is the keD >> keR regime the paper flags in
# Figure 3b. 10% still goes red on a transcription error.
stopifnot(max(abs(crossover$`% diff`)) < 10)
knitr::kable(crossover, digits = 3,
caption = "Equation (15) cross-over time vs the simulated DT = RT crossing.")| Model | Dose | Eq (15) tc (h) | Simulated DT=RT (h) | % diff |
|---|---|---|---|---|
| Straube_2025_caplacizumab_1cmt | 8 mg/kg | 4.128 | 3.856 | -6.592 |
| Straube_2025_linagliptin_1cmt | 10 mg | 12.295 | 12.367 | 0.584 |
Target suppression and recovery (Equations 29-31)
The FTBR must be strongly suppressed while the target is saturated and return to unity once drug has washed out. Suppression is measured over the paper’s own figure windows; recovery needs a longer window, because for linagliptin the free target does not approach baseline monotonically (below).
suppression <- ftbr |> dplyr::group_by(drug, treatment) |>
dplyr::summarise(`FTBR min` = min(ftbr), .groups = "drop")
# Recovery over a full-washout window rather than the figure window.
washout <- dplyr::bind_rows(lapply(seq_len(nrow(doses))[doses$drug != "omalizumab"], function(i) {
model <- if (doses$drug[i] == "caplacizumab") "Straube_2025_caplacizumab_1cmt"
else "Straube_2025_linagliptin_1cmt"
s <- solve_one(model, doses$amt[i], "central", doses$treatment[i], tmax = 30, n = 2000)
tibble::tibble(treatment = doses$treatment[i],
`FTBR max (overshoot)` = max(s$ftbr),
`FTBR at 30 d` = dplyr::last(s$ftbr))
}))
rec <- suppression |> dplyr::inner_join(washout, by = "treatment")
# Deterministic solves. Achieved: suppression <= 0.64, |1 - FTBR(30 d)| <= 7.4e-5.
stopifnot(all(rec$`FTBR min` < 0.75),
all(abs(rec$`FTBR at 30 d` - 1) < 1e-3))
knitr::kable(rec, digits = 5,
caption = "Target suppression, transient overshoot, and recovery to baseline.")| drug | treatment | FTBR min | FTBR max (overshoot) | FTBR at 30 d |
|---|---|---|---|---|
| ALX-0081 (caplacizumab) | 0.02 mg/kg | 0.63970 | 1.00000 | 0.99997 |
| ALX-0081 (caplacizumab) | 0.4 mg/kg | 0.00004 | 1.00000 | 0.99993 |
| ALX-0081 (caplacizumab) | 8 mg/kg | 0.00000 | 1.00000 | 0.99993 |
| Linagliptin | 0.5 mg | 0.08030 | 1.04723 | 1.00000 |
| Linagliptin | 10 mg | 0.00264 | 1.10839 | 1.00000 |
| Linagliptin | 2.5 mg | 0.01145 | 1.08051 | 1.00000 |
For linagliptin the FTBR overshoots baseline by up to 11% before settling. This is the expected consequence of : by Equation (32) the total target accumulates towards while drug is present, so as the complex is cleared the liberated free target transiently exceeds its basal level. The paper’s Figure 5c is drawn on a 0-25 h window with the y-axis capped at 1, so it shows only the rise. Caplacizumab, with , shows no overshoot – its total target is depleted rather than accumulated, and the FTBR approaches 1 from below.
Affinity classification (Equation 33)
Equation (33) classifies a drug as high-affinity when and low-affinity when , with (Equation 7). This reproduces the paper’s own assignment of each molecule.
cls <- lapply(mods[c(1, 3, 5)], function(n) {
gg <- function(p) unname(exp(ui[[n]]$theta[[p]]))
KM <- gg("lkd") * (1 + gg("lkint") / gg("lk2"))
Tacc <- gg("lkdeg") / gg("lkint"); Rb <- gg("lrbase")
tibble::tibble(Model = n, `KM (nM)` = KM, `Tacc*Rb (nM)` = Tacc * Rb, `Rb (nM)` = Rb,
`KM / min(Tacc*Rb, Rb)` = KM / min(Tacc * Rb, Rb),
`KM / max(Tacc*Rb, Rb)` = KM / max(Tacc * Rb, Rb))
}) |> dplyr::bind_rows()
# The paper states KM/(Tacc*Rb) = 3.3e-4 for ALX-0081 and "about 100-fold
# larger" for linagliptin (section 3.2).
alx <- cls$`KM / min(Tacc*Rb, Rb)`[grepl("caplacizumab", cls$Model)]
lin <- cls$`KM / min(Tacc*Rb, Rb)`[grepl("linagliptin", cls$Model)]
stopifnot(abs(alx - 3.3e-4) / 3.3e-4 < 0.1, # paper: 3.3e-4
lin / alx > 30, lin / alx < 300) # paper: "about 100-fold larger"
knitr::kable(cls, digits = 5,
caption = "Equation (33) affinity classification, one-compartment fits.")| Model | KM (nM) | Tacc*Rb (nM) | Rb (nM) | KM / min(Tacc*Rb, Rb) | KM / max(Tacc*Rb, Rb) |
|---|---|---|---|---|---|
| Straube_2025_omalizumab_1cmt | 2.41682 | 6.76625 | 1.342 | 1.80091 | 0.35719 |
| Straube_2025_caplacizumab_1cmt | 0.00364 | 10.96793 | 32.800 | 0.00033 | 0.00011 |
| Straube_2025_linagliptin_1cmt | 0.07908 | 161.11078 | 1.855 | 0.04263 | 0.00049 |
Omalizumab sits between the two limits – the paper notes that its “is in the same order of magnitude as ”, which is why the low-affinity approximation reproduces it only approximately.
PKNCA validation
NCA on the simulated total-drug profiles for the two intravenously dosed molecules. The observation window is eight terminal half-lives, long enough to capture but short enough that the tail has not decayed into solver noise (which would corrupt the half-life fit).
nca_sims <- lapply(which(doses$drug %in% c("caplacizumab", "linagliptin")), function(i) {
model <- if (doses$drug[i] == "caplacizumab") "Straube_2025_caplacizumab_1cmt"
else "Straube_2025_linagliptin_1cmt"
solve_one(model, doses$amt[i], "central", doses$treatment[i],
tmax = 8 * log(2) / exp(ui[[model]]$theta[["lkint"]]), n = 4000)
}) |> dplyr::bind_rows()
# Only `!is.na(Cc)` -- a `time > 0` or `Cc > 0` filter would drop the time-zero
# row that anchors AUC0-*.
sim_nca <- nca_sims |>
dplyr::filter(!is.na(Cc)) |>
dplyr::select(id, time, Cc, treatment)
stopifnot(nrow(sim_nca) > 0, !anyNA(sim_nca$Cc), all(sim_nca$Cc >= 0))
conc_obj <- PKNCA::PKNCAconc(sim_nca, Cc ~ time | treatment + id)
dose_df <- doses |>
dplyr::filter(drug %in% c("caplacizumab", "linagliptin")) |>
dplyr::transmute(id = 1L, time = 0, amt, treatment)
dose_obj <- PKNCA::PKNCAdose(dose_df, amt ~ time | treatment + id)
intervals <- data.frame(start = 0, end = Inf,
cmax = TRUE, aucinf.obs = TRUE, half.life = TRUE)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals))Comparison against the paper’s closed-form predictions
Straube reports no NCA table, but Equations (12) and (17) are closed-form NCA predictions: for a high-affinity drug the terminal half-life is at every dose, and for an IV bolus.
reference <- doses |>
dplyr::filter(drug %in% c("caplacizumab", "linagliptin")) |>
dplyr::rowwise() |>
dplyr::mutate(
.m = if (drug == "caplacizumab") "Straube_2025_caplacizumab_1cmt"
else "Straube_2025_linagliptin_1cmt",
cmax = amt / exp(ui[[.m]]$theta[["lvc"]]),
half.life = log(2) / exp(ui[[.m]]$theta[["lkint"]])
) |>
dplyr::ungroup() |>
dplyr::select(treatment, cmax, half.life)
cmp <- nlmixr2lib::ncaComparisonTable(
simulated = nca_res, reference = reference, by = "treatment",
units = c(cmax = "nM", half.life = "day"), tolerance_pct = 20
)
knitr::kable(cmp, caption = paste(
"Simulated NCA vs the paper's closed-form predictions.",
"* differs from reference by >20%."))| NCA parameter | treatment | Reference | Simulated | % diff |
|---|---|---|---|---|
| Cmax (nM) | 0.02 mg/kg | 11.9 | 11.9 | +0.0% |
| Cmax (nM) | 0.4 mg/kg | 238 | 238 | +0.0% |
| Cmax (nM) | 8 mg/kg | 4750 | 4750 | +0.0% |
| Cmax (nM) | 0.5 mg | 10.1 | 10.1 | +0.0% |
| Cmax (nM) | 2.5 mg | 50.5 | 50.5 | +0.0% |
| Cmax (nM) | 10 mg | 202 | 202 | +0.0% |
| t½ (day) | 0.02 mg/kg | 0.731 | 0.725 | -0.8% |
| t½ (day) | 0.4 mg/kg | 0.731 | 0.72 | -1.5% |
| t½ (day) | 8 mg/kg | 0.731 | 0.72 | -1.5% |
| t½ (day) | 0.5 mg | 6.03 | 1.74 | -71.1%* |
| t½ (day) | 2.5 mg | 6.03 | 1.74 | -71.1%* |
| t½ (day) | 10 mg | 6.03 | 1.74 | -71.1%* |
# ncaComparisonTable's "% diff" column is FORMATTED CHARACTER, so gate on raw
# numbers computed here rather than parsing the rendered table.
obs <- as.data.frame(nca_res) |>
dplyr::filter(PPTESTCD %in% c("cmax", "half.life")) |>
dplyr::select(treatment, PPTESTCD, PPORRES) |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |>
dplyr::inner_join(reference, by = "treatment", suffix = c("_obs", "_ref")) |>
dplyr::mutate(cmax_pct = 100 * (cmax_obs - cmax_ref) / cmax_ref,
thalf_pct = 100 * (half.life_obs - half.life_ref) / half.life_ref,
drug = ifelse(grepl("mg/kg", treatment), "caplacizumab", "linagliptin"))
# Cmax = D0/Vc is exact for an IV bolus, for both drugs.
stopifnot(max(abs(obs$cmax_pct)) < 0.5)
# The half-life claim holds for caplacizumab (achieved -0.8%) but NOT for
# linagliptin -- see the deviation discussion below. Gate the case that holds
# and keep the other visible in the table above rather than widening to cover it.
stopifnot(max(abs(obs$thalf_pct[obs$drug == "caplacizumab"])) < 3)
knitr::kable(obs |> dplyr::select(treatment, cmax_pct, thalf_pct),
digits = 2,
caption = "Percent difference from the closed-form predictions.")| treatment | cmax_pct | thalf_pct |
|---|---|---|
| 0.02 mg/kg | 0 | -0.80 |
| 0.4 mg/kg | 0 | -1.46 |
| 0.5 mg | 0 | -71.11 |
| 10 mg | 0 | -71.11 |
| 2.5 mg | 0 | -71.12 |
| 8 mg/kg | 0 | -1.48 |
Known deviation: the linagliptin terminal half-life
For linagliptin the simulated terminal half-life is about 1.74 days against the days that Equation (17) predicts – a -71% deviation, identical across all three doses.
This is not an encoding error; it reproduces a result the paper itself reports. Equation (17) is the high-affinity approximation, which requires binding to be fast relative to drug elimination. Linearising the full binding system (Equation 2) about the basal target level gives a slow eigenvalue that is the true terminal rate constant, and it equals only in that fast-binding limit:
slow_eigen <- function(n) {
gg <- function(p) unname(exp(ui[[n]]$theta[[p]]))
konRb <- (gg("lk2") / gg("lkd")) * gg("lrbase"); keD <- gg("lcl") / gg("lvc")
A <- matrix(c(-(konRb + keD), gg("lk2"), konRb, -(gg("lk2") + gg("lkint"))), 2, 2, byrow = TRUE)
max(Re(eigen(A)$values))
}
tibble::tibble(
Model = mods[c(3, 5)],
`koff / keD` = vapply(mods[c(3, 5)], function(n)
exp(ui[[n]]$theta[["lk2"]]) / (exp(ui[[n]]$theta[["lcl"]]) / exp(ui[[n]]$theta[["lvc"]])),
numeric(1)),
`t1/2 from slow eigenvalue (d)` = vapply(mods[c(3, 5)],
function(n) -log(2) / slow_eigen(n), numeric(1)),
`ln(2)/keDR (d)` = vapply(mods[c(3, 5)],
function(n) log(2) / exp(ui[[n]]$theta[["lkint"]]), numeric(1)),
`Simulated (d)` = c(mean(obs$half.life_obs[obs$drug == "caplacizumab"]),
mean(obs$half.life_obs[obs$drug == "linagliptin"]))
) |>
knitr::kable(digits = 4, caption = paste(
"The simulated terminal half-life tracks the slow eigenvalue of the",
"linearised binding system, not ln(2)/keDR, whenever koff is not large",
"relative to keD."))| Model | koff / keD | t1/2 from slow eigenvalue (d) | ln(2)/keDR (d) | Simulated (d) |
|---|---|---|---|---|
| Straube_2025_caplacizumab_1cmt | 1.4124 | 0.7263 | 0.7312 | 0.7220 |
| Straube_2025_linagliptin_1cmt | 0.1874 | 1.7409 | 6.0274 | 1.7411 |
Caplacizumab has with an extremely fast on-rate, so the approximation holds; linagliptin has , and the paper’s own supplement (Equations S11-S17, Figure S1) analyses exactly this regime as the case where “the quasi-steady state assumption leading to Equation (5) is not valid anymore”. Consistently, the main text states that “the approximation for (especially the slow terminal elimination phase) is much better for ALX-0081 than for linagliptin” (section 3.2), which is what Figure 5c shows: the dashed Equation (17) curves lie well above the solid full-model curves at late times.
Assumptions and deviations
-
Subcutaneous bioavailability (omalizumab).
Supplement Equation (S20) is printed as
with
.
Taken literally,
is then only a rescaling of the absorption rate constant and the
entire dose reaches the central compartment. Simulating that
form gives a peak of 143 nM at day 9, against roughly 60 nM at day 4-6
in Figure 5a; encoding
as an ordinary bioavailable fraction
(
f(depot) <- exp(lfdepot)) gives 61.6 nM at day 4.25 and reproduces the figure. The models therefore use standard bioavailability, and this is the only place where the encoding departs from an equation as printed. - Caplacizumab doses are figure-derived. The paper reports the ALX-0081 doses only as mg/kg and states neither the species nor the body weight; its fitted of 0.046 L is not consistent with an adult human, and the upstream data source (Glassman & Muzykantov 2020) is not open access. The nmol doses used here were back-computed from the paper’s own and the ~133 ug/mL initial concentration of the 8 mg/kg profile in Figure 5b, then scaled by the mg/kg ratio. This is a figure-derived input, not a paper-tabulated one. It is self-consistent: the resulting 0.02 mg/kg profile starts at 11.9 nM, below the 32.8 nM target level, which is exactly the condition the Figure 5 caption gives for that dose having no cross-over time. No model parameter depends on it.
-
Printed rounding. Several Table 2 / Table S1
columns are rounded more coarsely than the quantities derived from them.
keD = CL/Vccomputed from the printedCLandVcdiffers from the printedkeDby 1.9% (omalizumab, Table 2, wherekeDis printed as 0.0025 for 0.002548), 0.9% (caplacizumab Table 2) and 1.2% (caplacizumab Table S1);kon = koff/Kddiffers by up to 1.2%; andTacc = keR/keDRdiffers by 1.3% for omalizumab Table S1, wherekeDRis printed to two significant figures. The primary estimates (CL,Vc,koff,Kd,keR,keDR) are encoded as printed and the derived quantities are computed inmodel(), so these residuals are visible in the table-consistency check rather than hidden. -
Target accumulation ratio
is not a stored parameter. It is exactly
(Equation 14) and is recovered as
kdeg/kint, so storing it would duplicate an existing degree of freedom. Where the paper fixed (caplacizumab, Table 2 footnote a) and estimated , the derived is encoded un-fixed()because it inherits ’s uncertainty; the in-file comment records this. -
Specimen is not stated. The paper does not say
whether concentrations are plasma or serum, so every circulating
compartmentDataentry is markedverified = FALSEwithspecimen = "plasma"as the assumption. - No IIV and no residual error. These are individual (omalizumab) or typical-value (caplacizumab, linagliptin) deterministic fits; the paper reports no variance components, and none were invented.
-
Two patients, one encoded. The omalizumab files
carry the Figure 5a / Figure S3a patient. The second patient’s
patient-specific values are recorded in each file’s
population$notes.