Darunavir (Daskapan 2019)
Source:vignettes/articles/Daskapan_2019_darunavir.Rmd
Daskapan_2019_darunavir.Rmd
ui <- rxode2::rxode(readModelDb("Daskapan_2019_darunavir"))
#> ℹ parameter labels from comments will be replaced by 'label()'Model and source
- Citation: Daskapan A, Tran QTD, Cattaneo D, Gervasoni C, Resnati C, Stienstra Y, Bierman WFW, Kosterink JGW, van der Werf TS, Proost JH, Alffenaar JWC, Touw DJ. Darunavir Population Pharmacokinetic Model Based on HIV Outpatient Data. Ther Drug Monit. 2019;41(1):59-65. doi:10.1097/FTD.0000000000000576. PMCID: PMC6358182.
- Description: One-compartment population pharmacokinetic model with first-order absorption for ritonavir-boosted darunavir in HIV-1-infected adult outpatients, developed by an iterative two-stage Bayesian procedure in MWPharm for therapeutic drug monitoring. Clearance is scaled by total body weight and volume by an MWPharm fat-corrected lean body mass; bioavailability and absorption rate are fixed at literature values and the renal clearance fraction is fixed at zero.
- Article: https://doi.org/10.1097/FTD.0000000000000576
- Open-access full text: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC6358182/
- Supplement 1 (initial / prior parameter values): http://links.lww.com/TDM/A279
- Supplement 3 (one- and two-compartment submodel settings and AIC): http://links.lww.com/TDM/A281
- Supplement 4 (fat-distribution and renal-fraction grids): http://links.lww.com/TDM/A282
Daskapan and colleagues built a therapeutic-drug-monitoring (TDM)
model for ritonavir-boosted darunavir from routine HIV outpatient
records, using the iterative two-stage Bayesian (ITSB) procedure of the
KinPop module of MWPharm 3.82 rather than nonlinear
mixed-effects modelling. The purpose is narrow and explicit: translate a
darunavir concentration drawn at a convenient rather than an optimal
time into a predicted trough, so the trough can be scored against the
local follow-up cut-offs.
Two things follow from that purpose and shape everything below.
First, the reported structure is deliberately small. A
one-compartment model with first-order absorption and elimination won;
adding a second compartment did not improve the AIC and estimated a
peripheral volume of 0.051 L/kg, “which is negligible as a significant
peripheral compartment” (Results). Absorption could not be estimated at
all, because few samples fell in the 0-4 h window, so Ka
was held at a literature value.
Second, the published validation is an agreement analysis between measured and individually-Bayesian-predicted concentrations (Passing-Bablok regression in Figure 1, Bland-Altman in Figure 2). Those figures are functions of the individual patient records, which are not public, so they cannot be reproduced here and no attempt is made to. What this vignette does instead is verify that the packaged model is the published model: every derived quantity is checked against the paper’s own equations, the solved system is checked against its own closed form, and each fixed structural switch is checked with a mutation control that proves the term is live rather than dead code.
Population
The development set is “hospital A” – ASST Fatebenefratelli Sacco University Hospital, Milano – a retrospective record review over April 2015 to August 2017. 198 unique adult patients contributed 198 darunavir plasma samples, i.e. one sample per patient. Median age was 54 years (range 24-74), median weight 72.0 kg (range 40-123), median height 173.0 cm (range 150-193), median BMI 24.6 kg/m^2 (range 16.9-35.3) and median serum creatinine 83.5 umol/L (range 44.2-230.7); 29% were women (Table 1). All patients received darunavir/ritonavir 800/100 mg once daily (162 of 198, 82%) or 600/100 mg twice daily (36 of 198, 18%). Samples were excluded when the intake or sampling time was unknown, or when the concentration fell below the 0.2 mg/L lower limit of quantification. Race and ethnicity are not reported.
A separate 170-patient / 170-sample set from University Medical Center Groningen (“hospital B”, January 2010 to May 2017) served only as the external validator and contributed nothing to parameter estimation.
The same information is available programmatically:
str(ui$population)
#> List of 19
#> $ species : chr "human"
#> $ n_subjects : num 198
#> $ n_studies : num 1
#> $ n_observations: num 198
#> $ age_range : chr "24-74 years"
#> $ age_median : chr "54 years"
#> $ weight_range : chr "40-123 kg"
#> $ weight_median : chr "72.0 kg"
#> $ height_range : chr "150-193 cm"
#> $ height_median : chr "173.0 cm"
#> $ bmi_range : chr "16.9-35.3 kg/m^2"
#> $ bmi_median : chr "24.6 kg/m^2"
#> $ sex_female_pct: num 29
#> $ race_ethnicity: chr "Not reported."
#> $ disease_state : chr "HIV-1 infection, adult outpatients on ritonavir-boosted darunavir"
#> $ renal_function: chr "Serum creatinine median 83.5 umol/L, range 44.2-230.7 umol/L; renal function not used as a covariate in the fin"| __truncated__
#> $ dose_range : chr "darunavir/ritonavir 800/100 mg once daily (162 of 198, 82%) or 600/100 mg twice daily (36 of 198, 18%)"
#> $ regions : chr "Italy (development set, ASST Fatebenefratelli Sacco University Hospital, Milano)"
#> $ notes : chr "Development set = 'hospital A' (ASST Fatebenefratelli Sacco, Milano), a retrospective therapeutic-drug-monitori"| __truncated__Source trace
Per-parameter provenance is recorded as an in-file comment beside
each ini() entry in
inst/modeldb/specificDrugs/Daskapan_2019_darunavir.R.
Collected here for review. “Model 2” is the column of Table 2 that
footnote * marks as the chosen final model.
| Equation / parameter | Value | Source location |
|---|---|---|
lka (Ka) |
1.04 1/h, fixed | Table 2, Model 2, Ka; footnotes “literature value”
(Arab-Alameddine 2014) and “set on fixed value”. Supplement 1
one-compartment prior Ka 1.04 (0.35) |
lcl_nonren (CLm) |
9.47 L/h/70 kg | Table 2, Model 2, CLm mean (95% CI 8.24-10.65) |
lvd (Vd) |
2.13 L/kg LBMc | Table 2, Model 2, Vd mean (95% CI 1.39-3.26) |
lfdepot (F) |
0.82, fixed | Table 2, F; footnote “literature value from SPC”.
Results: “bioavailability was fixed in all parameterizations at the
literature value of 0.82” |
e_wt_vd (fd) |
5, fixed | Table 2, “Fat distribution”. Supplement 4 AIC grid 1:1 = 3743.29, 1:2 = 3676.84, 1:5 = 3603.29, 1:10 = 3630.27, 1:20 = 3602.20 |
e_crcl_cl_renal (fr) |
0, fixed | Table 2, Model 2, fr. Supplement 4: fr =
0.12 gives AIC 1611.08, fr = 0 gives 1584.89 |
etalcl_nonren |
0.355749 | Table 2, Model 2, CLm SD 6.19 (4.85-7.76) -> CV
65.4% -> log(1 + CV^2)
|
etalvd |
0.912284 | Table 2, Model 2, Vd SD 2.60 (1.43-4.66) -> CV
122.1% -> log(1 + CV^2)
|
addSd, propSd
|
0.2 mg/L, 0.05, fixed | Methods: “the residual error was assumed to be normally distributed and equal to the SD of the assay, which was estimated as 0.2 + 0.05 * C” |
cl = CLm * (WT / 70) + fr * CLcr |
n/a | Methods, Population Pharmacokinetic Model Development, CL equation |
vc = Vd * LBMc |
n/a | Methods, same paragraph, V equation |
LBMc = LBM + (WT - LBM) * fd |
n/a | Methods, same paragraph |
LBM = 50.0 + 0.9 * (HT - 152) (men),
45.5 + 0.9 * (HT - 152) (women) |
n/a | Methods, same paragraph |
| 1-compartment ODEs, first-order absorption and elimination | n/a | Results: “A 1-compartment model with a first-order absorption and elimination, a distribution to fatty tissue factor (fd) of 5, and a fr value of zero resulted in the best model” |
ui$iniDf |>
dplyr::select(name, est, fix, label) |>
dplyr::rename(
"Parameter" = name, "Estimate" = est, "Fixed" = fix, "Label" = label
) |>
knitr::kable(
digits = 6,
caption = "Packaged ini() block. `Fixed = TRUE` marks values the source held constant rather than estimating."
)| Parameter | Estimate | Fixed | Label |
|---|---|---|---|
| lka | 0.039221 | TRUE | Absorption rate constant (1/h; literature value, Arab-Alameddine 2014) |
| lcl_nonren | 2.248129 | FALSE | Metabolic clearance CLm at 70 kg total body weight (L/h/70 kg) |
| lvd | 0.756122 | FALSE | Volume of distribution per kg fat-corrected lean body mass (L/kg LBMc) |
| lfdepot | -0.198451 | TRUE | Oral bioavailability (fraction; literature value, darunavir SPC) |
| e_wt_vd | 5.000000 | TRUE | Fat-distribution factor fd weighting (WT - LBM) inside LBMc (unitless interpolation coefficient, not an exponent) |
| e_crcl_cl_renal | 0.000000 | TRUE | Ratio fr of darunavir renal clearance to creatinine clearance (unitless slope of the renal CL arm on CLcr in L/h) |
| addSd | 0.200000 | TRUE | Additive residual SD (mg/L; assay SD intercept, not estimated) |
| propSd | 0.050000 | TRUE | Proportional residual SD (fraction; assay SD slope, not estimated) |
| etalcl_nonren | 0.355749 | FALSE | Table 2, Model 2, CLm ‘SD (95% CI)’ = 6.19 (4.85-7.76) L/h/70kgBW -> 65.4 % CV |
| etalvd | 0.912284 | FALSE | Table 2, Model 2, Vd ‘SD (95% CI)’ = 2.60 (1.43-4.66) L/kgLBMc -> 122.1 % CV |
Structural verification
These checks are deterministic: each one re-derives a published quantity independently and compares it with what the compiled model actually computes. None of them depends on a random draw, so the tolerances are numerical rather than statistical.
The solved system keeps both compartments
rxode2 can match a cl / vc pair against its
analytic kernel and discard the explicit d/dt bodies. For a
one-compartment oral model the analytic and ODE systems are identical,
so this would not change the predictions, but it would drop the state
columns. Assert that it did not happen.
typ <- rxode2::zeroRe(ui)
cov_grid <- tibble::tibble(
id = 1:12,
SEXF = c(0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1),
HT = c(160, 173, 173, 180, 190, 193, 152, 165, 165, 170, 178, 185),
WT = c( 62, 60, 72, 95, 110, 123, 48, 55, 65, 80, 95, 105),
CRCL = c( 60, 90, 100, 110, 90, 75, 55, 90, 100, 120, 85, 70)
)
sd_grid <- dplyr::bind_rows(
cov_grid |> dplyr::mutate(time = 0, amt = 800, evid = 1L, cmt = "depot"),
cov_grid |>
tidyr::crossing(time = sort(unique(c(
0, exp(seq(log(0.01), log(24), length.out = 220)),
seq(24, 300, by = 0.5)
)))) |>
dplyr::mutate(amt = NA_real_, evid = 0L, cmt = "central")
) |>
dplyr::arrange(id, time, evid)
sd_typ <- rxode2::rxSolve(
typ, sd_grid,
omega = NA, # mandatory: rxSolve reuses a previous solve's omega otherwise
keep = c("WT", "HT", "SEXF", "CRCL"),
returnType = "data.frame"
)
#> Warning: multi-subject simulation without without 'omega'
stopifnot(
all(c("depot", "central") %in% names(sd_typ)),
# zeroRe actually took: one cl per covariate row, not a random draw
nrow(dplyr::distinct(sd_typ, id, cl_nonren)) == nrow(cov_grid)
)Every derived covariate term matches the published equations
LBM, LBMc, the clearance arms and the
absolute volume are recomputed here in plain R straight from the Methods
equations, then compared with the columns the compiled model
returns.
derived <- sd_typ |>
dplyr::distinct(id, WT, HT, SEXF, CRCL, lbm, lbmc, cl_nonren, cl_renal, cl, vd, vc) |>
dplyr::mutate(
# Methods, V equation
lbm_ref = ifelse(SEXF == 1, 45.5, 50.0) + 0.9 * (HT - 152),
lbmc_ref = lbm_ref + (WT - lbm_ref) * 5,
vc_ref = 2.13 * lbmc_ref,
# Methods, CL equation, with fr = 0 and CLcr converted mL/min -> L/h
cl_nonren_ref = 9.47 * (WT / 70),
cl_renal_ref = 0 * 0.06 * CRCL,
cl_ref = cl_nonren_ref + cl_renal_ref
)
max_rel <- function(a, b) max(abs(a - b) / pmax(abs(b), 1e-12))
stopifnot(
max_rel(derived$lbm, derived$lbm_ref) < 1e-12,
max_rel(derived$lbmc, derived$lbmc_ref) < 1e-12,
max_rel(derived$vc, derived$vc_ref) < 1e-10,
max_rel(derived$cl_nonren, derived$cl_nonren_ref) < 1e-10,
max_rel(derived$cl, derived$cl_ref) < 1e-10,
all(derived$cl_renal == 0)
)
derived |>
dplyr::transmute(
SEXF, HT, WT,
LBM = round(lbm, 2), LBMc = round(lbmc, 2),
`CL (L/h)` = round(cl, 3), `Vc (L)` = round(vc, 1),
`t1/2 (h)` = round(log(2) * vc / cl, 1)
) |>
dplyr::rename("Female" = SEXF, "Height (cm)" = HT, "Weight (kg)" = WT) |>
knitr::kable(caption = "Derived individual parameters over a representative covariate grid.")| Female | Height (cm) | Weight (kg) | LBM | LBMc | CL (L/h) | Vc (L) | t1/2 (h) |
|---|---|---|---|---|---|---|---|
| 0 | 160 | 62 | 57.2 | 81.2 | 8.388 | 173.0 | 14.3 |
| 0 | 173 | 60 | 68.9 | 24.4 | 8.117 | 52.0 | 4.4 |
| 0 | 173 | 72 | 68.9 | 84.4 | 9.741 | 179.8 | 12.8 |
| 0 | 180 | 95 | 75.2 | 174.2 | 12.852 | 371.0 | 20.0 |
| 0 | 190 | 110 | 84.2 | 213.2 | 14.881 | 454.1 | 21.2 |
| 0 | 193 | 123 | 86.9 | 267.4 | 16.640 | 569.6 | 23.7 |
| 1 | 152 | 48 | 45.5 | 58.0 | 6.494 | 123.5 | 13.2 |
| 1 | 165 | 55 | 57.2 | 46.2 | 7.441 | 98.4 | 9.2 |
| 1 | 165 | 65 | 57.2 | 96.2 | 8.794 | 204.9 | 16.2 |
| 1 | 170 | 80 | 61.7 | 153.2 | 10.823 | 326.3 | 20.9 |
| 1 | 178 | 95 | 68.9 | 199.4 | 12.852 | 424.7 | 22.9 |
| 1 | 185 | 105 | 75.2 | 224.2 | 14.205 | 477.5 | 23.3 |
The solved profile matches its own closed form
A one-compartment model with first-order absorption has the Bateman solution
Cc(t) = F * Dose * ka / (vc * (ka - kel)) * (exp(-kel * t) - exp(-ka * t)).
Both sides use each subject’s own solved ka,
cl and vc, so the only difference admissible
here is solver quadrature error. This is the check that would go red if
the compiled system were structurally different from the published
one.
cf <- sd_typ |>
dplyr::filter(!is.na(Cc), time > 0) |>
dplyr::mutate(
kel = cl / vc,
Cc_closed = exp(ui$theta[["lfdepot"]]) * 800 * ka / (vc * (ka - kel)) *
(exp(-kel * time) - exp(-ka * time))
) |>
# Floor the comparison at 1e-6 mg/L. The grid runs to 300 h, and in the far
# tail both sides are 1e-19 mg/L -- nineteen orders of magnitude below the
# paper's 0.2 mg/L limit of quantification -- where the solver's ABSOLUTE
# tolerance dominates and a RELATIVE difference is meaningless (it reaches
# 0.70 there). 1e-6 mg/L is still five orders below anything measurable.
dplyr::filter(Cc_closed > 1e-6)
stopifnot(
nrow(cf) > 2000, # the gate has rows to test
# Deterministic quantity (no cohort, no RNG), so the bound is tight: the
# realised maximum is 7.1e-7, set by solver quadrature, not by a draw.
max_rel(cf$Cc, cf$Cc_closed) < 1e-5
)
cat(sprintf(
"closed-form agreement: max relative difference %.2e over %d points\n",
max_rel(cf$Cc, cf$Cc_closed), nrow(cf)
))
#> closed-form agreement: max relative difference 7.09e-07 over 8654 pointsMutation controls – each fixed switch is live, not dead code
Three of the model’s parameters are fixed at values that make their
term disappear or collapse (fr = 0 removes the renal
clearance arm entirely, fd = 5 is a grid-selected constant,
F = 0.82 is a literature anchor). A gate that only checks
the published configuration cannot distinguish a wired term from a
mis-typed one. Each is therefore perturbed and the predicted consequence
asserted exactly.
one <- sd_grid |> dplyr::filter(id == 3) # 72 kg, 173 cm man, CRCL 100
solve_one <- function(params = NULL) {
rxode2::rxSolve(
typ, one, omega = NA, params = params,
keep = c("WT", "HT", "SEXF", "CRCL"), returnType = "data.frame"
)
}
base <- solve_one()
base_cl <- base$cl[1]
base_vc <- base$vc[1]
# (1) fr: restoring the SPC renal fraction of 0.12 that Supplement 4 rejected
# must add exactly fr * CLcr(L/h) to total clearance.
mut_fr <- solve_one(c(e_crcl_cl_renal = 0.12))
expect_fr <- base_cl + 0.12 * 0.06 * 100
# (2) fd: fd = 1 collapses LBMc to total body weight, by construction
# (LBMc = LBM + (WT - LBM) * 1 = WT). An algebraic identity, so exact.
mut_fd <- solve_one(c(e_wt_vd = 1))
# (3) F: setting bioavailability to 1 must scale every concentration by 1/0.82.
mut_f <- solve_one(c(lfdepot = log(1)))
both <- dplyr::inner_join(
# Same 1e-6 mg/L floor as the closed-form gate, for the same reason.
base |> dplyr::filter(!is.na(Cc), Cc > 1e-6) |> dplyr::select(time, Cc),
mut_f |> dplyr::filter(!is.na(Cc), Cc > 1e-6) |> dplyr::select(time, Cc_f = Cc),
by = "time"
)
stopifnot(
# each mutation moved something
abs(mut_fr$cl[1] - base_cl) > 1e-6,
abs(mut_fd$vc[1] - base_vc) > 1e-6,
nrow(both) > 500, # the single-subject grid holds ~770 observation times
# and moved it by exactly the predicted amount
abs(mut_fr$cl[1] - expect_fr) < 1e-9,
abs(mut_fd$lbmc[1] - 72) < 1e-9,
max_rel(both$Cc_f / both$Cc, rep(1 / 0.82, nrow(both))) < 1e-5
)
tibble::tibble(
Mutation = c("fr: 0 -> 0.12", "fd: 5 -> 1", "F: 0.82 -> 1"),
Quantity = c("CL (L/h)", "LBMc (kg)", "Cc ratio vs published"),
Published = c(round(base_cl, 4), round(base$lbmc[1], 2), 1),
Mutated = c(round(mut_fr$cl[1], 4), round(mut_fd$lbmc[1], 2), round(mean(both$Cc_f / both$Cc), 4)),
Predicted = c(round(expect_fr, 4), 72, round(1 / 0.82, 4))
) |>
knitr::kable(caption = "Mutation controls. Each fixed switch changes the model by exactly the amount its equation predicts.")| Mutation | Quantity | Published | Mutated | Predicted |
|---|---|---|---|---|
| fr: 0 -> 0.12 | CL (L/h) | 9.7406 | 10.4606 | 10.4606 |
| fd: 5 -> 1 | LBMc (kg) | 84.4000 | 72.0000 | 72.0000 |
| F: 0.82 -> 1 | Cc ratio vs published | 1.0000 | 1.2195 | 1.2195 |
Dose recovery through PKNCA
For a linear one-compartment model the clearance identity
CL * AUC(0-inf) = F * Dose holds exactly. AUC is computed
with PKNCA over the covariate grid, never by an inline trapezoid. The
same identity re-run with F mutated to 1 is the control
that keeps the gate from passing vacuously.
nca_recovery <- function(sim, dose = 800) {
conc <- sim |>
dplyr::filter(!is.na(Cc)) |>
dplyr::transmute(id, time, Cc = pmax(Cc, 0), regimen = "single dose")
conc <- dplyr::bind_rows(
conc,
conc |> dplyr::distinct(id, regimen) |> dplyr::mutate(time = 0, Cc = 0)
) |>
dplyr::distinct(id, regimen, time, .keep_all = TRUE) |>
dplyr::arrange(id, time)
dose_df <- sim |>
dplyr::distinct(id) |>
dplyr::mutate(time = 0, amt = dose, regimen = "single dose")
res <- PKNCA::pk.nca(PKNCA::PKNCAdata(
PKNCA::PKNCAconc(as.data.frame(conc), Cc ~ time | regimen + id,
concu = "mg/L", timeu = "h"
),
PKNCA::PKNCAdose(as.data.frame(dose_df), amt ~ time | regimen + id,
doseu = "mg", route = "extravascular"
),
intervals = data.frame(
start = 0, end = Inf,
cmax = TRUE, tmax = TRUE, aucinf.obs = TRUE, half.life = TRUE
)
))
as.data.frame(res$result) |>
dplyr::filter(PPTESTCD %in% c("cmax", "tmax", "aucinf.obs", "half.life")) |>
dplyr::select(id, PPTESTCD, PPORRES) |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES)
}
rec_base <- nca_recovery(sd_typ) |>
dplyr::left_join(dplyr::select(derived, id, cl, vc), by = "id") |>
dplyr::mutate(recovered = cl * aucinf.obs / 800)
sd_f1 <- rxode2::rxSolve(
typ, sd_grid,
omega = NA, params = c(lfdepot = log(1)),
keep = c("WT", "HT", "SEXF", "CRCL"), returnType = "data.frame"
)
#> Warning: multi-subject simulation without without 'omega'
rec_f1 <- nca_recovery(sd_f1) |>
dplyr::left_join(dplyr::select(derived, id, cl), by = "id") |>
dplyr::mutate(recovered = cl * aucinf.obs / 800)
stopifnot(
nrow(rec_base) == nrow(cov_grid), # the gate has rows to test
!anyNA(rec_base$aucinf.obs),
# published F = 0.82
max(abs(rec_base$recovered - 0.82)) < 0.005,
# mutation control: with F = 1 the same identity must recover 1, not 0.82
max(abs(rec_f1$recovered - 1.00)) < 0.005,
# and PKNCA's half-life must match log(2) * vc / cl
max_rel(rec_base$half.life, log(2) * rec_base$vc / rec_base$cl) < 0.01
)
rec_base |>
dplyr::transmute(
id,
`Cmax (mg/L)` = round(cmax, 3),
`Tmax (h)` = round(tmax, 2),
`AUC0-inf (mg*h/L)` = round(aucinf.obs, 1),
`t1/2 (h)` = round(half.life, 1),
`CL * AUC / Dose` = round(recovered, 4)
) |>
knitr::kable(caption = "Single 800 mg dose, typical values over the covariate grid. The last column recovers the fixed bioavailability of 0.82.")| id | Cmax (mg/L) | Tmax (h) | AUC0-inf (mg*h/L) | t1/2 (h) | CL * AUC / Dose |
|---|---|---|---|---|---|
| 1 | 3.265 | 3.05 | 78.2 | 14.3 | 0.82 |
| 2 | 9.029 | 2.14 | 80.8 | 4.5 | 0.82 |
| 3 | 3.102 | 2.95 | 67.3 | 12.8 | 0.82 |
| 4 | 1.572 | 3.40 | 51.0 | 20.0 | 0.82 |
| 5 | 1.291 | 3.40 | 44.1 | 21.2 | 0.82 |
| 6 | 1.039 | 3.52 | 39.4 | 23.7 | 0.82 |
| 7 | 4.530 | 3.05 | 101.0 | 13.2 | 0.82 |
| 8 | 5.428 | 2.75 | 88.2 | 9.2 | 0.82 |
| 9 | 2.791 | 3.17 | 74.6 | 16.2 | 0.82 |
| 10 | 1.795 | 3.40 | 60.6 | 20.9 | 0.82 |
| 11 | 1.389 | 3.52 | 51.0 | 22.9 | 0.82 |
| 12 | 1.237 | 3.52 | 46.2 | 23.3 | 0.82 |
The fd = 5 domain bound
This is the one property of the published parameterisation that a user must know before simulating it, and it is a consequence of the fitted value rather than of the transcription.
With fd = 1 the corrected lean body mass
LBMc = LBM + (WT - LBM) * fd equals total body weight and
with fd = 0 it equals lean body mass; the selected
fd = 5 therefore extrapolates rather than
interpolating, and rearranges to LBMc = 5 * WT - 4 * LBM.
That is positive only when WT > 0.8 * LBM, i.e.
- men:
WT > 40 + 0.72 * (HT - 152)kg, - women:
WT > 36.4 + 0.72 * (HT - 152)kg.
A 173 cm man needs more than 55.1 kg and a 193 cm man more than 69.5
kg. Below the bound LBMc, and hence the volume of
distribution, is negative. The bound is reachable inside the source
cohort’s own reported ranges, which extend to 193 cm, down to 40 kg and
down to a BMI of 16.9 kg/m^2: a 173 cm man at BMI 16.9 weighs 50.6 kg
and falls below it. Any virtual cohort built from this model must
therefore be restricted, which is what the next section does.
bound <- tidyr::crossing(
HT = seq(150, 195, by = 1),
SEXF = c(0, 1)
) |>
dplyr::mutate(
LBM = ifelse(SEXF == 1, 45.5, 50.0) + 0.9 * (HT - 152),
WT_min = 0.8 * LBM,
Sex = ifelse(SEXF == 1, "Women", "Men")
)
# Verified against the model itself rather than against the algebra alone.
stopifnot(
abs(with(dplyr::filter(bound, Sex == "Men", HT == 173), WT_min) - 55.12) < 0.01,
abs(with(dplyr::filter(bound, Sex == "Men", HT == 193), WT_min) - 69.52) < 0.01
)
ggplot(bound, aes(HT, WT_min, colour = Sex)) +
geom_line(linewidth = 0.9) +
geom_ribbon(aes(ymin = -Inf, ymax = WT_min, fill = Sex), alpha = 0.12, colour = NA) +
annotate("point", x = 173, y = 50.6, shape = 4, size = 3) +
annotate("text",
x = 173, y = 47, hjust = 0.5, size = 3,
label = "173 cm man at the cohort's\nminimum BMI of 16.9 kg/m^2"
) +
labs(
x = "Height (cm)", y = "Minimum body weight for a positive LBMc (kg)",
title = "Domain bound created by the fitted fat-distribution factor fd = 5",
caption = "Shaded region: LBMc = 5 * WT - 4 * LBM is negative, so the model returns a negative volume."
) +
theme_bw()
Virtual cohort
Individual patient data are not public. The cohort below approximates
the Table 1 demographics of hospital A – 29% women, median BMI 24.6
kg/m^2 (range 16.9-35.3), sex-appropriate heights – and is then
restricted to the positive-LBMc domain above. The number of
draws that restriction removes is reported rather than silently
absorbed.
# set.seed() seeds R's RNG, which is what draws the covariates below, so the
# COHORT is reproducible. It does NOT seed rxode2's eta sampler, whose streams
# are partitioned per solver thread -- so every assertion on an eta-driven
# quantity downstream is written to hold for any draw.
set.seed(20190201)
n_per_arm <- 200L
draw_subjects <- function(n_draw) {
sexf <- rbinom(n_draw, 1L, 0.29)
ht <- round(ifelse(sexf == 1L, rnorm(n_draw, 163, 6.5), rnorm(n_draw, 176, 7)))
ht <- pmin(pmax(ht, 150), 193) # Table 1 height range
bmi <- pmin(pmax(rnorm(n_draw, 24.6, 3.6), 16.9), 35.3) # Table 1 BMI range
tibble::tibble(
SEXF = sexf, HT = ht,
WT = round(pmin(pmax(bmi * (ht / 100)^2, 40), 123), 1), # Table 1 weight range
# CKD-EPI eGFR; enters only through fr, which the final model fixes at 0.
CRCL = round(pmin(pmax(rnorm(n_draw, 95, 22), 25), 150), 1)
) |>
dplyr::mutate(
LBM = ifelse(SEXF == 1L, 45.5, 50.0) + 0.9 * (HT - 152),
in_domain = WT > 0.8 * LBM
)
}
pool <- draw_subjects(3000L)
n_dropped <- sum(!pool$in_domain)
cat(sprintf(
"domain restriction: %d of %d draws (%.1f%%) had WT <= 0.8 * LBM and were dropped\n",
n_dropped, nrow(pool), 100 * n_dropped / nrow(pool)
))
#> domain restriction: 106 of 3000 draws (3.5%) had WT <= 0.8 * LBM and were dropped
stopifnot(n_dropped > 0) # the restriction is doing work, not decorative
subjects <- pool |>
dplyr::filter(in_domain) |>
head(2L * n_per_arm) |>
dplyr::mutate(id = seq_len(dplyr::n())) |>
dplyr::select(id, SEXF, HT, WT, CRCL, LBM)
stopifnot(nrow(subjects) == 2L * n_per_arm)
# 40 days of dosing, not the 14 that a median half-life of ~13 h would suggest.
# The 122% CV on Vd puts individual half-lives as high as ~480 h, and a subject
# who has not accumulated to steady state has an AUC over the final interval
# well below F * Dose / CL -- measured at 14 days, 10% of a 100-subject draw
# failed the identity, down to 38% recovery. Dose events are cheap; only
# observation rows cost, and those are confined to the first and last intervals.
regimens <- tibble::tribble(
~regimen, ~amt, ~ii, ~n_dose,
"600/100 mg q12h", 600, 12, 80L,
"800/100 mg q24h", 800, 24, 40L
)
t_end <- 960 # h = 40 days; identical for both arms
make_arm <- function(reg, subj) {
last_dose <- reg$ii * (reg$n_dose - 1L)
obs_times <- sort(unique(c(
seq(0, 24, by = 0.5), # first dosing interval, for the accumulation view
last_dose, # exact interval start
# Log-spaced immediately after the dose so the sharp early peak of a
# low-volume subject is resolved, then uniform across the interval.
last_dose + exp(seq(log(0.01), log(reg$ii), length.out = 120)),
seq(last_dose, t_end, length.out = 97) # includes BOTH endpoints exactly,
))) # which cmin needs
dplyr::bind_rows(
subj |>
tidyr::crossing(time = reg$ii * seq(0L, reg$n_dose - 1L)) |>
dplyr::mutate(amt = reg$amt, evid = 1L, cmt = "depot"),
subj |>
tidyr::crossing(time = obs_times) |>
dplyr::mutate(amt = NA_real_, evid = 0L, cmt = "central")
) |>
dplyr::mutate(regimen = reg$regimen, ii_h = reg$ii, last_dose = last_dose)
}
events <- dplyr::bind_rows(
make_arm(regimens[1, ], subjects[1:n_per_arm, ]),
make_arm(regimens[2, ], subjects[(n_per_arm + 1):(2 * n_per_arm), ])
) |>
dplyr::arrange(id, time, evid)
# IDs are disjoint across arms, so no subject can silently receive both.
stopifnot(
!anyDuplicated(unique(events[, c("id", "time", "evid")])),
length(intersect(
unique(events$id[events$regimen == regimens$regimen[1]]),
unique(events$id[events$regimen == regimens$regimen[2]])
)) == 0L,
all(subjects$WT > 0.8 * subjects$LBM),
all(subjects$LBM + (subjects$WT - subjects$LBM) * 5 > 0)
)Simulation
sim <- rxode2::rxSolve(
ui, events,
omega = ui$omega, # mandatory: see the note in the cohort chunk
keep = c("regimen", "WT", "HT", "SEXF", "CRCL", "ii_h", "last_dose"),
returnType = "data.frame"
)
stopifnot(
all(c("depot", "central") %in% names(sim)),
# the population solve really did sample IIV (the reverse of the omega trap)
dplyr::n_distinct(round(sim$vd, 8)) > 100L,
dplyr::n_distinct(round(sim$cl_nonren, 8)) > 100L,
# and the drawn volumes stayed positive over the restricted cohort
all(sim$vc > 0), all(sim$cl > 0), !anyNA(sim$Cc)
)The cohort’s realised variability is a draw, so the check below is
deliberately loose: it confirms the omega^2 = log(1 + CV^2)
encoding put the right order of magnitude of spread on each parameter,
not that any particular draw reproduced the published SD.
iiv <- sim |>
dplyr::distinct(id, cl_nonren, vd) |>
dplyr::summarise(
`CLm median (L/h/70 kg)` = median(cl_nonren),
`CLm CV (%)` = 100 * sd(cl_nonren) / mean(cl_nonren),
`Vd median (L/kg LBMc)` = median(vd),
`Vd CV (%)` = 100 * sd(vd) / mean(vd)
)
# Published: CLm 9.47 with SD 6.19 (CV 65.4 %); Vd 2.13 with SD 2.60 (CV 122.1 %).
# The MEDIAN of a log-normal whose median is the published mean is the published
# mean, so those are checked with generous multiplicative bounds; the CVs are
# checked only for order of magnitude because a 400-subject draw of a CV-122 %
# log-normal has a very wide sampling distribution.
stopifnot(
iiv$`CLm median (L/h/70 kg)` > 9.47 / 1.5, iiv$`CLm median (L/h/70 kg)` < 9.47 * 1.5,
iiv$`Vd median (L/kg LBMc)` > 2.13 / 1.5, iiv$`Vd median (L/kg LBMc)` < 2.13 * 1.5,
iiv$`CLm CV (%)` > 35, iiv$`CLm CV (%)` < 110,
iiv$`Vd CV (%)` > 60, iiv$`Vd CV (%)` < 220
)
iiv |>
dplyr::mutate(dplyr::across(dplyr::everything(), \(x) round(x, 2))) |>
knitr::kable(caption = "Realised cohort variability against the published CLm 9.47 (SD 6.19) and Vd 2.13 (SD 2.60).")| CLm median (L/h/70 kg) | CLm CV (%) | Vd median (L/kg LBMc) | Vd CV (%) |
|---|---|---|---|
| 10.01 | 59 | 2.01 | 139.62 |
Simulated concentration-time behaviour
The paper publishes no concentration-versus-time figure – its Figures 1 and 2 are Passing-Bablok and Bland-Altman agreement plots built from individual patient records, which are not public. The panels below therefore characterise the packaged model rather than replicating a published figure.
sim |>
dplyr::filter(!is.na(Cc), time <= 24) |>
dplyr::group_by(regimen, time) |>
dplyr::summarise(
Q05 = quantile(Cc, 0.05), Q50 = quantile(Cc, 0.50), Q95 = quantile(Cc, 0.95),
.groups = "drop"
) |>
ggplot(aes(time, Q50)) +
geom_ribbon(aes(ymin = Q05, ymax = Q95), alpha = 0.25, fill = "steelblue") +
geom_line(linewidth = 0.8) +
facet_wrap(~regimen) +
scale_x_continuous(breaks = seq(0, 24, 6)) +
labs(
x = "Time after first dose (h)", y = "Darunavir concentration (mg/L)",
title = "First 24 h after treatment start",
caption = "Median with 5th-95th percentile band, 200 virtual patients per regimen."
) +
theme_bw()
sim |>
dplyr::filter(!is.na(Cc), time >= last_dose) |>
dplyr::mutate(tad = time - last_dose) |>
dplyr::group_by(regimen, tad) |>
dplyr::summarise(
Q05 = quantile(Cc, 0.05), Q50 = quantile(Cc, 0.50), Q95 = quantile(Cc, 0.95),
.groups = "drop"
) |>
ggplot(aes(tad, Q50)) +
geom_ribbon(aes(ymin = Q05, ymax = Q95), alpha = 0.25, fill = "darkorange") +
geom_line(linewidth = 0.8) +
geom_hline(
data = tibble::tibble(
regimen = regimens$regimen, cutoff = c(2.60, 1.07)
),
aes(yintercept = cutoff), linetype = "dashed"
) +
facet_wrap(~regimen, scales = "free_x") +
labs(
x = "Time after the last dose (h)", y = "Darunavir concentration (mg/L)",
title = "Steady state, final dosing interval",
caption = paste(
"Median with 5th-95th percentile band. Dashed line: the follow-up trough",
"cut-off quoted in the Discussion (1.07 mg/L once daily, 2.60 mg/L twice daily)."
)
) +
theme_bw()
PKNCA validation
Steady-state NCA over the final dosing interval of each regimen, per
PKNCA recipe 3. The observation grid places records at both interval
endpoints exactly, which cmin needs.
Note the regimen column on the intervals
frame. Without it PKNCA applies every interval row to
every group, so each subject would receive results for
both the q12h and the q24h window; pivot_wider() then
silently produces list columns and an anyNA() check on them
returns FALSE regardless of content – a gate that cannot go
red. The type assertions below exist for that reason.
sim_nca <- sim |>
dplyr::filter(!is.na(Cc)) |>
dplyr::transmute(id, time, Cc, regimen = as.character(regimen))
# Guarantee a time = 0 record per subject; for extravascular dosing the
# pre-dose concentration is 0.
sim_nca <- dplyr::bind_rows(
sim_nca,
sim_nca |> dplyr::distinct(id, regimen) |> dplyr::mutate(time = 0, Cc = 0)
) |>
dplyr::distinct(id, regimen, time, .keep_all = TRUE) |>
dplyr::arrange(id, time)
dose_df <- events |>
dplyr::filter(evid == 1L) |>
dplyr::select(id, time, amt, regimen) |>
dplyr::mutate(regimen = as.character(regimen))
conc_obj <- PKNCA::PKNCAconc(
as.data.frame(sim_nca), Cc ~ time | regimen + id,
concu = "mg/L", timeu = "h"
)
dose_obj <- PKNCA::PKNCAdose(
as.data.frame(dose_df), amt ~ time | regimen + id,
doseu = "mg", route = "extravascular"
)
ss_windows <- events |>
dplyr::distinct(regimen, ii_h, last_dose) |>
dplyr::arrange(regimen) # PKNCA orders groups alphabetically
intervals <- data.frame(
regimen = as.character(ss_windows$regimen), # binds each window to its own arm
start = ss_windows$last_dose,
end = ss_windows$last_dose + ss_windows$ii_h,
cmax = TRUE, tmax = TRUE, cmin = TRUE,
auclast = TRUE, cav = TRUE,
stringsAsFactors = FALSE
)
nca_res <- PKNCA::pk.nca(PKNCA::PKNCAdata(conc_obj, dose_obj, intervals = intervals))
nca_par <- c("cmax", "tmax", "cmin", "auclast", "cav")
nca_raw <- as.data.frame(nca_res$result) |>
dplyr::filter(PPTESTCD %in% nca_par)
# One result per subject per parameter -- proves the interval grouping worked
# and that the pivot below cannot produce a list column.
stopifnot(
nrow(dplyr::count(nca_raw, regimen, id, PPTESTCD) |> dplyr::filter(n > 1L)) == 0L
)
nca_tbl <- nca_raw |>
dplyr::select(regimen, id, PPTESTCD, PPORRES) |>
tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES)
stopifnot(
nrow(nca_tbl) == 2L * n_per_arm, # every subject produced a result
# Type guard: a list column would make every anyNA() below pass vacuously.
all(vapply(nca_tbl[nca_par], is.numeric, logical(1))),
!anyNA(nca_tbl$auclast), !anyNA(nca_tbl$cmin), !anyNA(nca_tbl$cmax)
)At steady state a linear model must satisfy
CL * AUC(0-tau) = F * Dose, evaluated per subject against
that subject’s own drawn clearance. It is exact up to quadrature, so it
is a structural gate rather than a statistical one, and it is the check
that would catch a lost dose event, a wrong f(depot) target
or a mis-scaled interval.
The identity only holds once a subject has actually
accumulated to steady state, which is not automatic here: the
122% CV on Vd gives some subjects half-lives of several
hundred hours. Each subject’s attainment is therefore tested explicitly
against its own half-life and the shortfall is reported rather than
absorbed into a loosened tolerance.
subject_pk <- sim |>
dplyr::distinct(id, regimen, cl, vc) |>
dplyr::mutate(
regimen = as.character(regimen),
thalf = log(2) * vc / cl,
# 7 half-lives leaves under 1% of the approach to steady state outstanding.
ss_attained = t_end >= 7 * thalf
)
stopifnot(nrow(subject_pk) == 2L * n_per_arm) # one row per subject, no fan-out
mb <- nca_tbl |>
dplyr::left_join(subject_pk, by = c("id", "regimen")) |>
dplyr::left_join(regimens |> dplyr::select(regimen, dose_amt = amt), by = "regimen") |>
dplyr::mutate(recovered = cl * auclast / (0.82 * dose_amt))
stopifnot(nrow(mb) == 2L * n_per_arm, !anyNA(mb$recovered), !anyNA(mb$ss_attained))
n_not_ss <- sum(!mb$ss_attained)
cat(sprintf(
paste0(
"half-life: median %.1f h, max %.1f h\n",
"not yet at steady state after %d h (t1/2 > %.0f h): %d of %d subjects\n",
"CL * AUCtau / (F * Dose), steady-state subjects only: %.4f - %.4f\n"
),
median(mb$thalf), max(mb$thalf), t_end, t_end / 7, n_not_ss, nrow(mb),
min(mb$recovered[mb$ss_attained]), max(mb$recovered[mb$ss_attained])
))
#> half-life: median 12.9 h, max 748.0 h
#> not yet at steady state after 960 h (t1/2 > 137 h): 7 of 400 subjects
#> CL * AUCtau / (F * Dose), steady-state subjects only: 0.9944 - 1.0000
stopifnot(
# The gate must have most of the cohort to test.
sum(mb$ss_attained) > 0.9 * nrow(mb),
# Quadrature over one interval plus the < 1% of accumulation still outstanding
# at 7 half-lives. Observed maxima across draws sat at or under 1.0%; 3%
# leaves headroom for which subjects land near the attainment boundary while
# still going red on a structural break (dropping f(depot) moves this 22%).
max(abs(mb$recovered[mb$ss_attained] - 1)) < 0.03
)The same identity on the deterministic typical-value covariate grid, where every subject is comfortably at steady state, is where the tight bound belongs.
ss_typ <- lapply(seq_len(nrow(regimens)), function(i) {
reg <- regimens[i, ]
ld <- reg$ii * (reg$n_dose - 1L)
ev <- dplyr::bind_rows(
cov_grid |> tidyr::crossing(time = reg$ii * seq(0L, reg$n_dose - 1L)) |>
dplyr::mutate(amt = reg$amt, evid = 1L, cmt = "depot"),
cov_grid |> tidyr::crossing(time = sort(unique(c(
ld, ld + exp(seq(log(0.01), log(reg$ii), length.out = 150)),
seq(ld, ld + reg$ii, length.out = 97)
)))) |>
dplyr::mutate(amt = NA_real_, evid = 0L, cmt = "central")
) |>
dplyr::arrange(id, time, evid)
s <- rxode2::rxSolve(typ, ev, omega = NA, returnType = "data.frame")
s <- s[!is.na(s$Cc) & s$time >= ld, ]
# pk.calc.auc.last() defaults its interval to start at 0, so it returns NA on
# an absolute-time window starting at 936 h. Pass time after the dose instead.
s$tad <- s$time - ld
s |>
dplyr::group_by(id) |>
dplyr::summarise(
cl = dplyr::first(cl),
auctau = PKNCA::pk.calc.auc.last(conc = Cc, time = tad),
.groups = "drop"
) |>
dplyr::mutate(regimen = reg$regimen, recovered = cl * auctau / (0.82 * reg$amt))
}) |>
dplyr::bind_rows()
#> Warning: multi-subject simulation without without 'omega'
#> Warning: multi-subject simulation without without 'omega'
stopifnot(
nrow(ss_typ) == 2L * nrow(cov_grid),
!anyNA(ss_typ$recovered),
# Deterministic: quadrature only. Realised maximum deviation 0.03%.
max(abs(ss_typ$recovered - 1)) < 0.005
)
cat(sprintf(
"typical-value steady-state recovery: %.5f - %.5f over %d covariate/regimen combinations\n",
min(ss_typ$recovered), max(ss_typ$recovered), nrow(ss_typ)
))
#> typical-value steady-state recovery: 0.99995 - 0.99999 over 24 covariate/regimen combinations
nca_summary <- nca_tbl |>
dplyr::group_by(regimen) |>
dplyr::summarise(
n = dplyr::n(),
cmax = median(cmax), tmax = median(tmax),
cmin = median(cmin), cav = median(cav), auclast = median(auclast),
cmin_p05 = quantile(cmin, 0.05), cmin_p95 = quantile(cmin, 0.95),
.groups = "drop"
)
nca_summary |>
dplyr::transmute(
regimen, n,
cmax = round(cmax, 2), tmax = round(tmax, 2),
cmin = round(cmin, 2),
cmin_range = sprintf("%.2f - %.2f", cmin_p05, cmin_p95),
cav = round(cav, 2), auclast = round(auclast, 1)
) |>
dplyr::rename(
"Regimen" = regimen, "N" = n,
"Cmax,ss (mg/L)" = cmax, "Tmax,ss (h)" = tmax,
"Ctrough,ss (mg/L)" = cmin, "Ctrough 5th-95th pctile" = cmin_range,
"Cavg,ss (mg/L)" = cav, "AUC0-tau,ss (mg*h/L)" = auclast
) |>
knitr::kable(caption = "Median steady-state NCA parameters by regimen (PKNCA, final dosing interval after 40 days, 200 virtual patients per arm). Ctrough,ss is PKNCA's cmin over the interval.")| Regimen | N | Cmax,ss (mg/L) | Tmax,ss (h) | Ctrough,ss (mg/L) | Ctrough 5th-95th pctile | Cavg,ss (mg/L) | AUC0-tau,ss (mg*h/L) |
|---|---|---|---|---|---|---|---|
| 600/100 mg q12h | 200 | 5.33 | 2.26 | 2.82 | 2.82 - 2.82 | 4.23 | 50.8 |
| 800/100 mg q24h | 200 | 4.87 | 2.60 | 1.14 | 1.14 - 1.14 | 2.64 | 63.4 |
Comparison against published NCA
The source paper reports no NCA parameters. Table 2
holds only model parameters; there is no Cmax, Tmax, AUC or half-life
anywhere in the paper or its four supplements, and the published
validation is the agreement analysis described above.
nlmixr2lib::ncaComparisonTable() is therefore not used
here, because there is no reference column to put beside the simulated
one.
The only exposure numbers the paper does print are the two trough cut-offs the Groningen protocol uses to trigger TDM follow-up: 1.07 mg/L for once-daily and 2.60 mg/L for twice-daily dosing (Discussion). These are decision thresholds taken from the local protocol, not model outputs and not population predictions, so they are recorded below as orientation rather than gated: an adherent typical patient is expected to sit above them, which is the direction the model gives.
typical_ss <- function() {
# One typical patient per regimen at the Table 1 median covariates, no IIV.
med <- tibble::tibble(SEXF = 0, HT = 173, WT = 72, CRCL = 95)
out <- lapply(seq_len(nrow(regimens)), function(i) {
reg <- regimens[i, ]
last_dose <- reg$ii * (reg$n_dose - 1L)
ev <- dplyr::bind_rows(
med |> tidyr::crossing(time = reg$ii * seq(0L, reg$n_dose - 1L)) |>
dplyr::mutate(id = 1L, amt = reg$amt, evid = 1L, cmt = "depot"),
med |> tidyr::crossing(time = seq(last_dose, last_dose + reg$ii, length.out = 97)) |>
dplyr::mutate(id = 1L, amt = NA_real_, evid = 0L, cmt = "central")
) |>
dplyr::arrange(time, evid)
s <- rxode2::rxSolve(typ, ev, omega = NA, returnType = "data.frame")
s <- s[!is.na(s$Cc), ]
tibble::tibble(
regimen = reg$regimen, cmax = max(s$Cc), ctrough = min(s$Cc),
cutoff = if (reg$ii == 24) 1.07 else 2.60
)
})
dplyr::bind_rows(out)
}
typ_ss <- typical_ss()
typ_ss |>
dplyr::transmute(
regimen,
`Typical Cmax,ss (mg/L)` = round(cmax, 2),
`Typical Ctrough,ss (mg/L)` = round(ctrough, 2),
`Protocol follow-up cut-off (mg/L)` = cutoff,
`Above cut-off` = ctrough > cutoff
) |>
dplyr::rename("Regimen" = regimen) |>
knitr::kable(caption = "Typical-value steady-state troughs at the Table 1 median male covariates, beside the follow-up cut-offs quoted in the Discussion. Orientation only -- the cut-offs are protocol decision thresholds, not published model predictions.")| Regimen | Typical Cmax,ss (mg/L) | Typical Ctrough,ss (mg/L) | Protocol follow-up cut-off (mg/L) | Above cut-off |
|---|---|---|---|---|
| 600/100 mg q12h | 5.07 | 3.15 | 2.60 | TRUE |
| 800/100 mg q24h | 4.34 | 1.44 | 1.07 | TRUE |
# Gated only on being physically sensible and above the 0.2 mg/L assay LLOQ:
# this is a deterministic typical-value quantity, so the bound is tight.
stopifnot(
all(typ_ss$ctrough > 0.2), # the paper's lower limit of quantification
all(typ_ss$cmax > typ_ss$ctrough),
all(typ_ss$cmax < 20) # a mis-scaled volume or dose would blow this
)Assumptions and deviations
Interpretation of the reported “Mean (SD)” pairs
MWPharm’s KinPop reports each population parameter as a
mean with a separate interindividual SD in the parameter’s own units
(Table 2; Supplement 1 and Supplement 3 print the same
value (SD) shape), while the Methods state that
“pharmacokinetic parameters were assumed to be log-normally
distributed”. The packaged model follows this library’s standing
convention for that combination: the printed mean is carried as the
typical value and the printed SD is converted to a log-scale variance by
omega^2 = log(1 + CV^2) with CV = SD / mean,
giving 0.355749 for CLm (CV 65.4%) and 0.912284 for
Vd (CV 122.1%).
The alternative reading is that the printed mean is the
arithmetic mean of the log-normal, in which case the median
would be mean / sqrt(1 + CV^2) – 7.93 rather than 9.47
L/h/70 kg for CLm, and 1.35 rather than 2.13 L/kg LBMc for
Vd. The paper does not say which it is, and MWPharm’s
convention is not documented in it. Two observations in each direction,
recorded so a reader can judge:
-
for the arithmetic-mean reading: it makes Models 1 and 2 of
Table 2 agree on a median
CLm(7.63 vs 7.93 L/h/70 kg) where the median reading has them 18% apart, and it puts the implied medianVd(1.35) within 5% of the Arab-Alameddine literature prior of 1.42 L/kg LBMc that Supplement 1 carries. - for the convention used here: it is what the rest of this library does with a printed mean, it keeps the packaged typical values byte-identical to Table 2 so the source trace is checkable by eye, and it gives a typical terminal half-life of 12.8 h, closer to darunavir’s reported value than the 10.5 h the alternative gives.
A user who wants the other reading can divide each typical value by
sqrt(1 + CV^2) without touching the variances.
The fd = 5 domain bound
Documented at length in its own section above, and repeated here
because it is the deviation most likely to bite. fd = 5
extrapolates the lean-body-mass correction past total body weight, so
LBMc = 5 * WT - 4 * LBM and the volume of distribution is
negative for any subject with
WT <= 0.8 * LBM. That region is reachable inside the
covariate ranges the paper itself reports for its own cohort. This is a
property of the published model, not of the transcription; the virtual
cohort here is restricted to the positive-LBMc region and
the number of draws that restriction removed is printed.
A related point: the Methods sentence “V1 is the volume of
distribution (in liters per 70 kg LBMc)” is inconsistent with Table 2’s
own unit label L/kgLBMc, with the parallel sentence
describing V2 as “in liters per kg LBMc”, and with
dimensional sense – the per-70-kg reading would give a typical volume of
2.6 L. The per-kg reading of Table 2 is used.
fr = 0 makes the CRCL column inert
The published clearance equation is
CL = CLm * (WT / 70) + fr * CLcr, and fr is
fixed at 0 in the final model, so creatinine clearance has no effect on
any prediction. The arm is nevertheless retained in the model file, with
fr encoded as e_crcl_cl_renal <- fixed(0),
because Supplement 4 shows it was tested at the SPC value of 0.12 and
rejected on AIC (1611.08 vs 1584.89) rather than never considered. A
user can restore it by overriding e_crcl_cl_renal; the
mutation control above verifies that doing so changes clearance by
exactly fr * CLcr. The consequence for simulation is that a
CRCL column must be supplied even though it does
nothing.
CKD-EPI needs age and sex as well as serum creatinine, and the paper does not state whether a race coefficient was applied, so a user deriving the column from raw creatinine will have to choose a CKD-EPI variant.
Other assumptions
- Race and ethnicity are not reported in Table 1 and are not covariates, so the virtual cohort does not model them.
-
Height and weight distributions. Table 1 gives
medians and ranges but no SDs or joint distribution. The cohort samples
sex-appropriate heights and a BMI centred on the reported median of 24.6
kg/m^2, then derives weight, so that height and weight stay
physiologically paired – which matters here because the
fd = 5bound is a joint constraint on the two. - Covariates held constant. The source treats weight and serum creatinine as “continuously changing covariates” (Discussion) – that flexibility is the reason the authors chose ITSB over mixed-effects modelling. The cohort here holds them fixed per subject, which is the right choice for a steady-state exposure illustration but does not exercise the time-varying path.
-
Residual error combination. The assay SD is
reported as the linear sum
0.2 + 0.05 * C, which is nlmixr2’scombined1()form rather than the default quadrature combination ofadd()andprop(). Both terms are fixed, because they describe the assay rather than a fitted quantity. -
Only the final model is packaged. Table 2’s Model 1
(AIC 945.31,
CLmBayesian only) is a rejected development submodel: its volume was held at the literature value, which “implies that the volume of distribution … is the same for each patient, which does not seem logical” (Results), and its external validation was markedly worse (r^2 = 0.85 against 0.97; 92.3% of predictions within the limits of agreement against 98.2%). Per the base-versus-final policy the library packages the final model only. The two-compartment model explored in Supplement 3 is likewise not packaged; the paper rejected it. - No published NCA and no reproducible published figure. Discussed in the PKNCA section. Every numeric gate in this vignette is therefore either an algebraic identity of the published equations or a mutation control on one of them, not a comparison against a published exposure number – because the paper contains none.
-
Ka is not identifiable in these data. It is fixed
at the Arab-Alameddine literature value of 1.04 1/h because “the low
number of darunavir samples drawn in the absorption phase; 0-4 hours
after drug intake” made estimation impossible (Discussion). Simulated
Tmaxand the shape of the absorption phase are therefore inherited from that external source, not fitted here, and should not be treated as validated by this paper. - Unmodelled mechanisms the authors name. Food intake, pharmacogenomics and binding to alpha-1-acid glycoprotein are all absent from the model and are listed in the Discussion as candidate explanations for the residual overestimation at low concentrations. Ritonavir is present in every regimen as a fixed-dose booster and is not modelled as a covariate, so the packaged model describes boosted darunavir only.
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.5 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
#> [4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
#> [7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
#> [10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] ggplot2_4.0.3 tidyr_1.3.2 dplyr_1.2.1
#> [4] rxode2_5.1.8 PKNCA_0.12.1 nlmixr2lib_0.3.2.9000
#>
#> loaded via a namespace (and not attached):
#> [1] gtable_0.3.6 xfun_0.61 bslib_0.12.0
#> [4] rxode2lincmt_0.1.0 lattice_0.22-9 vctrs_0.7.3
#> [7] tools_4.6.1 generics_0.1.4 parallel_4.6.1
#> [10] tibble_3.3.1 symengine_0.2.13 pkgconfig_2.0.3
#> [13] data.table_1.18.6.1 checkmate_2.3.4 RColorBrewer_1.1-3
#> [16] S7_0.2.2 desc_1.4.3 lifecycle_1.0.5
#> [19] compiler_4.6.1 farver_2.1.2 textshaping_1.0.5
#> [22] fontawesome_0.5.3 htmltools_0.5.9 sys_3.4.3
#> [25] sass_0.4.10 yaml_2.3.12 pillar_1.11.1
#> [28] pkgdown_2.2.1 crayon_1.5.3 jquerylib_0.1.4
#> [31] whisker_0.4.1 openssl_2.4.2 cachem_1.1.0
#> [34] nlme_3.1-169 tidyselect_1.2.1 digest_0.6.39
#> [37] lotri_1.0.5 purrr_1.2.2 labeling_0.4.3
#> [40] rxode2ll_2.0.18 fastmap_1.2.0 grid_4.6.1
#> [43] cli_3.6.6 dparser_1.3.1-13 magrittr_2.0.5
#> [46] withr_3.0.3 scales_1.4.0 backports_1.5.1
#> [49] rmarkdown_2.32 otel_0.2.0 askpass_1.2.1
#> [52] ragg_1.5.2 memoise_2.0.1 evaluate_1.0.5
#> [55] knitr_1.52 rex_1.2.2 PreciseSums_0.7
#> [58] rlang_1.3.0 downlit_0.4.5 Rcpp_1.1.2
#> [61] glue_1.8.1 xml2_1.6.0 jsonlite_2.0.0
#> [64] R6_2.6.1 systemfonts_1.3.2 fs_2.1.0