Iruplinalkib (Yang 2025)
Source:vignettes/articles/Yang_2025_iruplinalkib.Rmd
Yang_2025_iruplinalkib.RmdModel and source
- Citation: Yang G, Wang Y, Zhao H, Jiang Z, Zheng S, Ge M, Si M, Kang X. Population pharmacokinetics of iruplinalkib in healthy volunteers and patients with solid tumors. Clin Transl Sci. 2025;18(1):e70099. doi:10.1111/cts.70099
- Description: Two-compartment population PK model with first-order absorption and first-order elimination for oral iruplinalkib (WX-0593, a selective ALK/ROS1 tyrosine kinase inhibitor approved in China for ALK-positive non-small-cell lung cancer), pooled over four Chinese trials in 392 subjects: 16 healthy volunteers and 376 patients with solid tumors. Apparent oral clearance carries power effects of baseline body weight, time-varying serum albumin, time-varying creatinine clearance and time-varying lactate dehydrogenase; the apparent central volume carries a power effect of baseline body weight. Food slows absorption without changing exposure: dosing in the fed state multiplies the absorption rate constant by 0.588 and adds a 0.472 h absorption lag. Residual error is proportional, with a separate magnitude for the healthy-volunteer food-effect study WX-0593-002 (55.7%) and for the three solid-tumor studies WX-0593-001, -003 and -004 (35.7%).
- Article: https://doi.org/10.1111/cts.70099
- Supplement (Tables S1-S2, Figures S1-S3): https://www.ebi.ac.uk/europepmc/webservices/rest/PMC11671680/supplementaryFiles
Iruplinalkib (WX-0593) is a selective oral ALK/ROS1 tyrosine kinase inhibitor approved in China for ALK-positive non-small-cell lung cancer. Yang 2025 is the first population PK analysis of the drug, pooling four Chinese trials.
Population
The analysis pooled 3788 plasma concentrations from 392 Chinese subjects across four trials (Yang 2025 Table 1 and Table S1): 376 patients with ALK/ROS1-positive advanced solid tumors – from the phase 1 dose-escalation/expansion trial WX-0593-001 (n = 54, 30-300 mg), the single-arm phase 2 trial WX-0593-003 (n = 202, 180 mg once daily after a 7-day 60 mg lead-in) and the randomised phase 3 trial WX-0593-004 (n = 120, same regimen) – plus 16 healthy volunteers in the two-period food-effect crossover WX-0593-002 (a single 120 mg dose fasted then fed, or the reverse, with a 7-day washout).
Median age was 52.0 years (range 25.0-76.0; 14.5% were 65 or older), 52.0% were female, median baseline body weight was 63.0 kg (range 35.0-98.9) and 95.7% were Han Chinese. Median baseline albumin was 40.9 g/L, creatinine clearance 97.9 mL/min and lactate dehydrogenase 219 U/L. Only normal and mild-to-moderate renal or hepatic impairment were meaningfully represented; the paper states explicitly that the effect of moderate or severe impairment remains to be defined.
The same information is available programmatically via
readModelDb("Yang_2025_iruplinalkib")()$population.
Source trace
Every ini() entry in
inst/modeldb/specificDrugs/Yang_2025_iruplinalkib.R carries
an in-file comment naming its source location. They are collected here
for review. Yang 2025 prints its final model as seven display equations
(Eqs. 1-6, with Eq. 2 split across two typeset lines) immediately after
the Results paragraph “The final model included the following covariate
relationships”. Those equations are rendered as images in the
publisher’s PDF and are dropped by text extractors; they were recovered
with pdftotext -layout.
| Equation / parameter | Value | Source location |
|---|---|---|
lka (Ka, fasted) |
1.06 /h | Table 2 final model; Eq. 1 |
lcl (CL/F) |
18.9 L/h | Table 2 final model; Eq. 2 |
lvc (V1/F) |
348 L | Table 2 final model; Eq. 3 |
lvp (V2/F) |
295 L | Table 2 final model; Eq. 4 |
lq (Q/F) |
15.5 L/h | Table 2 final model; Eq. 5 |
ltlag (ALAG, fed only) |
0.472 h | Table 2 “ALAG for fed subjects”; Eq. 6 |
e_fed_ka |
0.588 | Table 2 “Food on Ka”; Eq. 1 |
e_wt_base_cl |
0.441 | Table 2; Eq. 2 (BBWT/63)^0.441
|
e_ldh_cl |
-0.225 | Table 2; Eq. 2 (LDH/242.63)^-0.225
|
e_crcl_cl |
0.22 | Table 2; Eq. 2 (CRCL/84.79)^0.22
|
e_alb_cl |
1.05 | Table 2; Eq. 2 (ALB/43.89)^1.05
|
e_wt_base_vc |
1.36 | Table 2; Eq. 3 (BBWT/63)^1.36
|
etalka |
74.8% CV | Table 2 IIV; log(1 + 0.748^2) = 0.444368 |
etalcl |
24.8% CV | Table 2 IIV; log(1 + 0.248^2) = 0.059687 |
etalvc |
53.4% CV | Table 2 IIV; log(1 + 0.534^2) = 0.250880 |
propSdPatient |
35.7% | Table 2, studies WX-0593-001, -003, -004 |
propSdHv |
55.7% | Table 2, study WX-0593-002 |
| Reference covariate values | 63 kg, 43.89 g/L, 84.79 mL/min, 242.63 U/L | Results, “Typical subjects in this PopPK analysis had …”; Eqs. 2-3 denominators |
| Two-compartment structure, first-order absorption and elimination | n/a | Results “PopPK analysis”; Eqs. 2-5 |
Note that the four reference (centring) constants are the paper’s own
typical-subject values from the Results, and three of
the four are not the Table 1 baseline medians (albumin 43.89 vs
40.9 g/L, creatinine clearance 84.79 vs 97.9 mL/min, LDH 242.63 vs 219
U/L). That is consistent with those three covariates entering as
time-varying: the reference belongs to the longitudinal record, not to
the baseline snapshot. Only body weight, which the paper enters as a
baseline covariate (BBWT), has a reference equal
to its Table 1 median of 63 kg.
Virtual cohort
Original observed data are not publicly available. The deterministic checks below use the paper’s own typical subject; the stochastic cohort uses covariate distributions built from the paper’s own stated percentiles (see Assumptions).
# The paper's typical subject (Yang 2025 Results, "Covariate effects on
# iruplinalkib steady-state exposure") -- these are also the denominators of
# Eqs. 2 and 3.
REF <- list(WT_BASE = 63, ALB = 43.89, CRCL = 84.79, LDH = 242.63)
mod <- readModelDb("Yang_2025_iruplinalkib")
mod_typ <- rxode2::zeroRe(mod)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> Warning: No sigma parameters in the model
# Build one arm as a self-contained event table. `id_offset` keeps IDs disjoint
# so arms can be bind_rows()-ed; rxSolve treats id as the subject key and
# silently merges duplicates.
make_arm <- function(treatment, dose, ndose, ii, tmax,
fed = 0L, cov = REF, id_offset = 0L, n = 1L,
subj = NULL, obs_times = NULL) {
if (is.null(subj)) {
subj <- data.frame(id = id_offset + seq_len(n))
for (nm in names(cov)) subj[[nm]] <- cov[[nm]]
}
subj$FED <- fed
subj$STUDY_WX0593_002 <- 0L
subj$treatment <- treatment
if (is.null(obs_times)) obs_times <- seq(0, tmax, by = 0.25)
dosing <- merge(subj, data.frame(
time = seq_len(ndose) * ii - ii, amt = dose, evid = 1L, cmt = "depot"
))
# Observations sit on the ODE state `central`, never on the algebraic
# observable `Cc` -- naming an observable in `cmt` renumbers the
# compartment slots.
obs <- merge(subj, data.frame(
time = sort(unique(c(obs_times, tmax))),
amt = NA_real_, evid = 0L, cmt = "central"
))
dplyr::arrange(dplyr::bind_rows(dosing, obs), id, time, dplyr::desc(evid))
}
# Absorption is resolved finely, the terminal phase coarsely: a uniform fine
# grid over 336 h costs an order of magnitude more rows for no extra accuracy.
grid_sd <- sort(unique(c(seq(0, 12, by = 0.05), seq(12, 48, by = 0.5),
seq(48, 336, by = 4))))
# The steady-state arms are only ever summarised over the 14th interval.
grid_ss <- seq(312, 336, by = 0.25)
solve_typ <- function(ev) {
as.data.frame(rxode2::rxSolve(mod_typ, events = ev,
keep = c("treatment", "FED")))
}
# Trapezoidal AUC helper for the closed-form identity checks (PKNCA does the
# validation NCA further down; this is only used for the exact identities).
trapz <- function(t, y) sum(diff(t) * (head(y, -1) + tail(y, -1)) / 2)Simulation
# Deterministic arms, typical subject, fasted unless stated.
arm_ss <- make_arm("180 mg QD, steady state", 180, 14, 24, 336,
id_offset = 0L, obs_times = grid_ss)
arm_sd <- make_arm("180 mg single dose", 180, 1, 24, 336,
id_offset = 100L, obs_times = grid_sd)
arm_fast <- make_arm("120 mg single dose, fasted", 120, 1, 24, 336,
fed = 0L, id_offset = 200L, obs_times = grid_sd)
arm_fed <- make_arm("120 mg single dose, fed", 120, 1, 24, 336,
fed = 1L, id_offset = 300L, obs_times = grid_sd)
events_typ <- dplyr::bind_rows(arm_ss, arm_sd, arm_fast, arm_fed)
stopifnot(!anyDuplicated(unique(events_typ[, c("id", "time", "evid")])))
sim_typ <- solve_typ(events_typ)
#> ℹ omega/sigma items treated as zero: 'etalka', 'etalcl', 'etalvc'
#> Warning: multi-subject simulation without without 'omega'Structural identity: steady-state AUC recovers Dose / (CL/F)
At steady state under linear disposition the AUC over one dosing
interval is exactly Dose / (CL/F), independent of every
distribution parameter. Both sides of this comparison use the same fixed
parameters, so the residual is pure numerical (trapezoidal + solver)
error and a tight bound is appropriate.
ss <- sim_typ |>
dplyr::filter(treatment == "180 mg QD, steady state", time >= 312, time <= 336)
auc_tau <- trapz(ss$time, ss$Cc) # mg*h/L
auc_pred <- 180 / 18.9 # Dose / (CL/F), Table 2
c(simulated = auc_tau, `Dose/(CL/F)` = auc_pred, ratio = auc_tau / auc_pred)
#> simulated Dose/(CL/F) ratio
#> 9.5168564 9.5238095 0.9992699
# Deterministic identity -- the two sides share the same parameters, so this is
# solver/trapezoid error only. Realised 0.99927.
stopifnot(abs(auc_tau / auc_pred - 1) < 0.005)Structural identity: terminal half-life recovers the two-compartment closed form
lam <- local({
CL <- 18.9; V1 <- 348; Q <- 15.5; V2 <- 295
k10 <- CL / V1; k12 <- Q / V1; k21 <- Q / V2
S <- k10 + k12 + k21
(S - sqrt(S^2 - 4 * k10 * k21)) / 2
})
thalf_closed <- log(2) / lam
sd_prof <- sim_typ |>
dplyr::filter(treatment == "180 mg single dose", time >= 168, time <= 336, Cc > 0)
thalf_sim <- log(2) / -stats::coef(stats::lm(log(Cc) ~ time, data = sd_prof))[["time"]]
c(closed_form = thalf_closed, simulated = thalf_sim)
#> closed_form simulated
#> 31.41423 31.41423
# Deterministic; realised agreement was better than 1e-4 relative.
stopifnot(abs(thalf_sim / thalf_closed - 1) < 0.01)Replicate published figures
Figure 2 – covariate effects on steady-state exposure
Yang 2025 Figure 2 is a forest plot of the ratio of
Cmax,ss, Cmin,ss and AUCss at the
5th and 95th percentile of each significant covariate, relative to the
typical subject receiving 180 mg once daily. The paper’s own summary of
that figure is the sharpest available check on the transcription,
because it constrains all four exponents and all four
centring constants at once:
the 90% CIs of
Cmax,ss,Cmin,ss, andAUCssratios of iruplinalkib in patients with baseline body weight from 48 to 85 kg, albumin from 34.7 to 49.6 g/L, CRCL from 48.87 to 141.7 mL/min or LDH from 161 to 388.45 U/L basically fell within the predefined bioequivalence boundary of 0.80-1.25. The effect of low albumin on exposure was more than 125%.
# Replicates Figure 2 of Yang 2025: exposure ratios at the 5th and 95th
# percentile of each retained covariate, versus the typical subject.
forest_grid <- tibble::tribble(
~covariate, ~percentile, ~value,
"WT_BASE", "5th", 48,
"WT_BASE", "95th", 85,
"ALB", "5th", 34.7,
"ALB", "95th", 49.6,
"CRCL", "5th", 48.87,
"CRCL", "95th", 141.7,
"LDH", "5th", 161,
"LDH", "95th", 388.45
)
forest_events <- do.call(dplyr::bind_rows, lapply(
seq_len(nrow(forest_grid)),
function(i) {
cov <- REF
cov[[forest_grid$covariate[i]]] <- forest_grid$value[i]
make_arm(
paste(forest_grid$covariate[i], forest_grid$percentile[i]),
dose = 180, ndose = 14, ii = 24, tmax = 336,
cov = cov, id_offset = 1000L + 10L * i, obs_times = grid_ss
)
}
))
stopifnot(!anyDuplicated(unique(forest_events[, c("id", "time", "evid")])))
forest_exposure <- solve_typ(forest_events) |>
dplyr::filter(time >= 312, time <= 336) |>
dplyr::group_by(treatment) |>
dplyr::summarise(
AUCss = trapz(time, Cc),
Cmax_ss = max(Cc),
Cmin_ss = min(Cc),
.groups = "drop"
)
#> ℹ omega/sigma items treated as zero: 'etalka', 'etalcl', 'etalvc'
#> Warning: multi-subject simulation without without 'omega'
typical_exposure <- c(AUCss = auc_tau, Cmax_ss = max(ss$Cc), Cmin_ss = min(ss$Cc))
forest <- forest_exposure |>
tidyr::separate_wider_delim(treatment, " ", names = c("covariate", "percentile")) |>
dplyr::left_join(forest_grid, by = c("covariate", "percentile")) |>
dplyr::mutate(
AUCss = AUCss / typical_exposure[["AUCss"]],
Cmax_ss = Cmax_ss / typical_exposure[["Cmax_ss"]],
Cmin_ss = Cmin_ss / typical_exposure[["Cmin_ss"]]
)
forest |>
tidyr::pivot_longer(c(AUCss, Cmax_ss, Cmin_ss),
names_to = "metric", values_to = "ratio") |>
dplyr::mutate(label = paste0(covariate, " ", percentile, " (", value, ")")) |>
ggplot(aes(ratio, label)) +
geom_vline(xintercept = c(0.80, 1.25), linetype = "dashed", colour = "grey40") +
geom_vline(xintercept = 1, colour = "grey70") +
geom_point(size = 2) +
facet_wrap(~metric) +
labs(x = "Ratio versus the typical subject", y = NULL,
title = "Figure 2 -- covariate effects on steady-state exposure",
caption = "Replicates Figure 2 of Yang 2025. Dashed lines: 0.80-1.25.")
The simulated AUCss ratios must equal the closed form
(cov / ref)^-exponent, because
AUCss = Dose / (CL/F) and each covariate enters
CL/F as a power term. This is a deterministic identity, so
it is asserted tightly.
expo <- c(WT_BASE = 0.441, ALB = 1.05, CRCL = 0.22, LDH = -0.225)
ref <- unlist(REF)
check <- forest |>
dplyr::mutate(
closed_form = (value / ref[covariate])^(-expo[covariate]),
pct_diff = 100 * (AUCss / closed_form - 1)
) |>
dplyr::select(covariate, percentile, value, closed_form, AUCss, Cmax_ss,
Cmin_ss, pct_diff)
check |>
dplyr::rename(
"Covariate" = covariate,
"Percentile" = percentile,
"Value" = value,
"AUCss ratio (closed form)" = closed_form,
"AUCss ratio (simulated)" = AUCss,
"Cmax,ss ratio" = Cmax_ss,
"Cmin,ss ratio" = Cmin_ss,
"AUCss % difference" = pct_diff
) |>
knitr::kable(digits = 4,
caption = "Yang 2025 Figure 2 exposure ratios: simulated versus closed form.")| Covariate | Percentile | Value | AUCss ratio (closed form) | AUCss ratio (simulated) | Cmax,ss ratio | Cmin,ss ratio | AUCss % difference |
|---|---|---|---|---|---|---|---|
| ALB | 5th | 34.70 | 1.2798 | 1.2784 | 1.1713 | 1.4341 | -0.1109 |
| ALB | 95th | 49.60 | 0.8795 | 0.8796 | 0.9270 | 0.8184 | 0.0183 |
| CRCL | 5th | 48.87 | 1.1289 | 1.1285 | 1.0788 | 1.1984 | -0.0371 |
| CRCL | 95th | 141.70 | 0.8932 | 0.8933 | 0.9353 | 0.8388 | 0.0169 |
| LDH | 5th | 161.00 | 0.9118 | 0.9120 | 0.9466 | 0.8667 | 0.0147 |
| LDH | 95th | 388.45 | 1.1117 | 1.1114 | 1.0682 | 1.1718 | -0.0310 |
| WT_BASE | 5th | 48.00 | 1.1274 | 1.1274 | 1.2580 | 1.0394 | -0.0037 |
| WT_BASE | 95th | 85.00 | 0.8763 | 0.8762 | 0.7920 | 0.9658 | -0.0117 |
# Deterministic identity: simulated AUCss ratio == (cov/ref)^-exponent.
stopifnot(max(abs(check$pct_diff)) < 0.5)
# The paper's own claim: of the eight AUCss arms, LOW ALBUMIN is the one that
# exceeds the 0.80-1.25 bioequivalence window, and every other arm sits inside
# it. This pins all four exponents AND all four centring constants at once, and
# it is reference-free -- it uses no number this model was built from.
outside <- check |> dplyr::filter(AUCss < 0.80 | AUCss > 1.25)
stopifnot(nrow(outside) == 1L,
outside$covariate == "ALB",
outside$percentile == "5th",
outside$AUCss > 1.25)Food effect (study WX-0593-002)
Yang 2025 reports that “iruplinalkib absorption was delayed 0.472 h
after meal, and Ka was 58.8% of that under fasting. However, there was
no difference in exposure of iruplinalkib between the fasted and fed
states.” Because food acts only on Ka and on the absorption
lag – never on CL/F or on bioavailability – total exposure
is exactly unchanged, which the simulation reproduces as an
identity rather than as an approximation.
food <- sim_typ |>
dplyr::filter(treatment %in% c("120 mg single dose, fasted",
"120 mg single dose, fed")) |>
dplyr::group_by(treatment) |>
dplyr::summarise(
Tmax_h = time[which.max(Cc)],
Cmax_ng_mL = 1000 * max(Cc),
AUC0_336_h = trapz(time, Cc),
.groups = "drop"
)
knitr::kable(food, digits = c(0, 2, 0, 4),
caption = "Food effect: a single 120 mg dose fasted versus fed (typical subject).")| treatment | Tmax_h | Cmax_ng_mL | AUC0_336_h |
|---|---|---|---|
| 120 mg single dose, fasted | 2.50 | 271 | 6.3477 |
| 120 mg single dose, fed | 4.05 | 246 | 6.3478 |
fed_v_fasted <- function(col) {
food[[col]][food$treatment == "120 mg single dose, fed"] /
food[[col]][food$treatment == "120 mg single dose, fasted"]
}
# Exposure is unchanged: food touches only Ka and the lag, so AUC is identical
# up to solver error. Deterministic -- tight bound.
stopifnot(abs(fed_v_fasted("AUC0_336_h") - 1) < 0.01)
# Absorption is delayed and blunted. Realised Tmax 2.50 h -> 4.05 h and
# Cmax 271 -> 246 ng/mL.
stopifnot(fed_v_fasted("Tmax_h") > 1.3, fed_v_fasted("Cmax_ng_mL") < 0.95)Steady-state concentration-time profile, 180 mg once daily
# `set.seed()` seeds R's RNG. It does NOT seed rxode2's simulation RNG, whose
# 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 draw.
set.seed(20250101)
n_cohort <- 200L # the 200-per-arm cap
# Log-normal covariates whose MEDIAN is the paper's typical-subject value and
# whose 5th-95th window is the paper's own Figure 2 window (see Assumptions).
draw_ln <- function(median_value, p05, p95) {
sigma <- log(p95 / p05) / (2 * stats::qnorm(0.95))
median_value * exp(stats::rnorm(n_cohort, 0, sigma))
}
cohort <- data.frame(
id = 5000L + seq_len(n_cohort),
WT_BASE = pmin(pmax(draw_ln(REF$WT_BASE, 48, 85), 35), 98.9), # clipped to Table 1 range
ALB = draw_ln(REF$ALB, 34.7, 49.6),
CRCL = draw_ln(REF$CRCL, 48.87, 141.7),
LDH = draw_ln(REF$LDH, 161, 388.45)
)
events_cohort <- make_arm("180 mg QD cohort", 180, 14, 24, 336,
subj = cohort, obs_times = grid_ss)
stopifnot(!anyDuplicated(unique(events_cohort[, c("id", "time", "evid")])))
sim_cohort <- as.data.frame(
rxode2::rxSolve(mod, events = events_cohort, keep = "treatment")
)
#> ℹ parameter labels from comments will be replaced by 'label()'
# Replicates the shape of Figure 1g-h of Yang 2025 (pcVPC for the 180 mg QD
# phase 2 / phase 3 studies): 5th, 50th and 95th percentiles over one
# steady-state dosing interval.
sim_cohort |>
dplyr::filter(time >= 312) |>
dplyr::mutate(tad = time - 312) |>
dplyr::group_by(tad) |>
dplyr::summarise(
Q05 = quantile(1000 * Cc, 0.05),
Q50 = quantile(1000 * Cc, 0.50),
Q95 = quantile(1000 * Cc, 0.95),
.groups = "drop"
) |>
ggplot(aes(tad, Q50)) +
geom_ribbon(aes(ymin = Q05, ymax = Q95), alpha = 0.25) +
geom_line(linewidth = 0.8) +
scale_y_log10() +
labs(x = "Time after dose (h)", y = "Iruplinalkib (ng/mL)",
title = "Steady-state interval, 180 mg once daily",
caption = paste("Median and 5th-95th percentile of", n_cohort,
"simulated subjects. Compare Figure 1g-h of Yang 2025."))
Yang 2025 quantifies the overall exposure spread it observed as “-32%
to +86%, -30% to +71% and -44% to +120% for the 5th to 95th percentiles
of the population relative to the typical values of AUCss,
Cmax,ss, and Cmin,ss”.
cohort_exposure <- sim_cohort |>
dplyr::filter(time >= 312) |>
dplyr::group_by(id) |>
dplyr::summarise(
AUCss = trapz(time, Cc),
Cmax_ss = max(Cc),
Cmin_ss = min(Cc),
.groups = "drop"
)
spread <- tibble::tibble(
metric = c("AUCss", "Cmax,ss", "Cmin,ss"),
published_p05 = c(-32, -30, -44),
published_p95 = c(+86, +71, +120),
simulated_p05 = 100 * (vapply(c("AUCss", "Cmax_ss", "Cmin_ss"),
function(k) unname(quantile(cohort_exposure[[k]], 0.05)) / typical_exposure[[k]],
numeric(1)) - 1),
simulated_p95 = 100 * (vapply(c("AUCss", "Cmax_ss", "Cmin_ss"),
function(k) unname(quantile(cohort_exposure[[k]], 0.95)) / typical_exposure[[k]],
numeric(1)) - 1),
median_ratio = vapply(c("AUCss", "Cmax_ss", "Cmin_ss"),
function(k) stats::median(cohort_exposure[[k]]) / typical_exposure[[k]],
numeric(1))
)
spread |>
dplyr::rename(
"Metric" = metric,
"Published 5th (%)" = published_p05,
"Published 95th (%)" = published_p95,
"Simulated 5th (%)" = simulated_p05,
"Simulated 95th (%)" = simulated_p95,
"Simulated median / typical" = median_ratio
) |>
knitr::kable(digits = c(0, 0, 0, 0, 0, 3),
caption = "Population exposure spread relative to the typical subject.")| Metric | Published 5th (%) | Published 95th (%) | Simulated 5th (%) | Simulated 95th (%) | Simulated median / typical |
|---|---|---|---|---|---|
| AUCss | -32 | 86 | -39 | 54 | 1.010 |
| Cmax,ss | -30 | 71 | -37 | 69 | 1.007 |
| Cmin,ss | -44 | 120 | -58 | 87 | 0.998 |
# Cohort-derived, so bounds are loose by construction (pattern 12). The
# published 95th/5th spread ratios are 2.7, 2.4 and 3.9; realised 2.50 / 2.76 /
# 4.05 at 2 threads and 2.57 / 2.75 / 4.62 at 16. A mis-transcribed IIV or
# covariate exponent moves these well outside 2-6.
spread_ratio <- (1 + spread$simulated_p95 / 100) / (1 + spread$simulated_p05 / 100)
stopifnot(all(spread_ratio > 2), all(spread_ratio < 6))
# AUCss depends on CL/F alone, which carries ONE log-normal eta and monotone
# covariate terms -- so the population MEDIAN is the typical value, up to
# sampling noise on 200 draws. Realised 1.008-1.019 for AUCss across thread
# counts; a mis-transcribed CL/F moves it by tens of percent.
stopifnot(all(abs(spread$median_ratio - 1) < 0.20))PKNCA validation
sim_nca <- sim_typ |>
dplyr::filter(!is.na(Cc)) |>
dplyr::select(id, time, Cc, treatment)
# Guarantee a time = 0 record per (id, treatment); pre-dose Cc = 0 is correct
# for an extravascular single dose.
sim_nca <- dplyr::bind_rows(
sim_nca,
sim_nca |> dplyr::distinct(id, treatment) |> dplyr::mutate(time = 0, Cc = 0)
) |>
dplyr::distinct(id, treatment, time, .keep_all = TRUE) |>
dplyr::arrange(id, treatment, time)
single_arms <- c("180 mg single dose", "120 mg single dose, fasted",
"120 mg single dose, fed")
conc_sd <- PKNCA::PKNCAconc(
dplyr::filter(sim_nca, treatment %in% single_arms), Cc ~ time | treatment + id
)
dose_sd <- PKNCA::PKNCAdose(
events_typ |>
dplyr::filter(evid == 1, treatment %in% single_arms) |>
dplyr::select(id, time, amt, treatment),
amt ~ time | treatment + id
)
nca_sd <- PKNCA::pk.nca(PKNCA::PKNCAdata(
conc_sd, dose_sd,
intervals = data.frame(
start = 0, end = Inf,
cmax = TRUE, tmax = TRUE, aucinf.obs = TRUE, half.life = TRUE, cl.obs = TRUE
)
))
nca_sd_wide <- as.data.frame(nca_sd) |>
dplyr::select(treatment, PPTESTCD, PPORRES) |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES)
nca_sd_wide |>
dplyr::mutate(cmax = 1000 * cmax) |>
dplyr::rename(
"Arm" = treatment,
"Cmax (ng/mL)" = cmax,
"Tmax (h)" = tmax,
"AUC0-inf (mg*h/L)" = aucinf.obs,
"t1/2 (h)" = half.life,
"CL/F (L/h)" = cl.obs
) |>
knitr::kable(digits = 3, caption = "PKNCA on the typical-subject single-dose arms.")| Arm | Cmax (ng/mL) | Tmax (h) | tlast | clast.obs | lambda.z | r.squared | adj.r.squared | lambda.z.time.first | lambda.z.time.last | lambda.z.n.points | clast.pred | t1/2 (h) | span.ratio | AUC0-inf (mg*h/L) | CL/F (L/h) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 120 mg single dose, fasted | 271.239 | 2.50 | 336 | 0 | 0.022 | 1 | 1 | 30 | 336 | 109 | 0 | 31.165 | 9.819 | 6.349 | 18.9 |
| 120 mg single dose, fed | 245.548 | 4.05 | 336 | 0 | 0.022 | 1 | 1 | 31 | 336 | 107 | 0 | 31.152 | 9.791 | 6.349 | 18.9 |
| 180 mg single dose | 406.859 | 2.50 | 336 | 0 | 0.022 | 1 | 1 | 30 | 336 | 109 | 0 | 31.165 | 9.819 | 9.524 | 18.9 |
CL/F recovered by NCA must return the
18.9 L/h the model was built from, since
CL/F = Dose / AUC0-inf and the model is linear. This is
another deterministic identity.
cl_nca <- nca_sd_wide$cl.obs
stopifnot(length(cl_nca) == 3L)
stopifnot(max(abs(cl_nca / 18.9 - 1)) < 0.02)
c(range(cl_nca), `Table 2 CL/F` = 18.9)
#> Table 2 CL/F
#> 18.89953 18.89987 18.90000
conc_ss <- PKNCA::PKNCAconc(
dplyr::filter(sim_nca, treatment == "180 mg QD, steady state"),
Cc ~ time | treatment + id
)
dose_ss <- PKNCA::PKNCAdose(
events_typ |>
dplyr::filter(evid == 1, treatment == "180 mg QD, steady state") |>
dplyr::select(id, time, amt, treatment),
amt ~ time | treatment + id
)
nca_ss <- PKNCA::pk.nca(PKNCA::PKNCAdata(
conc_ss, dose_ss,
intervals = data.frame(
start = 312, end = 336,
auclast = TRUE, cmax = TRUE, tmax = TRUE, ctrough = TRUE
)
))
as.data.frame(nca_ss) |>
dplyr::select(PPTESTCD, PPORRES) |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |>
dplyr::mutate(cmax = 1000 * cmax, ctrough = 1000 * ctrough) |>
dplyr::rename(
"AUC0-tau (mg*h/L)" = auclast,
"Cmax,ss (ng/mL)" = cmax,
"Tmax (h)" = tmax,
"Ctrough (ng/mL)" = ctrough
) |>
knitr::kable(digits = 3,
caption = "PKNCA over the 14th dosing interval, 180 mg once daily.")| AUC0-tau (mg*h/L) | Cmax,ss (ng/mL) | Tmax (h) | Ctrough (ng/mL) |
|---|---|---|---|
| 9.517 | 624.584 | 2.25 | NA |
Comparison against published NCA
Yang 2025 reports no NCA table of its own – it is a modelling paper.
It does, however, quote two NCA descriptors from the dedicated phase 1
ADME study (reference 10 of the paper, Wang et al., Expert Opin
Investig Drugs 2024;33:63-72) and a CL/F range from
study WX-0593-001. Those are used here as the external comparators; the
ADME values come from a different publication and a different
cohort, so agreement within 20% is the expectation, not an
identity.
published <- tibble::tribble(
~treatment, ~tmax, ~half.life,
"180 mg single dose", 2.0, 28.6
)
cmp <- nlmixr2lib::ncaComparisonTable(
simulated = nca_sd,
reference = published,
by = "treatment",
units = c(tmax = "h", half.life = "h"),
tolerance_pct = 20
)
knitr::kable(
cmp,
caption = paste(
"Simulated versus published NCA. Reference values are from the phase 1",
"ADME study cited as reference 10 of Yang 2025, not from Yang 2025 itself.",
"* differs from reference by >20%."
),
align = c("l", "l", "r", "r", "r")
)| NCA parameter | treatment | Reference | Simulated | % diff |
|---|---|---|---|---|
| Tmax (h) | 180 mg single dose | 2 | 2.5 | +25.0%* |
| t½ (h) | 180 mg single dose | 28.6 | 31.2 | +9.0% |
The half-life row agrees closely (simulated 31.4 h against the
published 28.6 h, a 9.8% difference). Tmax is
starred: the model gives 2.5 h against the “approximately 2 h”
quoted for the ADME study, a 25% difference on a quantity whose absolute
discrepancy is half an hour. Two things make this expected rather than a
transcription problem. First, the comparator is a soft, rounded prose
value (“approximately 2 h”) from a different study in healthy subjects,
not a tabulated median. Second, Tmax under first-order
absorption is the least robust NCA descriptor here: Ka
carries the largest IIV in the model (74.8% CV) and the highest
shrinkage (58.6%), so the typical-value Tmax is the
parameter least well determined by the pooled data. No parameter was
adjusted.
The paper’s own Discussion reports that study WX-0593-001 gave
CL/F of 12.8-28.2 L/h after single doses and 15.8-27.9 L/h
after multiple doses of 30-300 mg. The packaged model’s
18.9 L/h sits inside both ranges.
stopifnot(cl_nca > 12.8, cl_nca < 28.2)Assumptions and deviations
The reported
IIV (%)is read as a log-normal CV. Yang 2025 Methods states that individual variability was “estimated using an exponential relationship for all PK parameters”, and Table 2’s abbreviation list defines “CV, coefficient of variation”. The tabulated percentages are therefore converted withomega^2 = log(1 + CV^2), the convention used throughout nlmixr2lib. The paper does not print theOMEGAvariances themselves, so the alternative readingomega = CVcannot be excluded from the source; it would raise theKavariance from 0.444 to 0.560 and leaveCL/FandV1/Fessentially unchanged (0.0597 vs 0.0615, 0.2509 vs 0.2852).The absorption lag is zero in the fasted state. Table 2 names the row “ALAG for fed subjects” and Eq. 6 writes
ALAG = 0.472 (if fed), so the lag is gated byFEDrather than applied unconditionally. Under this reading the fasted state – the model’s reference condition – has no lag at all.WT_BASErather thanWT. The paper’s Eqs. 2 and 3 name the covariateBBWT, baseline body weight, while the three laboratory covariates onCL/Fare described throughout as time-varying. The baseline-versus-time-varying distinction is therefore a deliberate, column-by-column choice by the authors, which is exactly what theWT_BASEcanonical records.Creatinine clearance is the raw, un-normalised value in mL/min. Yang 2025 Table 1 lists creatinine clearance (mL/min) and eGFR (mL/min/1.73 m^2) as two separate rows and states that the two were strongly correlated and screened separately, with creatinine clearance retained. The paper does not name the estimating equation.
Study-specific residual error. Two proportional residual magnitudes are reported, keyed on protocol number rather than on subject type. They are encoded through the
STUDY_WX0593_002indicator, which is set to 0 in every simulation here because all the arms replicate patient-study conditions.The virtual cohort’s covariate distributions are constructed, not published. Yang 2025 reports only medians and ranges (Table 1) and the 5th/95th percentiles used for Figure 2. Each covariate is drawn here as an independent log-normal whose median is the paper’s typical-subject value and whose 5th-95th window matches the Figure 2 window; body weight is additionally clipped to the Table 1 range of 35-98.9 kg. The real covariates are correlated (weight with BMI, creatinine clearance with eGFR, and albumin with LDH through disease severity) and skewed, so the simulated exposure spread is expected to be in the right neighbourhood of the published -32%/+86% rather than to match it. That comparison is reported but deliberately gated loosely; the deterministic checks above are the ones that carry the validation weight.
Time-varying covariates are held constant per subject. Albumin, creatinine clearance and LDH enter the published model as time-varying, but the paper publishes no longitudinal trajectories from which to reconstruct them. Holding each at its reference value is the correct construction for the typical-subject checks (where they cancel exactly) and an explicit simplification for the stochastic cohort.
Dose-record
FEDstatus. Only study WX-0593-002 contributed fed records; the patient studies are simulated fasted. The paper does not state the meal composition, so the generalFEDindicator is used rather thanFED_HIGHFAT.The published NCA comparators are from a different paper.
Tmax(~2 h) andt1/2(28.6 h) are quoted by Yang 2025 from the separate phase 1 ADME publication, not measured in the pooled popPK dataset. TheTmaxrow is starred at the 20% tolerance and is discussed above; no parameter was tuned.Equations recovered from the PDF layout. The seven display equations of the final model are vector images in the publisher’s PDF and are lost by text extraction (the trimmed markdown shows seven
formula-not-decodedplaceholders). They were recovered withpdftotext -layout, which is the source for every centring constant in the model file.