Tideglusib (Di Deo 2025)
Source:vignettes/articles/DiDeo_2025_tideglusib.Rmd
DiDeo_2025_tideglusib.RmdModel and source
This paper contributes two models, extracted as two files because the authors fitted two separate final models on two different cohorts (the Phase I fit was used as a Bayesian prior for the Phase II fit, not merged with it):
-
DiDeo_2025_tideglusib_healthy– Phase I study NP031112-07A03, elderly healthy subjects (n = 54, 1832 samples, rich sampling). -
DiDeo_2025_tideglusib_dm1– Phase II study AMO-02-MD-2-001, adolescent and adult DM-1 patients (n = 16, 51 samples, sparse sampling), estimated with NONMEM$PRIOR NWPRIusing the healthy-subject model as the prior.
#> ℹ parameter labels from comments will be replaced by 'label()'
-
DiDeo_2025_tideglusib_healthy– Two-compartment population PK model with first-order absorption and dose-dependent bioavailability for tideglusib in elderly healthy subjects (Phase I study NP031112-07A03)
#> ℹ parameter labels from comments will be replaced by 'label()'
DiDeo_2025_tideglusib_dm1– Two-compartment population PK model with first-order absorption and dose-dependent bioavailability for tideglusib in adolescent and adult patients with congenital or juvenile-onset myotonic dystrophy type 1 (Phase II study AMO-02-MD-2-001), estimated using the elderly healthy subject model as a Bayesian priorCitation: Di Deo A, Oosterholt S, Horrigan J, Evans S, McMorn A, Della Pasqua O. Population Pharmacokinetics of Tideglusib in Congenital and Childhood Myotonic Dystrophy Type 1: Influence of Demographic and Clinical Factors on Systemic Exposure. Pharmaceutics. 2025;17(8):1065. doi:10.3390/pharmaceutics17081065. Estimated with NONMEM $PRIOR NWPRI using the healthy-elderly-subject model of the same paper as the prior; see modellib(‘DiDeo_2025_tideglusib_healthy’).
Supplement: https://www.mdpi.com/article/10.3390/pharmaceutics17081065/s1
Both models share one structure: two-compartment disposition with first-order oral absorption, allometric body-weight scaling on all four disposition parameters, and a dose-dependent absorption rate constant and relative bioavailability that switch at a 400 mg per-administration threshold.
Population
Phase I (NP031112-07A03). 72 elderly subjects were randomised across six dose groups (12 per group, 9 active : 3 placebo); the 54 who received active tideglusib contributed 1832 plasma samples. Age 60-74 years (mean 64.3, s.d. 3.5), body weight 50.7-98.1 kg (mean 74.5, s.d. 9.9), 24 of 54 female. Dose groups were 300 mg b.i.d., 400 mg b.i.d., and 600, 800, 1000 and 1200 mg q.d. as an oral suspension of formulation F06-037F, given as a single dose, a 48 h washout, then 14 days of repeat dosing (Di Deo 2025 Section 2.1, Section 2.2 and Table 1, first six rows).
Phase II (AMO-02-MD-2-001). 16 adolescent and adult patients with congenital or juvenile-onset DM-1, aged 13.8-34.9 years (mean 20.9, s.d. 5.9), body weight 36.8-122.6 kg (mean 63.6, s.d. 19.6), 6 of 16 female, randomised 1:1 to 400 mg or 1000 mg q.d. for 12 weeks after a 2-week single-blind placebo run-in. Sampling was sparse – pre-dose and 2-4 h post-dose at weeks 2 and 12 – giving 51 evaluable samples, 9 of them below the 1 ng/mL LLOQ and all 9 pre-dose troughs (Section 2.2 and Table 1, last two rows).
The same information is available programmatically via each model’s
population metadata
(readModelDb("<model>")()$population).
Source trace
Every ini() entry carries an in-file comment naming its
source location; the table below collects them. All parameter values
come from Di Deo 2025 Table 2, which reports both fits
side by side.
Table 2 column order. The table’s title reads “in healthy elderly subjects and DM-1 patients”, but its columns are ordered AMO-02-MD-2-001 (DM-1 patients) first, then NP031112-07A03 (healthy elderly). The column headers are correct as printed; the title order is not the column order. This was confirmed against the paper’s own precision claims: Section 3.1 reports the healthy fit as “%RSE < 27.4%” (fixed effects) and “< 41.1%” (IIV), exactly the maxima of the NP031112-07A03 RSE column, while the DM-1 claims (“< 23%”, “< 32%”) match the AMO-02-MD-2-001 maxima (22.1 and 31.6). Independently, the Discussion’s headline figures – “apparent systemic clearance of 341 L/h” and an apparent steady-state volume of “1140 L” (= V2 + V3 = 154 + 986) – are the AMO-02-MD-2-001 column.
| Parameter | Healthy (NP031112-07A03) | DM-1 (AMO-02-MD-2-001) | Source location |
|---|---|---|---|
lka |
log(0.78) | log(0.767) | Table 2, “Absorption rate constant, kA (1/h)” |
lka_highdose |
log(0.61) | log(0.609) | Table 2, “kA (dose > 400 mg) (1/h)” |
lcl |
log(327) | log(341) | Table 2, “Clearance, CL (L/h)” |
lvc |
log(152) | log(154) | Table 2, “Volume of distribution central compartment, V2 (L)” |
lq |
log(66.1) | log(66.1) | Table 2, “Intercompartmental clearance, Q (L/h)” |
lvp |
log(1010) | log(986) | Table 2, “Volume of distribution peripheral compartment, V3 (L)” |
lfdepot |
fixed(log(1)) | fixed(log(1)) | Table 2, “Bioavailability (doses <= 400 mg)” = 1, FIXED |
lfdepot_highdose |
log(0.85) | log(0.88) | Table 2, “Bioavailability (doses > 400 mg)” |
e_wt_cl_q |
fixed(0.75) | fixed(0.75) | Section 2.4.1, “fixed allometric exponents on clearance (0.75)” |
e_wt_vc_vp |
fixed(1) | fixed(1) | Section 2.4.1, “…and volumes (1)” |
etalcl |
0.18 | 0.186 | Table 2, “eta CL variance” |
etalvc |
0.33 | 0.321 | Table 2, “eta V2 variance” |
etalq |
0.96 | 1.15 | Table 2, “eta Q variance” |
etalvp |
0.90 | 1.01 | Table 2, “eta V3 variance” |
etalka |
0.06 | 0.069 | Table 2, “eta KA variance” |
propSd |
sqrt(0.56) | sqrt(0.54) | Table 2, “Proportional error (doses <= 400 mg)” |
propSd_highdose |
sqrt(0.45) | sqrt(0.46) | Table 2, “Proportional error (doses > 400 mg)” |
Allometry (WT/70)^exp
|
n/a | n/a | Section 2.4.1, reference weight 70 kg |
| 2-compartment ODEs, first-order absorption | n/a | n/a | Figure 1 schematic; Section 3.1 |
Two readings that had to be settled from the source
The IIV column is a VARIANCE, not a standard
deviation. Table 2 prints “Population Estimate (CV%)”. Taking
the DM-1 column, whose values are given to three significant figures,
CV% = sqrt(variance) * 100 reproduces every printed CV to
the digit, whereas the log-normal form
sqrt(exp(variance) - 1) * 100 does not:
v <- c(etalcl = 0.186, etalq = 1.15, etalvp = 1.01,
etalvc = 0.321, etalka = 0.069)
printed_cv <- c(43.1, 107.2, 100.5, 56.7, 26.4) # Table 2, DM-1 column
data.frame(
variance = v,
`sqrt(var)*100` = round(sqrt(v) * 100, 1),
`sqrt(exp(v)-1)` = round(sqrt(exp(v) - 1) * 100, 1),
printed_cv = printed_cv,
check.names = FALSE
)
#> variance sqrt(var)*100 sqrt(exp(v)-1) printed_cv
#> etalcl 0.186 43.1 45.2 43.1
#> etalq 1.150 107.2 146.9 107.2
#> etalvp 1.010 100.5 132.1 100.5
#> etalvc 0.321 56.7 61.5 56.7
#> etalka 0.069 26.3 26.7 26.4
# The sqrt(variance) column matches the printed CV%; the log-normal one does not.
stopifnot(max(abs(sqrt(v) * 100 - printed_cv)) < 0.2)Table 2’s footnote b prints the formula as
CV = sqrt(exp(Omega^2)) x 100, which does
not reproduce its own column; the arithmetic that does
is sqrt(variance). nlmixr2 takes variances on the
ini() diagonal, so the printed values are used
directly.
The residual-error rows are variances too. Table 2’s
abbreviation footnote defines sigma = residual variance,
and the IIV rows of the same table are demonstrably variances (above),
so the residual values are converted to the standard deviations nlmixr2
expects: propSd <- sqrt(0.54). The sqrt()
is kept inline in ini() so the printed value stays visible
in the source trace.
Virtual cohort
Original observed data are not publicly available. The cohorts below are virtual populations whose body-weight distributions approximate the published per-arm medians and ranges (Table 1). Each cohort is 100-200 subjects, within the 200-per-arm cap.
# set.seed() seeds R's RNG. It does NOT seed rxode2's simulation RNG, and
# rxode2's streams are partitioned PER SOLVER THREAD -- so this cohort is
# reproducible on this machine and different on a machine with a different
# thread count. Every assertion below is written to hold for ANY cohort the
# model can produce (see known-vignette-failure-patterns.md pattern 12).
set.seed(20250216)
rxode2::rxSetSeed(20250216)
# Observation grid: dense through the absorption/peak phase (Tmax ~ 0.7 h) so
# the trapezoidal AUC and Cmax are resolved, coarser thereafter.
grid_24 <- sort(unique(c(seq(0, 4, by = 0.05), seq(4, 24, by = 0.25))))
grid_12 <- grid_24[grid_24 <= 12]
# Build one steady-state cohort. Doses run to steady state (>= 240 h of
# dosing) and observations cover only the final dosing interval, with time
# re-based so that t = 0 is the last dose.
make_cohort <- function(n, dose, ii, wt, label, model, id_offset = 0L) {
# 720 h of dosing, not 240: IIV on Q and V3 is ~100% CV, so subjects in the
# tail of the peripheral-distribution etas have terminal half-lives far
# longer than the typical ~12 h and are not at steady state after 240 h.
n_dose <- ceiling(720 / ii)
start <- n_dose * ii
grid <- if (ii == 12) grid_12 else grid_24
dosing <- tibble(
id = id_offset + seq_len(n), time = 0, amt = dose, evid = 1L,
cmt = "depot", ii = ii, addl = n_dose
)
obs <- tidyr::crossing(
id = id_offset + seq_len(n), time = start + grid
) |>
mutate(amt = NA_real_, evid = 0L, cmt = "central", ii = 0, addl = 0L)
bind_rows(dosing, obs) |>
left_join(
tibble(id = id_offset + seq_len(n), WT = wt), by = "id"
) |>
mutate(
DOSE_HIGH = as.numeric(dose > 400),
treatment = label, model = model, dose_mg = dose, tau = ii,
t_start = start
) |>
arrange(id, time, desc(evid))
}
# Log-normal weights matched to each arm's reported median (Table 1).
lnwt <- function(n, med, sdlog) exp(rnorm(n, log(med), sdlog))
n_arm <- 100L
events <- bind_rows(
make_cohort(n_arm, 400, 12, lnwt(n_arm, 77.1, 0.15), "Healthy 400 mg b.i.d.",
"DiDeo_2025_tideglusib_healthy", id_offset = 0L),
make_cohort(n_arm, 1000, 24, lnwt(n_arm, 72.5, 0.15), "Healthy 1000 mg q.d.",
"DiDeo_2025_tideglusib_healthy", id_offset = 100L),
make_cohort(n_arm, 400, 24, lnwt(n_arm, 60.3, 0.24), "DM-1 400 mg q.d.",
"DiDeo_2025_tideglusib_dm1", id_offset = 200L),
make_cohort(n_arm, 1000, 24, lnwt(n_arm, 58.7, 0.30), "DM-1 1000 mg q.d.",
"DiDeo_2025_tideglusib_dm1", id_offset = 300L)
)
# Disjoint-ID guard: duplicate ids across cohorts silently merge into a single
# subject receiving the summed dose.
stopifnot(!anyDuplicated(unique(events[, c("id", "time", "evid")])))
stopifnot(n_distinct(events$id) == 4L * n_arm)Simulation
Each model is solved over its own arms, then the two are combined.
solve_arms <- function(model_name) {
ev <- events |> filter(model == model_name)
if (nrow(ev) == 0L) return(NULL)
rxode2::rxSolve(
readModelDb(model_name), events = ev,
keep = c("treatment", "model", "dose_mg", "tau", "t_start",
"WT", "DOSE_HIGH")
) |>
as.data.frame()
}
sim <- bind_rows(lapply(
c("DiDeo_2025_tideglusib_healthy", "DiDeo_2025_tideglusib_dm1"),
solve_arms
)) |>
mutate(
id = as.integer(as.character(id)),
t = time - t_start, # time since the last (steady-state) dose
Cng = Cc * 1000 # model is mg/L == ug/mL; paper reports ng/mL
) |>
filter(t >= 0)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ parameter labels from comments will be replaced by 'label()'
# The solve must produce real concentrations for every arm -- guard against a
# silently empty or all-NA result (a gate that cannot go red is worse than none).
stopifnot(nrow(sim) > 0, !all(is.na(sim$Cc)))
stopifnot(n_distinct(sim$treatment) == 4L)
stopifnot(all(tapply(sim$Cng, sim$treatment, function(x) any(x > 0))))
stopifnot(all(sim$Cng >= 0))Structural check: the dose-dependent switches fire
DOSE_HIGH gates three quantities at once.
ka is returned per subject by the solver, so the switch can
be read directly off the output.
sim |>
group_by(treatment, DOSE_HIGH) |>
summarise(ka_min = min(ka), ka_max = max(ka), .groups = "drop") |>
knitr::kable(digits = 3, caption = "Absorption rate constant by dose group.")| treatment | DOSE_HIGH | ka_min | ka_max |
|---|---|---|---|
| DM-1 1000 mg q.d. | 1 | 0.307 | 1.161 |
| DM-1 400 mg q.d. | 0 | 0.402 | 1.554 |
| Healthy 1000 mg q.d. | 1 | 0.338 | 0.992 |
| Healthy 400 mg b.i.d. | 0 | 0.425 | 1.469 |
# Typical-value ka must equal the two printed Table 2 values exactly.
ka_tv <- function(model, dose) {
m0 <- rxode2::zeroRe(readModelDb(model))
ev <- rxode2::et(amt = dose, cmt = "depot") |> rxode2::et(c(0, 1))
s <- rxode2::rxSolve(m0, ev, params = data.frame(
WT = 70, DOSE_HIGH = as.numeric(dose > 400)), addDosing = FALSE)
unique(as.data.frame(s)$ka)
}
stopifnot(
abs(ka_tv("DiDeo_2025_tideglusib_dm1", 400) - 0.767) < 1e-6,
abs(ka_tv("DiDeo_2025_tideglusib_dm1", 1000) - 0.609) < 1e-6,
abs(ka_tv("DiDeo_2025_tideglusib_healthy", 400) - 0.78 ) < 1e-6,
abs(ka_tv("DiDeo_2025_tideglusib_healthy", 1000) - 0.61 ) < 1e-6
)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'Structural check: steady-state mass balance pins the fixed bioavailability
At steady state the amount cleared over one dosing interval must
equal the absorbed dose: CL * AUCtau = F * dose. It is the
cheapest gate that pins the fixed F:
F is 1 by fiat at doses <= 400 mg and 0.88 (DM-1) / 0.85
(healthy) above, and a mis-encoded switch, allometric exponent or unit
would show up here immediately.
The gate is applied to typical-value profiles. There both sides of the identity use the same parameters and the only discrepancy is integration error, so a tight bound is the correct assertion.
trap <- function(t, c) sum(diff(t) * (head(c, -1) + tail(c, -1)) / 2)
grid_fine <- sort(unique(c(seq(0, 4, by = 0.01), seq(4, 24, by = 0.05))))
mb_typical <- function(model, dose, ii, wt) {
m0 <- rxode2::zeroRe(readModelDb(model))
n_dose <- ceiling(1440 / ii) # long run: guarantee steady state
start <- n_dose * ii
g <- grid_fine[grid_fine <= ii]
ev <- rxode2::et(amt = dose, cmt = "depot", ii = ii, addl = n_dose) |>
rxode2::et(start + g)
s <- rxode2::rxSolve(m0, ev, params = data.frame(
WT = wt, DOSE_HIGH = as.numeric(dose > 400)), addDosing = FALSE) |>
as.data.frame()
s$t <- s$time - start
s <- s[order(s$t), ]
fF <- if (dose > 400) {
if (model == "DiDeo_2025_tideglusib_dm1") 0.88 else 0.85
} else 1
tibble::tibble(
model = model, dose_mg = dose, tau = ii, WT = wt,
ratio = unique(s$cl) * trap(s$t, s$Cc) / (fF * dose)
)
}
mb_grid <- expand.grid(
model = c("DiDeo_2025_tideglusib_healthy", "DiDeo_2025_tideglusib_dm1"),
regimen = c("400 q12h", "400 q24h", "1000 q24h", "1200 q24h"),
WT = c(40, 70, 100),
stringsAsFactors = FALSE
)
mb_grid$dose <- as.numeric(sub(" .*", "", mb_grid$regimen))
mb_grid$ii <- as.numeric(sub(".*q([0-9]+)h", "\\1", mb_grid$regimen))
mb_tv <- bind_rows(lapply(seq_len(nrow(mb_grid)), function(i) {
mb_typical(mb_grid$model[i], mb_grid$dose[i], mb_grid$ii[i], mb_grid$WT[i])
}))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc', 'etalq', 'etalvp', 'etalka'
mb_tv |>
mutate(F_used = ifelse(dose_mg > 400,
ifelse(model == "DiDeo_2025_tideglusib_dm1", 0.88, 0.85), 1)) |>
group_by(model, dose_mg, tau, F_used) |>
summarise(min_ratio = min(ratio), max_ratio = max(ratio), .groups = "drop") |>
knitr::kable(digits = 4,
caption = "Typical-value steady-state mass balance across body weights 40/70/100 kg: CL * AUCtau / (F * dose). Exactly 1 if the ODEs, allometry, bioavailability switch and units are all consistent.")| model | dose_mg | tau | F_used | min_ratio | max_ratio |
|---|---|---|---|---|---|
| DiDeo_2025_tideglusib_dm1 | 400 | 12 | 1.00 | 1 | 1 |
| DiDeo_2025_tideglusib_dm1 | 400 | 24 | 1.00 | 1 | 1 |
| DiDeo_2025_tideglusib_dm1 | 1000 | 24 | 0.88 | 1 | 1 |
| DiDeo_2025_tideglusib_dm1 | 1200 | 24 | 0.88 | 1 | 1 |
| DiDeo_2025_tideglusib_healthy | 400 | 12 | 1.00 | 1 | 1 |
| DiDeo_2025_tideglusib_healthy | 400 | 24 | 1.00 | 1 | 1 |
| DiDeo_2025_tideglusib_healthy | 1000 | 24 | 0.85 | 1 | 1 |
| DiDeo_2025_tideglusib_healthy | 1200 | 24 | 0.85 | 1 | 1 |
# Deterministic identity -> tight bound is correct (numerical error only).
stopifnot(max(abs(mb_tv$ratio - 1)) < 0.005)Across the simulated cohorts the same identity holds at the centre,
with a small negative tail: IIV on Q and V3 is
~100% CV, so a handful of subjects per arm have terminal half-lives long
enough that even 720 h of dosing leaves them fractionally short of
steady state. That is a property of the published variance model, not of
the encoding, so the cohort check asserts on robust quantiles rather
than the extreme.
mass_balance <- sim |>
group_by(id, treatment, model, dose_mg, tau) |>
arrange(t, .by_group = TRUE) |>
summarise(
cleared = first(cl) * trap(t, Cc), # mg cleared over tau
absorbed = first(dose_mg) * ifelse(first(dose_mg) > 400,
ifelse(first(model) == "DiDeo_2025_tideglusib_dm1", 0.88, 0.85), 1),
.groups = "drop"
) |>
mutate(ratio = cleared / absorbed)
mass_balance |>
group_by(treatment) |>
summarise(min_ratio = min(ratio), median_ratio = median(ratio),
q95_abs_dev = quantile(abs(ratio - 1), 0.95),
.groups = "drop") |>
knitr::kable(digits = 4,
caption = "Cohort steady-state mass balance by arm.")| treatment | min_ratio | median_ratio | q95_abs_dev |
|---|---|---|---|
| DM-1 1000 mg q.d. | 0.9753 | 0.9999 | 0.0027 |
| DM-1 400 mg q.d. | 0.9927 | 0.9998 | 0.0010 |
| Healthy 1000 mg q.d. | 0.9886 | 0.9998 | 0.0010 |
| Healthy 400 mg b.i.d. | 0.9920 | 0.9997 | 0.0021 |
Replicate published figures
Figure 3 – concentration-time profiles in DM-1 patients by dose group
sim |>
filter(model == "DiDeo_2025_tideglusib_dm1") |>
group_by(treatment, t) |>
summarise(
Q05 = quantile(Cng, 0.05), Q50 = median(Cng), Q95 = quantile(Cng, 0.95),
.groups = "drop"
) |>
ggplot(aes(t, Q50)) +
geom_ribbon(aes(ymin = Q05, ymax = Q95), alpha = 0.25) +
geom_line() +
facet_wrap(~treatment) +
scale_y_log10() +
labs(x = "Time after last dose (h)", y = "Tideglusib (ng/mL)",
title = "Steady-state profiles in DM-1 patients",
caption = "Replicates Figure 3 of Di Deo 2025 (median and 90% prediction interval).")
Figure 5 – higher exposure in lighter subjects
Section 3.4 reports that “higher exposure is expected to occur in subjects with lower body weight”. With a fixed dose and allometric clearance this is a structural consequence, so the trend – not a specific slope – is the claim to check.
expo <- sim |>
group_by(id, treatment, WT) |>
arrange(t, .by_group = TRUE) |>
summarise(auc = trap(t, Cng), .groups = "drop")
ggplot(expo, aes(WT, auc)) +
geom_point(alpha = 0.35, size = 0.9) +
geom_smooth(method = "lm", formula = y ~ x, se = FALSE, linewidth = 0.6) +
facet_wrap(~treatment, scales = "free_y") +
labs(x = "Body weight (kg)", y = "AUC over the dosing interval (ng*h/mL)",
title = "Exposure decreases with body weight",
caption = "Replicates Figure 5 of Di Deo 2025.")
# The claim is a NEGATIVE weight-exposure association in every arm -- a
# structural consequence of allometric CL at a fixed dose -- so the SIGN is
# what this asserts.
#
# The magnitude is not uniform across arms and must not be asserted as if it
# were. Rank correlation is attenuated when body weight varies little relative
# to the IIV on clearance, and the two healthy arms draw weight with sdlog 0.15
# against 0.24-0.30 in the DM-1 arms. Measured over three configurations
# (2 and 8 solver threads, n_arm 100 and 800) the healthy arms realise -0.246
# to -0.319 while the DM-1 arms reach -0.276 to -0.564. An earlier
# `all(rho < -0.3)` therefore failed on the healthy arms for reasons that had
# nothing to do with the model being wrong.
#
# -0.15 holds for every cohort measured, with the smallest observed magnitude
# (0.246) a comfortable margin away, and the gate still goes red if the sign
# flips or the association collapses -- which is the thing worth catching.
rho <- expo |>
group_by(treatment) |>
summarise(rho = cor(WT, auc, method = "spearman"), .groups = "drop")
knitr::kable(rho, digits = 3, caption = "Spearman correlation of AUC with body weight.")| treatment | rho |
|---|---|
| DM-1 1000 mg q.d. | -0.276 |
| DM-1 400 mg q.d. | -0.407 |
| Healthy 1000 mg q.d. | -0.258 |
| Healthy 400 mg b.i.d. | -0.367 |
PKNCA validation
NCA is computed over the final (steady-state) dosing interval, with
time re-based so t = 0 is the last dose.
# Filter ONLY on !is.na(Cc): adding `t > 0` or `Cng > 0` would drop the
# time-zero row that PKNCA needs to anchor AUC.
sim_nca <- sim |>
filter(!is.na(Cng)) |>
select(id, time = t, Cc = Cng, treatment)
# Guarantee a time-zero record per subject (extravascular pre-dose value).
sim_nca <- bind_rows(
sim_nca,
sim_nca |> distinct(id, treatment) |> mutate(time = 0, Cc = 0)
) |>
distinct(id, treatment, time, .keep_all = TRUE) |>
arrange(id, treatment, time)
stopifnot(nrow(sim_nca) > 0)
conc_obj <- PKNCA::PKNCAconc(sim_nca, Cc ~ time | treatment + id)
dose_df <- events |>
filter(evid == 1) |>
transmute(id, time = 0, amt, treatment)
dose_obj <- PKNCA::PKNCAdose(dose_df, amt ~ time | treatment + id)
# The paper reports AUC(0-12) for all four arms of Table 3, so NCA runs over
# 0-12 h. For the b.i.d. arm that is a full dosing interval; for the q.d. arms
# it is the paper's own reporting window.
intervals <- data.frame(
start = 0, end = 12,
cmax = TRUE, tmax = TRUE, auclast = TRUE
)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj,
intervals = intervals))
# Guard: NCA must have produced rows for all four arms (pattern 10 -- a
# lookup that matches nothing makes every downstream all() vacuously TRUE).
stopifnot(n_distinct(as.data.frame(nca_res)$treatment) == 4L)Comparison against published NCA (Table 3)
published <- tibble::tribble(
~treatment, ~cmax, ~tmax, ~auclast,
"Healthy 400 mg b.i.d.", 504.7, 0.7, 949.7,
"Healthy 1000 mg q.d.", 702.0, 0.7, 1749.8,
"DM-1 400 mg q.d.", 513.5, 0.75, 1218.1,
"DM-1 1000 mg q.d.", 1170.9, 0.7, 3145.7
)
cmp <- nlmixr2lib::ncaComparisonTable(
simulated = nca_res,
reference = published,
by = "treatment",
units = c(cmax = "ng/mL", tmax = "h", auclast = "ng*h/mL"),
tolerance_pct = 20
)
knitr::kable(
cmp,
caption = "Simulated vs. published NCA (Di Deo 2025 Table 3). * differs from reference by >20%."
)| NCA parameter | treatment | Reference | Simulated | % diff |
|---|---|---|---|---|
| Cmax (ng/mL) | Healthy 400 mg b.i.d. | 505 | 453 | -10.3% |
| Cmax (ng/mL) | Healthy 1000 mg q.d. | 702 | 797 | +13.5% |
| Cmax (ng/mL) | DM-1 400 mg q.d. | 514 | 475 | -7.6% |
| Cmax (ng/mL) | DM-1 1000 mg q.d. | 1170 | 937 | -20.0% |
| Tmax (h) | Healthy 400 mg b.i.d. | 0.7 | 0.625 | -10.7% |
| Tmax (h) | Healthy 1000 mg q.d. | 0.7 | 0.7 | +0.0% |
| Tmax (h) | DM-1 400 mg q.d. | 0.75 | 0.7 | -6.7% |
| Tmax (h) | DM-1 1000 mg q.d. | 0.7 | 0.7 | +0.0% |
| AUClast (ng*h/mL) | Healthy 400 mg b.i.d. | 950 | 1150 | +21.3%* |
| AUClast (ng*h/mL) | Healthy 1000 mg q.d. | 1750 | 2360 | +35.0%* |
| AUClast (ng*h/mL) | DM-1 400 mg q.d. | 1220 | 1230 | +1.3% |
| AUClast (ng*h/mL) | DM-1 1000 mg q.d. | 3150 | 2710 | -14.0% |
The DM-1 400 mg arm reproduces Table 3 closely. The other three arms deviate, and the deviations are not in a consistent direction – the healthy arms run high while the DM-1 1000 mg arm runs low – which rules out a systematic transcription or unit error in the model. See “Assumptions and deviations” for the diagnosis; no parameter was tuned to close these gaps.
The reference exposure of Section 2.7 is reproduced
Section 2.7 specifies a well-defined large simulation: adults of body weight 70-75 kg receiving 1000 mg q.d. under fasting conditions, reported as AUC(0-24) = 2560.8 (1282.5-5286.7) ng/mL*h and Cmax = 929.6 (446.4-1834.4) ng/mL. Unlike the Table 3 arms this is a specified virtual cohort rather than a median over 8-9 real subjects, so it is the more reliable target.
set.seed(4242)
n_ref <- 200L
ref_ev <- make_cohort(n_ref, 1000, 24, runif(n_ref, 70, 75),
"Reference adult 70-75 kg", "DiDeo_2025_tideglusib_dm1",
id_offset = 900L)
ref <- rxode2::rxSolve(readModelDb("DiDeo_2025_tideglusib_dm1"),
events = ref_ev, keep = c("treatment", "t_start")) |>
as.data.frame() |>
mutate(id = as.integer(as.character(id)), t = time - t_start,
Cng = Cc * 1000) |>
filter(t >= 0)
#> ℹ parameter labels from comments will be replaced by 'label()'
ref_pk <- ref |>
group_by(id) |>
arrange(t, .by_group = TRUE) |>
summarise(cmax = max(Cng), auc24 = trap(t, Cng), .groups = "drop")
ref_tab <- tibble::tibble(
Parameter = c("AUC(0-24) (ng*h/mL)", "Cmax (ng/mL)"),
Simulated = c(sprintf("%.1f (%.1f-%.1f)", median(ref_pk$auc24),
quantile(ref_pk$auc24, .05), quantile(ref_pk$auc24, .95)),
sprintf("%.1f (%.1f-%.1f)", median(ref_pk$cmax),
quantile(ref_pk$cmax, .05), quantile(ref_pk$cmax, .95))),
Published = c("2560.8 (1282.5-5286.7)", "929.6 (446.4-1834.4)"),
`% diff (median)` = c(
sprintf("%+.1f%%", 100 * (median(ref_pk$auc24) - 2560.8) / 2560.8),
sprintf("%+.1f%%", 100 * (median(ref_pk$cmax) - 929.6) / 929.6))
)
knitr::kable(ref_tab, caption = "Section 2.7 reference adult exposure, median (5th-95th percentile).")| Parameter | Simulated | Published | % diff (median) |
|---|---|---|---|
| AUC(0-24) (ng*h/mL) | 2393.2 (1220.3-4306.8) | 2560.8 (1282.5-5286.7) | -6.5% |
| Cmax (ng/mL) | 736.6 (431.3-1457.5) | 929.6 (446.4-1834.4) | -20.8% |
# AUC is the exposure metric the paper's dose rationale is built on, and it is
# insensitive to the unreported residual-error treatment (see deviations
# below): with and without residual error it moved only 2422 -> 2446 in
# development. Realised median % differences of -4.1 / -5.2 / -3.6 across
# thread counts; 20 leaves headroom while still breaking on a mis-transcribed
# clearance, dose or unit, which move AUC by tens of percent.
stopifnot(abs(100 * (median(ref_pk$auc24) - 2560.8) / 2560.8) < 20)
# Envelope check, robust to which subjects land in the tails.
stopifnot(median(ref_pk$auc24) > 1500, median(ref_pk$auc24) < 4000)Assumptions and deviations
Body-weight distributions are assumed. The paper reports only per-arm median and range (Table 1), not the full distribution. Each arm’s weights are drawn log-normally about the published median with a spread chosen to approximate the published range. The Section 2.7 reference cohort is the exception – the paper specifies a 70-75 kg uniform range, so no assumption is needed there, which is why it is the primary quantitative gate.
Concentration units. Both models are parameterised in the paper’s own units (dose mg, CL L/h, V L), so
Ccis natively mg/L (= ug/mL). The paper reports ng/mL, so the vignette multiplies by 1000 rather than hardcoding a conversion constant inmodel().-
Table 3 is reproduced for the DM-1 400 mg arm and deviates for the other three; the model was not tuned to close the gaps. Three findings support leaving the model as the parameter table specifies:
-
Steady-state mass balance is exact
(
CL * AUCtau / (F * dose)= 1.000 to four decimals, above). The ODE system, allometric scaling, bioavailability switch and units are therefore internally consistent, and the encoded clearance is exactly Table 2’s. - The deviations have opposite signs. The healthy arms simulate higher AUC(0-12) than Table 3 while the DM-1 1000 mg arm simulates lower (see the comparison table above; at typical values and each arm’s median weight the discrepancies are +19.8%, +35.0%, +1.1% and -12.3% for the four arms in Table 3 order). A transcription, unit or structural error would bias every arm the same way.
- Table 3’s healthy rows are internally inconsistent with Table 2. For the healthy 400 mg b.i.d. arm, Table 3’s own Css of 79.2 ng/mL implies AUC(0-24) = 1901 ngh/mL, hence an apparent clearance of 800 mg / 1.901 mgh/L = 421 L/h. Table 2’s healthy CL is 327 L/h, which is 351 L/h at that arm’s median 77.1 kg. The two published tables imply clearances differing by ~20%. Table 2 is the parameter table and is what the model encodes.
A contributing factor for all four arms is that Table 3’s medians are taken over only 8-9 subjects carrying very large IIV (Q and V3 have 95-107% CV), so each published median is itself an imprecise statistic, and the median of a cohort is not the typical-value prediction when IIV is that large.
-
Steady-state mass balance is exact
(
Cmax depends on an unreported simulation detail, so it is reported but not gated. Section 2.5 says secondary parameters were derived by NCA from “simulated individual concentration vs. time profiles” without stating whether residual error was included. That choice moves Cmax a long way and brackets the published value: for the Section 2.7 reference cohort, NCA on the individual predictions gives a median Cmax about 19% below the published 929.6 ng/mL, while adding the model’s proportional residual error (67% CV at this dose) gives roughly 1430 ng/mL, about 54% above it. AUC over the same two runs moved only 2422 -> 2446 ng*h/mL, i.e. barely at all, because a maximum is inflated by noise whereas an integral averages it out. AUC is therefore the robust target and carries the assertion; Cmax is shown in the tables without one.
No maturation function. The paper extrapolates the weight-banded paediatric regimen of Table 5 down to 5 kg using allometry alone. The Discussion states this explicitly as a limitation: “as there is incomplete data on the primary route of metabolism in vivo, neither a maturation function nor an age-dependent allometric exponent were included”. The models reproduce that choice, so predictions in young children carry the paper’s own caveat.
Food and meal type are documented but not modelled. In the Phase II study every dose was given after an overnight fast with food restricted for at least an hour afterwards, so no fed-vs-fasted contrast is identifiable. Sections 2.6 and 3.3 explore the timing and type of the first post-dose meal post hoc against derived NCA metrics in 30 records and find no effect, so no meal term enters
model(). These screened-but-unretained covariates (AGE,BMI,SEXF,FED) are recorded in each model’scovariatesDataExcludedmetadata.All parameter values come from the paper’s Table 2; none was derived from a figure, correspondence, or an upstream model. The supplement was not required – it contains inclusion/exclusion criteria, bioanalytical method detail, and additional summary tables and figures, but no model parameters.