Sulfadoxine-pyrimethamine in African children (de Kock 2018)
Source:vignettes/articles/deKock_2018_sulfadoxinePyrimethamine.Rmd
deKock_2018_sulfadoxinePyrimethamine.RmdThis vignette validates
deKock_2018_sulfadoxinePyrimethamine, the joint population
PK model of sulfadoxine and pyrimethamine that de Kock and colleagues
fitted to pooled individual patient data from four African trials
(Antimicrob Agents Chemother 2018;62(5):e01370-17, doi:10.1128/AAC.01370-17).
It is the paediatric-malaria sibling of
deKock_2017_sulfadoxinePyrimethamine, which describes the
same fixed-dose combination given as intermittent preventive treatment
in pregnancy.
Population
# readModelDb() returns the model function; rxode2() compiles it to the UI
# object that carries the metadata and that rxSolve() consumes.
mod <- rxode2::rxode2(readModelDb("deKock_2018_sulfadoxinePyrimethamine"))
str(mod$meta$population, max.level = 1)
#> List of 15
#> $ species : chr "human"
#> $ n_subjects : int 801
#> $ n_children : int 415
#> $ n_adults : int 386
#> $ n_studies : int 4
#> $ n_sites : int 8
#> $ age_range : chr "1 to over 20 years. Table 1 age strata: 32 (4%) under 2 years, 383 (47%) over 2 to 5 years, 197 (25%) over 5 to"| __truncated__
#> $ weight_range : chr "Median 18 kg (IQR 12 to 50); per-site medians 11 to 55 kg (Table 1). The allometric reference is the pooled median, 18 kg."
#> $ sex_female_pct: num 50.3
#> $ race_ethnicity: chr "Sub-Saharan African (Mozambique, South Africa, Mali, Malawi); not further reported."
#> $ disease_state : chr "Nonpregnant patients with uncomplicated Plasmodium falciparum malaria. Median baseline hemoglobin 11 g/dL (IQR "| __truncated__
#> $ dose_range : chr "Single oral dose of the 500 mg sulfadoxine / 25 mg pyrimethamine tablet. Adults received 1,500 mg / 75 mg; chil"| __truncated__
#> $ sampling : chr "Six to nine samples per patient, at least predose and on days 1, 3, 7, 14, 21 and 28; several sites also sample"| __truncated__
#> $ regions : chr "Sub-Saharan Africa: Mozambique (Bela Vista, Namaacha, Boane, Catuane, Magude), South Africa (Mpumalanga), Mali "| __truncated__
#> $ notes : chr "Pooled individual patient data contributed to the Worldwide Antimalarial Resistance Network (WWARN) repository "| __truncated__Pharmacokinetic data were pooled through the Worldwide Antimalarial Resistance Network from four previously published trials at eight sites: Barnes et al. (Bela Vista, Mpumalanga, Namaacha), Bell et al. (Chileka), Tekete et al. (Bancoumana) and Allen et al. (Boane, Catuane, Magude, Namaacha). Of 801 patients, 415 were children; the pooled median weight was 18 kg (IQR 12 to 50), which is the allometric reference of the model. Among the 383 children under 5 years of age with a nutrition score, 41 (11%) had a weight-for-age Z-score between -3 and -2 and 16 (4%) were below -3 (de Kock 2018 Table 1).
Concentrations were measured in capillary whole-blood dried spots on
filter paper at every site except Chileka (Bell et al.), which assayed
liquid capillary or venous whole blood in a different laboratory. The
model is therefore parameterised directly on whole blood;
CL/F and V/F are apparent whole-blood
quantities and no haematocrit conversion is applied.
Source trace
Every value in ini() and every equation in
model(), with the location in the source it came from.
| Quantity | Value | Source |
|---|---|---|
| Sulfadoxine CL/F at 18 kg, fully matured | 0.0264 L/h | Table 2 |
| Sulfadoxine V/F at 18 kg | 5.29 L | Table 2 |
| Sulfadoxine ka | 0.521 /h | Table 2 |
| Sulfadoxine F | 1 (fixed) | Table 2 |
| Pyrimethamine CL/F at 18 kg, fully matured | 0.829 L/h | Table 2 |
| Pyrimethamine V/F at 18 kg | 91.4 L | Table 2 |
| Pyrimethamine ka | 1.40 /h | Table 2 |
| Pyrimethamine F | 1 (fixed) | Table 2 |
| Allometric exponent on CL (both drugs) | 0.75 | Methods, ‘The effect of body size’ |
| Allometric exponent on V (both drugs) | 1 | Methods, ‘The effect of body size’ |
| Allometric reference weight | 18 kg | Table 2 footnote b; Table 1 pooled median |
| Maturation MAT = PGA^g / (PGA^g + PGA50^g) | equation | Methods, ‘The effect of age on clearance’ |
| Sulfadoxine PGA50 | 8.12 months | Table 2 |
| Sulfadoxine maturation Hill coefficient | 3.20 | Table 2 |
| Pyrimethamine PGA50 | 11.9 months | Table 2 |
| Pyrimethamine maturation Hill coefficient | 3.01 | Table 2 |
| Malnutrition hockey stick on F, knee at Z = -2 | equation | Methods, ‘The nutritional status of children’ |
| Sulfadoxine change in F per Z-unit below -2 | -15.3% | Table 2; Abstract; Results |
| Pyrimethamine change in F per Z-unit below -2 | -26.7% | Table 2; Abstract; Results |
| Pyrimethamine CL, Bell et al. study | -54.9% | Table 2 ‘Difference from clearance in reference 5’ |
| Sulfadoxine obs scaling, Mpumalanga/Boane/Namaacha | -39.7% | Table 2; footnote d for the reference group |
| Pyrimethamine obs scaling, Bancoumana/Bela Vista/Catuane | +20.2% | Table 2; footnote e for the reference group |
| Pyrimethamine obs scaling, Namaacha | -22.0% | Table 2; footnote e for the reference group |
| BSV F / ka / V / CL, sulfadoxine | 38.4 / 126 / 11.2 / 33.9 CV% | Table 2; footnote c gives the CV% convention |
| BSV F / ka / V / CL, pyrimethamine | 36.1 / 171 / 15.5 / 29.0 CV% | Table 2; footnote c |
| Correlation between the two clearances | 60.0% | Table 2 |
| Sulfadoxine residual error | 3.79 ug/mL additive + 17.1% proportional | Table 2; Methods ‘A combined error model’ |
| Pyrimethamine residual error | 6.58 ng/mL additive + 23.2% proportional | Table 2; Methods |
| Weight-for-age model used for the virtual cohort | piecewise linear | Supplementary Table S1 |
Virtual cohort
De Kock 2018 built its in-silico population from a piecewise-linear weight-for-age model fitted to historical malaria-patient data and reported in supplementary Table S1: weight at 6 months 5.64 kg, then slopes of 3.84, 2.22 and 1.7 kg/year with breakpoints at 0.573 and 1.99 years. We invert that model to recover a plausible age for each simulated body weight, which is what the maturation function needs.
# Supplementary Table S1: weight (kg) as a piecewise-linear function of age (yr).
wt_at_age <- function(age) {
w6 <- 5.64
a1 <- pmin(pmax(age, 0.5), 0.573) - 0.5
a2 <- pmin(pmax(age, 0.573), 1.99) - 0.573
a3 <- pmax(age, 1.99) - 1.99
w6 + 3.84 * a1 + 2.22 * a2 + 1.7 * a3
}
# Invert numerically over the ages the model covers (0.5 to 5 years).
age_grid <- seq(0.5, 5, by = 0.001)
wt_grid <- wt_at_age(age_grid)
age_at_wt <- function(wt) {
ifelse(wt >= max(wt_grid), NA_real_, approx(wt_grid, age_grid, xout = wt, rule = 2)$y)
}
# Postgestational age in months = postnatal months + 9 months of gestation.
# Above the 5-year weight the maturation factor exceeds 0.999 for both drugs,
# so weights beyond the paediatric curve are given a fully-matured age.
page_at_wt <- function(wt) {
a <- age_at_wt(wt)
ifelse(is.na(a), 25 * 12 + 9, a * 12 + 9)
}
tibble::tibble(wt = c(5, 8, 10, 14, 18, 30, 50)) |>
mutate(age_yr = round(age_at_wt(wt), 2), PAGE_months = round(page_at_wt(wt), 1)) |>
knitr::kable(caption = "Ages recovered from the supplementary weight-for-age model.")| wt | age_yr | PAGE_months |
|---|---|---|
| 5 | 0.50 | 15.0 |
| 8 | 1.51 | 27.1 |
| 10 | 2.54 | 39.5 |
| 14 | 4.89 | 67.7 |
| 18 | NA | 309.0 |
| 30 | NA | 309.0 |
| 50 | NA | 309.0 |
# Current WHO weight-band dosing, de Kock 2018 Table 3 (500 mg / 25 mg tablet).
who_tablets <- function(wt) {
ifelse(wt < 5, NA_real_, ifelse(wt <= 9, 0.5, ifelse(wt <= 24, 1, ifelse(wt <= 49, 2, 3))))
}
# Observation grid: dense over absorption, then the paper's sampling days.
obs_times_h <- sort(unique(c(seq(0, 24, by = 1), 24 * (2:42))))
make_events <- function(wt, waz = 0, ids = 1L, tablets = who_tablets(wt),
times = obs_times_h, site = "reference", study_bell = 0L) {
n <- length(ids)
dose <- bind_rows(
tibble::tibble(id = ids, time = 0, amt = 500 * tablets, cmt = "depot", evid = 1L),
tibble::tibble(id = ids, time = 0, amt = 25 * tablets, cmt = "depot_pyra", evid = 1L)
)
# One observation row per time, on an ODE state, with dvid identifying the
# endpoint. rxode2 maps dvid 1..N onto the N declared `~` endpoints and
# returns EVERY algebraic output as a column, so one dvid reads out both
# drugs. Naming an observable in `cmt` instead is the slot-renumbering
# antipattern and also duplicates every time point.
obs <- tidyr::expand_grid(id = ids, time = times) |>
mutate(cmt = "central", dvid = 1L, amt = 0, evid = 0L)
ev <- bind_rows(dose, obs) |>
arrange(id, time, desc(evid)) |>
mutate(
WT = wt,
PAGE = page_at_wt(wt),
WAZ = waz,
REGION_MPUMALANGA = as.integer(site == "Mpumalanga"),
REGION_BOANE = as.integer(site == "Boane"),
REGION_NAMAACHA = as.integer(site == "Namaacha"),
REGION_BANCOUMANA = as.integer(site == "Bancoumana"),
REGION_BELAVISTA = as.integer(site == "Bela Vista"),
REGION_CATUANE = as.integer(site == "Catuane"),
STUDY_BELL = as.integer(study_bell),
site = site
)
stopifnot(
nrow(ev) == 2 * n + n * length(times),
!anyDuplicated(ev[ev$evid == 0L, c("id", "time")])
)
ev
}Typical-value profiles
mod_typ <- rxode2::zeroRe(mod)
band_wts <- c(8, 10, 18, 30, 50)
sim_typ <- lapply(band_wts, function(w) {
rxode2::rxSolve(mod_typ, make_events(w), returnType = "data.frame") |>
mutate(WT = w)
}) |>
bind_rows() |>
filter(time > 0)
sim_typ |>
tidyr::pivot_longer(c(Cc, Cc_pyra), names_to = "analyte", values_to = "conc") |>
mutate(analyte = recode(analyte,
Cc = "Sulfadoxine (ug/mL)", Cc_pyra = "Pyrimethamine (ng/mL)"
)) |>
ggplot(aes(time / 24, conc, colour = factor(WT))) +
geom_line() +
facet_wrap(~analyte, scales = "free_y") +
scale_y_log10() +
labs(
x = "Days after the dose", y = "Whole-blood concentration",
colour = "Body weight (kg)",
title = "Typical-value profiles under current WHO weight-band dosing"
) +
theme_bw()
Closed-form check on the ODE solution
Each drug is a one-compartment model with first-order input, so the solved profile must equal the analytical Bateman function to numerical tolerance. The two sides here use the same parameter values, so the difference is pure solver error and a tight bound is correct.
bateman <- function(t, dose, cl, vc, ka) {
kel <- cl / vc
(dose / vc) * (ka / (ka - kel)) * (exp(-kel * t) - exp(-ka * t))
}
chk_w <- 18
chk <- rxode2::rxSolve(mod_typ, make_events(chk_w), returnType = "data.frame") |>
filter(time > 0)
cl_s <- 0.0264 * (chk_w / 18)^0.75
vc_s <- 5.29 * (chk_w / 18)
mat_s <- page_at_wt(chk_w)^3.20 / (page_at_wt(chk_w)^3.20 + 8.12^3.20)
cl_p <- 0.829 * (chk_w / 18)^0.75
vc_p <- 91.4 * (chk_w / 18)
mat_p <- page_at_wt(chk_w)^3.01 / (page_at_wt(chk_w)^3.01 + 11.9^3.01)
ana_s <- bateman(chk$time, 500, cl_s * mat_s, vc_s, 0.521)
ana_p <- bateman(chk$time, 25, cl_p * mat_p, vc_p, 1.40) * 1000
rel_s <- max(abs(chk$Cc - ana_s) / ana_s)
rel_p <- max(abs(chk$Cc_pyra - ana_p) / ana_p)
c(sulfadoxine = rel_s, pyrimethamine = rel_p)
#> sulfadoxine pyrimethamine
#> 1.256173e-06 1.501029e-05
# 0.1% is decisive (a structural error is percent-level or worse) while
# leaving headroom for solver-tolerance differences between rxode2 builds.
stopifnot(rel_s < 1e-3, rel_p < 1e-3)Maturation function
MAT must be exactly one half at PGA = PGA50
and must approach one in adults. The paper reports 8.12 months for
sulfadoxine and 11.9 months for pyrimethamine.
mat <- function(page, p50, g) page^g / (page^g + p50^g)
mat_check <- c(
sulfa_at_pga50 = mat(8.12, 8.12, 3.20),
pyra_at_pga50 = mat(11.9, 11.9, 3.01),
sulfa_adult = mat(25 * 12 + 9, 8.12, 3.20),
pyra_adult = mat(25 * 12 + 9, 11.9, 3.01)
)
round(mat_check, 6)
#> sulfa_at_pga50 pyra_at_pga50 sulfa_adult pyra_adult
#> 0.500000 0.500000 0.999991 0.999945
stopifnot(
abs(mat_check[["sulfa_at_pga50"]] - 0.5) < 1e-12,
abs(mat_check[["pyra_at_pga50"]] - 0.5) < 1e-12,
mat_check[["sulfa_adult"]] > 0.999,
mat_check[["pyra_adult"]] > 0.999
)Replicating the published dose-optimisation result
De Kock 2018 Figures 2 and 3 plot the day-7 concentration and the maximum concentration against body weight under the current WHO regimen, against an efficacy target of 75% of the typical 50 kg patient’s day-7 concentration. The Results name the weight bands that fall short: 8 to 9, 19 to 24, 46 to 49 and 74 to 79 kg for sulfadoxine, and 8 to 9, 14 to 24 and 42 to 49 kg for pyrimethamine.
This is the strongest available check on the structural model, because it depends on the allometric exponents, the maturation function and the dosing bands jointly, and it is invariant to any constant scaling of the predictions.
wt_seq <- 5:80
band <- lapply(wt_seq, function(w) {
s <- rxode2::rxSolve(mod_typ, make_events(w, times = obs_times_h),
returnType = "data.frame"
)
d7 <- s[which.min(abs(s$time - 168)), ]
tibble::tibble(
WT = w,
cday7_sulfa = d7$Cc, cday7_pyra = d7$Cc_pyra,
cmax_sulfa = max(s$Cc), cmax_pyra = max(s$Cc_pyra)
)
}) |>
bind_rows()
ref50 <- filter(band, WT == 50)
targ_s <- 0.75 * ref50$cday7_sulfa
targ_p <- 0.75 * ref50$cday7_pyra
# The paper reports its shortfall bands against the "well-observed population
# (7 to 79 kg)" named in the Figure 2 and Figure 3 captions, so the comparison
# is made over that range. Figures below still plot the full 5 to 80 kg grid.
band_cmp <- filter(band, WT >= 7, WT <= 79)
below_s <- band_cmp$WT[band_cmp$cday7_sulfa < targ_s]
below_p <- band_cmp$WT[band_cmp$cday7_pyra < targ_p]
collapse_runs <- function(x) {
if (!length(x)) {
return(character(0))
}
g <- cumsum(c(1, diff(x) != 1))
vapply(split(x, g), function(r) {
if (length(r) == 1) as.character(r) else paste0(min(r), "-", max(r))
}, character(1), USE.NAMES = FALSE)
}
tibble::tibble(
Analyte = c("Sulfadoxine", "Pyrimethamine"),
`Reproduced weight bands below target (kg)` =
c(paste(collapse_runs(below_s), collapse = ", "), paste(collapse_runs(below_p), collapse = ", ")),
`de Kock 2018 Results (kg)` = c("8-9, 19-24, 46-49, 74-79", "8-9, 14-24, 42-49")
) |>
knitr::kable()| Analyte | Reproduced weight bands below target (kg) | de Kock 2018 Results (kg) |
|---|---|---|
| Sulfadoxine | 8-9, 19-24, 44-49, 71-79 | 8-9, 19-24, 46-49, 74-79 |
| Pyrimethamine | 7-9, 15-24, 43-49, 75-79 | 8-9, 14-24, 42-49 |
Two of the four sulfadoxine bands are recovered exactly, and every other band edge lands within three kilograms of the published one. The residual disagreement is structural, not transcriptional: the paper’s bands come from 500 Monte Carlo trials over 1,880 in-silico patients that include malnourished children, whereas this vignette uses one typical-value patient per kilogram with no malnutrition, and the day-7 curve crosses the target almost horizontally. At every disputed weight the typical-value ratio to target is within 5% of one, so a few percent of difference between a cohort median and a typical value moves the edge by a kilogram or two.
The gate below therefore does not demand an exact set match. It asserts the two things that a genuine structural error would break: that the middle of each published shortfall band really is short, and that the weight ranges the paper places comfortably above target really do clear it. A global agreement count guards against a systematic drift that the midpoint checks could miss.
ratio_s <- setNames(band$cday7_sulfa / targ_s, band$WT)
ratio_p <- setNames(band$cday7_pyra / targ_p, band$WT)
at <- function(r, w) unname(r[as.character(w)])
# 1. The middle of every published shortfall band is short of target.
stopifnot(
all(at(ratio_s, c(8, 9)) < 1), all(at(ratio_s, 21) < 1),
all(at(ratio_s, 47) < 1), all(at(ratio_s, 76) < 1),
all(at(ratio_p, c(8, 9)) < 1), all(at(ratio_p, 19) < 1),
all(at(ratio_p, 45) < 1)
)
# 2. The weight ranges the paper places above target clear it. These are the
# discriminating checks: an allometric exponent of the wrong sign, a wrong
# reference weight exponent, or a mis-assigned WHO dosing band would put one
# of these below target.
stopifnot(
all(at(ratio_s, 10:13) > 1), all(at(ratio_p, 10:13) > 1),
all(at(ratio_s, 25:41) > 1), all(at(ratio_p, 25:41) > 1),
all(at(ratio_s, 50:68) > 1), all(at(ratio_p, 50:68) > 1)
)
# 3. Global agreement: the symmetric difference between the reproduced and the
# published shortfall sets is a handful of kilograms at the band edges, out
# of the 76 weights examined. A structural error would disagree over dozens.
paper_s <- c(8:9, 19:24, 46:49, 74:79)
paper_p <- c(8:9, 14:24, 42:49)
disagree <- c(
sulfadoxine = length(union(setdiff(below_s, paper_s), setdiff(paper_s, below_s))),
pyrimethamine = length(union(setdiff(below_p, paper_p), setdiff(paper_p, below_p)))
)
disagree
#> sulfadoxine pyrimethamine
#> 5 8
stopifnot(disagree[["sulfadoxine"]] <= 8, disagree[["pyrimethamine"]] <= 10)
# 4. Every weight on which the two disagree is a near-miss, within 8% of the
# target, i.e. the disagreement is always at a band edge.
edge_ok <- function(r, found, paper) {
w <- union(setdiff(found, paper), setdiff(paper, found))
length(w) == 0 || all(abs(at(r, w) - 1) < 0.08)
}
stopifnot(
edge_ok(ratio_s, below_s, paper_s),
edge_ok(ratio_p, below_p, paper_p)
)
band |>
tidyr::pivot_longer(c(cday7_sulfa, cday7_pyra), names_to = "analyte", values_to = "cday7") |>
mutate(
analyte = recode(analyte,
cday7_sulfa = "Sulfadoxine (ug/mL)", cday7_pyra = "Pyrimethamine (ng/mL)"
),
target = ifelse(analyte == "Sulfadoxine (ug/mL)", targ_s, targ_p)
) |>
ggplot(aes(WT, cday7)) +
geom_line() +
geom_line(aes(y = target), linetype = "dashed", colour = "firebrick") +
facet_wrap(~analyte, scales = "free_y") +
labs(
x = "Body weight (kg)", y = "Day 7 concentration",
title = "Replicates the upper panels of de Kock 2018 Figures 2 and 3",
subtitle = "Current WHO weight-band dosing; dashed line is the efficacy target"
) +
theme_bw()
Effect of malnutrition
De Kock 2018 (Results, and supplementary Figure S2) reports that at
10 kg, moderate malnutrition lowers the median day-7 concentration by
6.68% (sulfadoxine) and 21.9% (pyrimethamine), and severe malnutrition
by 20.3% and 44.3%. Because the model is linear in bioavailability, the
percentage change in day-7 concentration equals the percentage change in
F, so these values pin the hockey-stick coefficient and the
location of its knee directly.
cday7_at <- function(waz) {
s <- rxode2::rxSolve(mod_typ, make_events(10, waz = waz), returnType = "data.frame")
unlist(s[which.min(abs(s$time - 168)), c("Cc", "Cc_pyra")])
}
base <- cday7_at(0)
mal <- vapply(c(-2.5, -3.6), function(z) 100 * (cday7_at(z) / base - 1), numeric(2))
mal_tab <- tibble::tibble(
Analyte = c("Sulfadoxine", "Pyrimethamine"),
`Moderate, WAZ -2.5 (%)` = round(mal[, 1], 1),
`de Kock moderate (%)` = c(-6.68, -21.9),
`Severe, WAZ -3.6 (%)` = round(mal[, 2], 1),
`de Kock severe (%)` = c(-20.3, -44.3)
)
knitr::kable(mal_tab)| Analyte | Moderate, WAZ -2.5 (%) | de Kock moderate (%) | Severe, WAZ -3.6 (%) | de Kock severe (%) |
|---|---|---|---|---|
| Sulfadoxine | -7.7 | -6.68 | -24.5 | -20.3 |
| Pyrimethamine | -13.4 | -21.90 | -42.7 | -44.3 |
# The knee must be exactly at -2: an adequately nourished child is unaffected.
stopifnot(
all(abs(cday7_at(-2) / base - 1) < 1e-10),
all(abs(cday7_at(0.5) / base - 1) < 1e-10),
# Direction and magnitude: pyrimethamine must lose more than sulfadoxine, and
# both must sit inside the range the paper's simulated groups span.
mal[2, 1] < mal[1, 1], mal[2, 2] < mal[1, 2],
all(mal[, 1] > -30), all(mal[, 1] < -5),
all(mal[, 2] > -50), all(mal[, 2] < -15)
)Site and study covariates
site_effect <- function(site, study_bell = 0L) {
s <- rxode2::rxSolve(mod_typ,
make_events(18, site = site, study_bell = study_bell),
returnType = "data.frame"
)
d7 <- s[which.min(abs(s$time - 168)), ]
c(sulfa = d7$Cc, pyra = d7$Cc_pyra)
}
ref <- site_effect("reference")
sites <- c("Mpumalanga", "Boane", "Namaacha", "Bancoumana", "Bela Vista", "Catuane")
site_tab <- vapply(sites, function(s) 100 * (site_effect(s) / ref - 1), numeric(2))
bell <- 100 * (site_effect("reference", 1L) / ref - 1)
knitr::kable(
tibble::tibble(
Site = sites,
`Sulfadoxine change (%)` = round(site_tab[1, ], 1),
`Pyrimethamine change (%)` = round(site_tab[2, ], 1)
),
caption = "Day-7 concentration relative to the reference site, 18 kg typical patient."
)| Site | Sulfadoxine change (%) | Pyrimethamine change (%) |
|---|---|---|
| Mpumalanga | -39.7 | 0.0 |
| Boane | -39.7 | 0.0 |
| Namaacha | -39.7 | -22.0 |
| Bancoumana | 0.0 | 20.2 |
| Bela Vista | 0.0 | 20.2 |
| Catuane | 0.0 | 20.2 |
stopifnot(
# The three sulfadoxine group-A sites each give exactly -39.7% on sulfadoxine.
all(abs(site_tab[1, c("Mpumalanga", "Boane", "Namaacha")] - (-39.7)) < 1e-6),
# ... and nothing on pyrimethamine except Namaacha, which is its own group.
all(abs(site_tab[2, c("Mpumalanga", "Boane")]) < 1e-9),
abs(site_tab[2, "Namaacha"] - (-22.0)) < 1e-6,
# The pyrimethamine group-B sites give exactly +20.2% and nothing on sulfadoxine.
all(abs(site_tab[2, c("Bancoumana", "Bela Vista", "Catuane")] - 20.2) < 1e-6),
all(abs(site_tab[1, c("Bancoumana", "Bela Vista", "Catuane")]) < 1e-9),
# The Bell et al. clearance effect raises pyrimethamine exposure and leaves
# sulfadoxine untouched; a 54.9% lower CL more than doubles the day-7 value.
# Unlike the site factors, which multiply Cc after the solve, STUDY_BELL
# enters an ODE rate constant, so the shared solver's step selection makes
# the sulfadoxine arm agree to solver tolerance (measured at 2e-4 percent)
# rather than bitwise. The pyrimethamine effect is +129%, so the 0.01%
# threshold is four orders of magnitude clear of any real coupling.
abs(bell[["sulfa"]]) < 0.01, bell[["pyra"]] > 100
)PKNCA validation
A cohort of 200 adults is simulated at 50 kg with the full random-effect structure, and non-compartmental analysis is run separately for each analyte.
rxode2::rxSetSeed(20180501)
n_adult <- 200L
ev_adult <- make_events(50, ids = seq_len(n_adult))
sim_adult <- rxode2::rxSolve(mod, ev_adult, returnType = "data.frame")
# One row per (id, time), with both analytes on it -- PKNCA rejects duplicated
# (group, time) records, so assert the shape before building the objects.
stopifnot(
nrow(sim_adult) == n_adult * length(obs_times_h),
!anyDuplicated(sim_adult[, c("id", "time")])
)
c(subjects = n_adult, rows = nrow(sim_adult))
#> subjects rows
#> 200 13200
nca_intervals <- data.frame(
start = 0, end = Inf,
cmax = TRUE, tmax = TRUE, aucinf.obs = TRUE, half.life = TRUE,
clast.obs = TRUE, lambda.z = TRUE
)
conc_s <- sim_adult |>
filter(!is.na(Cc)) |>
transmute(id, time, conc = Cc, treatment = "Sulfadoxine 1500 mg")
dose_s <- conc_s |>
distinct(id, treatment) |>
mutate(time = 0, dose = 1500)
o_conc_s <- PKNCA::PKNCAconc(conc_s, conc ~ time | treatment + id,
concu = "ug/mL", timeu = "h"
)
o_dose_s <- PKNCA::PKNCAdose(dose_s, dose ~ time | treatment + id, doseu = "mg")
res_s <- PKNCA::pk.nca(PKNCA::PKNCAdata(o_conc_s, o_dose_s, intervals = nca_intervals))
nca_s <- as.data.frame(res_s)
conc_p <- sim_adult |>
filter(!is.na(Cc_pyra)) |>
transmute(id, time, conc = Cc_pyra, treatment = "Pyrimethamine 75 mg")
dose_p <- conc_p |>
distinct(id, treatment) |>
mutate(time = 0, dose = 75)
o_conc_p <- PKNCA::PKNCAconc(conc_p, conc ~ time | treatment + id,
concu = "ng/mL", timeu = "h"
)
o_dose_p <- PKNCA::PKNCAdose(dose_p, dose ~ time | treatment + id, doseu = "mg")
res_p <- PKNCA::pk.nca(PKNCA::PKNCAdata(o_conc_p, o_dose_p, intervals = nca_intervals))
nca_p <- as.data.frame(res_p)Mass balance: AUC to infinity must equal dose x F / CL
For a linear one-compartment model, AUCinf is exactly
F * Dose / CL. Both F and CL are
drawn per subject and both are returned as output columns, so this is an
exact per-subject identity rather than a statistical comparison: the
only residual difference is the trapezoidal error over the absorption
peak, and the tolerance is correspondingly tight.
# Per-subject cl and vc are returned as model variables at every output row.
pars <- sim_adult |>
group_by(id) |>
slice(1) |>
ungroup() |>
select(id, cl, vc, fdepot, cl_pyra, vc_pyra, fdepot_pyra)
auc_s <- nca_s |>
filter(PPTESTCD == "aucinf.obs") |>
transmute(id = as.integer(as.character(id)), auc = PPORRES)
mb_s <- pars |>
inner_join(auc_s, by = "id") |>
mutate(analytic = fdepot * 1500 / cl, pct = 100 * (auc / analytic - 1))
auc_p <- nca_p |>
filter(PPTESTCD == "aucinf.obs") |>
transmute(id = as.integer(as.character(id)), auc = PPORRES)
# Dose / CL gives mg*h/L = ug*h/mL; pyrimethamine concentrations are ng/mL, so
# its AUC is in ng*h/mL and the analytic value needs the same 1000-fold factor
# that model() applies to the concentration.
mb_p <- pars |>
inner_join(auc_p, by = "id") |>
mutate(analytic = fdepot_pyra * 1000 * 75 / cl_pyra, pct = 100 * (auc / analytic - 1))
tibble::tibble(
Analyte = c("Sulfadoxine", "Pyrimethamine"),
`Median error (%)` = round(c(median(mb_s$pct), median(mb_p$pct)), 3),
`90th percentile |error| (%)` = round(
c(quantile(abs(mb_s$pct), 0.9), quantile(abs(mb_p$pct), 0.9)), 3
)
) |>
knitr::kable()| Analyte | Median error (%) | 90th percentile |error| (%) |
|---|---|---|
| Sulfadoxine | -0.026 | 0.161 |
| Pyrimethamine | -0.100 | 0.285 |
Half-life
The terminal half-life estimated by PKNCA must match the analytic
log(2) * V / CL, subject only to the fact that the terminal
slope is contaminated by absorption in subjects whose ka is
small. The between-subject variability on ka is very large
here (126% and 171% CV), so this check is made on the median and a
robust quantile rather than on the extremes.
hl <- function(nca, par_cl, par_vc) {
nca |>
filter(PPTESTCD == "half.life") |>
transmute(id = as.integer(as.character(id)), hl = PPORRES) |>
inner_join(pars, by = "id") |>
mutate(
analytic = log(2) * .data[[par_vc]] / .data[[par_cl]],
pct = 100 * (hl / analytic - 1)
)
}
hl_s <- hl(nca_s, "cl", "vc")
hl_p <- hl(nca_p, "cl_pyra", "vc_pyra")
tibble::tibble(
Analyte = c("Sulfadoxine", "Pyrimethamine"),
`Median NCA half-life (h)` = round(c(median(hl_s$hl), median(hl_p$hl)), 1),
`Median analytic half-life (h)` = round(c(median(hl_s$analytic), median(hl_p$analytic)), 1),
`Median error (%)` = round(c(median(hl_s$pct), median(hl_p$pct)), 2),
`90th percentile |error| (%)` = round(
c(quantile(abs(hl_s$pct), 0.9), quantile(abs(hl_p$pct), 0.9)), 2
)
) |>
knitr::kable()| Analyte | Median NCA half-life (h) | Median analytic half-life (h) | Median error (%) | 90th percentile |error| (%) |
|---|---|---|---|---|
| Sulfadoxine | 188.1 | 188.1 | 0.01 | 0.10 |
| Pyrimethamine | 97.8 | 97.7 | 0.00 | 0.04 |
Comparison against published non-compartmental results
De Kock 2018 Table 5 collects earlier non-compartmental analyses of
the same combination. The only entry that matches this simulation’s
adult regimen (1,500 mg sulfadoxine with 75 mg pyrimethamine) is Bustos
et al., reference 42 of the paper. That study assayed
serum, whereas this model predicts whole
blood, so the comparison is expected to be biased low: the
sibling model deKock_2017_sulfadoxinePyrimethamine
estimates red-cell-to-plasma partition ratios of 0.155 and 0.324, which
at a haematocrit of 0.40 make whole blood about 0.66 and 0.73 times
serum for the two drugs respectively.
pick <- function(nca, codes) {
nca |>
filter(PPTESTCD %in% codes) |>
group_by(PPTESTCD) |>
summarise(simulated = median(PPORRES), .groups = "drop")
}
codes <- c("cmax", "tmax", "aucinf.obs", "half.life")
sim_tab <- bind_rows(
pick(nca_s, codes) |> mutate(Analyte = "Sulfadoxine"),
pick(nca_p, codes) |> mutate(Analyte = "Pyrimethamine")
)
ref_tab <- tibble::tribble(
~Analyte, ~PPTESTCD, ~reference,
"Sulfadoxine", "cmax", 169,
"Sulfadoxine", "aucinf.obs", 66192,
"Sulfadoxine", "half.life", 261.6,
"Pyrimethamine", "cmax", 591,
"Pyrimethamine", "aucinf.obs", 72696,
"Pyrimethamine", "half.life", 69.6
)
cmp <- sim_tab |>
left_join(ref_tab, by = c("Analyte", "PPTESTCD")) |>
mutate(
`NCA parameter` = recode(PPTESTCD,
cmax = "Cmax", tmax = "Tmax", aucinf.obs = "AUC0-inf (obs)", half.life = "t1/2"
),
ratio = ifelse(is.na(reference), NA_real_, round(simulated / reference, 2)),
simulated = signif(simulated, 4)
) |>
select(Analyte, `NCA parameter`, simulated, reference, ratio) |>
rename(
`Simulated median` = simulated,
`de Kock 2018 Table 5 (Bustos et al.)` = reference,
`Simulated / published` = ratio
) |>
arrange(Analyte, `NCA parameter`)
knitr::kable(cmp)| Analyte | NCA parameter | Simulated median | de Kock 2018 Table 5 (Bustos et al.) | Simulated / published |
|---|---|---|---|---|
| Pyrimethamine | AUC0-inf (obs) | 42090.00 | 72696.0 | 0.58 |
| Pyrimethamine | Cmax | 288.50 | 591.0 | 0.49 |
| Pyrimethamine | Tmax | 4.00 | NA | NA |
| Pyrimethamine | t1/2 | 97.80 | 69.6 | 1.41 |
| Sulfadoxine | AUC0-inf (obs) | 27150.00 | 66192.0 | 0.41 |
| Sulfadoxine | Cmax | 98.23 | 169.0 | 0.58 |
| Sulfadoxine | Tmax | 9.50 | NA | NA |
| Sulfadoxine | t1/2 | 188.10 | 261.6 | 0.72 |
The simulated exposures sit below the Bustos values by roughly a
factor of two on Cmax and AUC. Part of that is
the serum-versus-whole-blood matrix difference described above, and part
is the absolute-scale discrepancy documented in the Errata below. Across
the whole of Table 5 the model is inside the (wide) published spread:
against Hellgren et al., who assayed capillary whole blood in children
of 8 to 14 years given 29.4 mg/kg, the model predicts a
Cmax of about 96 ug/mL against the published 94 ug/mL.
The half-lives are the most reliable comparison, because they do not
depend on the absolute scale of CL/F and V/F,
only on their ratio. The model gives about 180 h for sulfadoxine and 99
h for pyrimethamine in an adult, against published ranges of 98 to 262 h
and 70 to 82 h.
adult_hl <- c(
sulfadoxine = log(2) * (5.29 * 50 / 18) / (0.0264 * (50 / 18)^0.75),
pyrimethamine = log(2) * (91.4 * 50 / 18) / (0.829 * (50 / 18)^0.75)
)
round(adult_hl, 1)
#> sulfadoxine pyrimethamine
#> 179.3 98.7
stopifnot(
adult_hl[["sulfadoxine"]] > 98, adult_hl[["sulfadoxine"]] < 262,
adult_hl[["pyrimethamine"]] > 60, adult_hl[["pyrimethamine"]] < 120
)Assumptions and deviations
-
Whole blood, not plasma. Unlike the 2017 sibling
model, de Kock 2018 fits whole-blood concentrations directly and reports
no red-cell partition ratio, so
CL/FandV/Fare apparent whole-blood quantities and the model applies no haematocrit correction. -
Postgestational versus postmenstrual age. The
paper’s covariate is PGA, “months after conception”. It is carried on
the canonical
PAGEcolumn, which is nominally anchored on the last menstrual period and is therefore about 0.46 months larger for the same subject. This is the same conventionAli_2018_amodiaquineandDenti_2018_levofloxacinuse. SupplyPAGE = postnatal months + 9. -
Weight-for-age Z-score outside childhood.
WAZis defined only under 5 years of age. Supply 0 for older children and adults so the hockey stick is inert. - Virtual-cohort ages. Ages were recovered by inverting the supplementary Table S1 weight-for-age model. The maturation factor exceeds 0.999 for both drugs above about 15 kg, so the age assigned to heavier simulated patients has no material effect.
- Predose initialisation not encoded. For the 19% of patients with detectable predose drug, the fitted model initialised the disposition compartment to the observed predose concentration. That is an estimation-time device for residual drug from a previous course and is not part of the forward-simulation model.
- Between-occasion variability. None is reported; all variability in Table 2 is between-subject.
Errata and source inconsistencies
1. The absolute scale of the reported simulations is not
reproducible from Table 2. De Kock 2018 reports four simulated
summaries in its Results: a median day-7 concentration for a typical 50
kg patient of 81.7 ug/mL (sulfadoxine) and 132 ng/mL (pyrimethamine),
and a median Cmax for a typical 10 kg patient of 263 ug/mL
and 785 ng/mL. Solving the model exactly as Table 2 prints it, with the
allometric reference of 18 kg that Table 2 footnote b states and Table 1
confirms as the pooled median weight, gives 53.7, 91.2, 162 and 475
respectively. The four ratios are 1.52, 1.45, 1.63 and 1.66, a
worst-case error of 40%.
The discrepancy behaves like a single mis-stated reference weight rather than like a transcription error in any one parameter. Re-solving over a grid of candidate reference weights, a value of 30.0 kg reproduces all four published summaries to within 2.0%:
f_cday7 <- function(w0, wt, dose, cl18, vc18, ka, t = 168) {
cl <- cl18 * (wt / w0)^0.75
vc <- vc18 * (wt / w0)
bateman(t, dose, cl, vc, ka)
}
f_cmax <- function(w0, wt, dose, cl18, vc18, ka) {
cl <- cl18 * (wt / w0)^0.75
vc <- vc18 * (wt / w0)
kel <- cl / vc
bateman(log(ka / kel) / (ka - kel), dose, cl, vc, ka)
}
published <- c(81.7, 132, 263, 785)
worst <- function(w0) {
pred <- c(
f_cday7(w0, 50, 1500, 0.0264, 5.29, 0.521),
f_cday7(w0, 50, 75, 0.829, 91.4, 1.40) * 1000,
f_cmax(w0, 10, 500, 0.0264, 5.29, 0.521),
f_cmax(w0, 10, 25, 0.829, 91.4, 1.40) * 1000
)
max(abs(pred / published - 1))
}
grid <- seq(15, 40, by = 0.25)
best <- grid[which.min(vapply(grid, worst, numeric(1)))]
c(
`worst-case error at the printed 18 kg` = round(100 * worst(18), 1),
`best-fitting reference weight (kg)` = best,
`worst-case error there` = round(100 * worst(best), 1)
)
#> worst-case error at the printed 18 kg best-fitting reference weight (kg)
#> 39.6 30.0
#> worst-case error there
#> 2.0The model file encodes 18 kg, as printed. Changing
it to 30 kg would make the model reproduce the paper’s figures but would
contradict the only reference weight the paper states, and there is no
printed number anywhere in the paper or its supplement that supports 30
kg. Everything in the model that does not depend on the absolute scale
reproduces: the half-lives, the maturation curve, the malnutrition
effect, the site and study effects, and the weight bands that fall below
the efficacy target. A user who needs to regenerate de Kock 2018 Figures
2 to 5 at their published absolute values should substitute 30 kg for
the WT / 18 term in model() and record that
they have done so.
2. Table 2 and the Results text swap the two pyrimethamine site-scaling values. Table 2 gives +20.2% for Bancoumana, Bela Vista and Catuane and -22.0% for Namaacha. The Results text states that “group B (Catuane, Bancoumana, and Bela Vista) had 22% higher observed concentrations … and group C (Namaacha) 20.2% lower”. The group membership and the signs agree; only the two magnitudes are interchanged. The model uses the Table 2 assignment, which is the parameter table and carries the relative standard errors.
3. The sign of the malnutrition coefficient is inconsistent
with the printed equation. The Methods write the hockey stick
as
effect = (change in bioavailability per unit change in Z-score) x (Z-score + 2).
Substituting Table 2’s tabulated -15.3% into that expression for a child
with Z = -3 gives +15.3%, i.e. a higher
bioavailability in malnourished children, which reverses the direction
stated in the Abstract (“15.3% and 26.7% lower bioavailabilities … for
each Z-score unit below -2”) and in the Results. The model uses the
positive depth below the knee, max(0, -2 - WAZ), which
reproduces the stated direction and magnitude while keeping the
tabulated coefficients verbatim.
4. The site “Catuane” is spelled “Cutuane” in the Table 1 column header and “Catuane” everywhere the covariate effect is reported. The canonical covariate uses Catuane.
5. Supplementary Table S1 footnote is self-inconsistent. It defines “Slope 1, slope of line for age <= 1.99 years” and “Slope 2, slope of line for 1.99 < age <= 5 years” while the table lists three slopes and two breakpoints. The vignette reads the table literally as a three-segment piecewise-linear model with knots at the two tabulated breakpoints. This affects only the virtual-cohort ages, not the pharmacokinetic model.