Skip to contents

Model and source

  • Citation: Garcia-Hervalejo M, Sanchez-Hernandez JG, Conde-Gonzalez I, Avendano Pita A, Otero MJ. Population Pharmacokinetics and Model-Informed Dose Optimization of Teicoplanin in Adults with Hematological Malignancies. Pharmaceutics. 2026;18(1):100. doi:10.3390/pharmaceutics18010100
  • Description: One-compartment IV-infusion population PK model for teicoplanin in 100 adult patients with haematological malignancies treated for febrile neutropenia at a single Spanish centre (Garcia-Hervalejo 2026). Clearance carries three covariates, all printed in the paper’s Equation 1: CL (L/h) = 1.28 * (1 + 0.012 * (AGE - 62)) * (eGFR / 92.15)^0.35 * (IBW / 61)^3.2. IDEAL body weight – not total, adjusted, BMI or BSA – was the anthropometric descriptor that best explained clearance variability, and its exponent of 3.2 is far steeper than any allometric value: clearance spans 0.33 to 3.05 L/h across IBW 40 to 80 kg at otherwise typical covariates, a 9-fold range that dominates the model and drives every dosing conclusion the paper reaches. Between-subject variability is exponential on CL and Vc; residual error is purely additive at 2.61 mg/L, the paper having tested and rejected proportional and combined forms because the observed concentrations are almost all troughs over a narrow range. The dataset is trough-only therapeutic drug monitoring, so a two-compartment model was evaluated but not identifiable (dOFV about 0.2, distribution-parameter RSE over 100%) and the parsimonious one-compartment structure was retained. The paper’s Monte Carlo simulations conclude that conventional regimens (6 mg/kg or 600 mg q12h x 3 then once daily) rarely reach a trough of 15-20 mg/L, and propose an intensified five-dose 12 mg/kg q12h loading phase followed by 12 mg/kg once daily.
  • Article: https://doi.org/10.3390/pharmaceutics18010100

Teicoplanin is a glycopeptide used empirically and as targeted therapy against resistant Gram-positive organisms in febrile neutropenia. Garcia-Hervalejo and colleagues fitted a one-compartment model with first-order elimination to routine therapeutic-drug-monitoring data from 100 adults with haematological malignancies at the University Hospital of Salamanca, then used it to argue that conventional dosing does not reach the 15-20 mg/L trough target quickly enough in this population.

The single most consequential feature of the model is the ideal-body-weight exponent of 3.2 on clearance. That is not an allometric exponent – it is over four times the classical 0.75 – and it makes clearance vary roughly nine-fold across the ideal body weights the paper simulates. Every dosing conclusion in the paper follows from it, so most of this vignette is spent checking that the packaged encoding reproduces those conclusions.

Population

The development cohort was 100 hospitalised adults (40% female, median age 62 years, range 18-85) with malignant haematological neoplasms receiving intravenous teicoplanin for febrile neutropenia between February 2021 and December 2023 (source Table 1). Median total body weight was 68 kg (41-127) and median Devine ideal body weight 61.4 kg (46.25-77). Renal function was preserved to augmented – median CKD-EPI eGFR 92.15 mL/min/1.73 m^2 (26-141) – with end-stage renal disease and renal replacement therapy excluded by design. The population shows the biochemical signature of the setting: hypoalbuminaemia (median 3.3 g/dL), anaemia (median haemoglobin 9.2 g/dL) and profound thrombocytopenia (median platelets 36,500/uL). Diagnoses were non-Hodgkin’s lymphoma 20%, acute myeloblastic leukaemia 20%, multiple myeloma 20%, acute lymphoblastic leukaemia 9% and other haematological malignancies 31%.

All patients started on 600 mg every 12 h by 30-minute intravenous infusion, with later doses individualised by therapeutic drug monitoring. Sampling was trough-dominated: the first sample at 24 h, then every 72-96 h, “always immediately before dose administration”. This is why the model is one-compartment – with no sample in the distribution phase a two-compartment model was not identifiable (dOFV about 0.2, distribution-parameter RSE over 100%) – and it is the main constraint on reuse. The model describes troughs and cumulative exposure; it should not be used to predict peak or early post-infusion concentrations. A further 51 patients contributing 95 concentrations formed an external validation cohort.

The same information is available programmatically via the model’s population metadata (readModelDb("GarciaHervalejo_2026_teicoplanin")()$population).

Source trace

The per-parameter origin is recorded as an in-file comment next to each ini() entry in inst/modeldb/specificDrugs/GarciaHervalejo_2026_teicoplanin.R. The table below collects them in one place for review.

Equation / parameter Value Source location
lcl (CL reference) 1.28 L/h Table 2, row CLpop (L/h); leading constant of Equation 1
lvc (Vd) 92.10 L Table 2, row Vpop (L)
e_ibw_cl 3.20 Table 2, row IBW-CL; (IBW/61) exponent of Equation 1
e_crcl_cl 0.35 Table 2, row eGFR-CL; (eGFR/92.15) exponent of Equation 1
e_age_cl 0.012 /year Equation 1 (Table 2 prints 0.01 at two-decimal precision – see Errata)
etalcl 0.110003 Table 2, IIVCL (CV, %) = 34.10 -> log(1 + 0.341^2)
etalvc 0.091758 Table 2, IIVV (CV, %) = 31.0 -> log(1 + 0.310^2)
addSd 2.61 mg/L Table 2, row RUVadi (mg/L)
CL covariate model n/a Equation 1, Results 3.2; restated in Results 3.2 prose
One compartment, first-order elimination n/a Results 3.2; Methods 2.3
Additive-only residual error n/a Results 3.2 (proportional and combined forms tested and rejected)
IBW reference 61 kg, eGFR reference 92.15, age centring 62 y n/a Equation 1; cohort medians in Table 1

Equation 1 as printed in the source:

CL(L/h)=1.28×(1+0.012×(AGE62))×(eGFR92.15)0.35×(IBW61)3.2 CL\ (\mathrm{L/h}) = 1.28 \times \bigl(1 + 0.012 \times (AGE - 62)\bigr) \times \left(\frac{eGFR}{92.15}\right)^{0.35} \times \left(\frac{IBW}{61}\right)^{3.2}

Structural gate: the packaged encoding against the printed equation

Before any simulation, check that the packaged model computes the clearance the paper printed. cl_published() below is a literal, independent transcription of Equation 1 from the source’s own constants – it does not read anything from the model file – so a disagreement means the encoding is wrong.

mod <- readModelDb("GarciaHervalejo_2026_teicoplanin")

# Independent transcription of Garcia-Hervalejo 2026 Equation 1.
cl_published <- function(IBW, eGFR, AGE) {
  1.28 * (1 + 0.012 * (AGE - 62)) * (eGFR / 92.15)^0.35 * (IBW / 61)^3.2
}
V_published <- 92.10

# A grid spanning the observed covariate ranges (Table 1).
grid <- tidyr::crossing(
  IBW  = c(46.25, 55, 61, 70, 77),
  CRCL = c(26, 60, 92.15, 120, 141),
  AGE  = c(18, 40, 62, 75, 85)
) |>
  dplyr::mutate(id = dplyr::row_number())

gate_ev <- grid |>
  dplyr::mutate(time = 0, amt = 600, evid = 1, cmt = "central", dur = 0.5)

gate <- rxode2::rxSolve(
  rxode2::zeroRe(mod), gate_ev,
  keep = c("IBW", "CRCL", "AGE")
) |>
  as.data.frame() |>
  dplyr::distinct(id, .keep_all = TRUE) |>
  dplyr::mutate(cl_ref = cl_published(IBW, CRCL, AGE))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> Warning: multi-subject simulation without without 'omega'
#> Warning: column 'IBW' has only 'NA' values for id '1'
#> Warning: column 'CRCL' has only 'NA' values for id '1'
#> Warning: column 'AGE' has only 'NA' values for id '1'
#> Warning: column 'IBW' has only 'NA' values for id '2'
#> Warning: column 'CRCL' has only 'NA' values for id '2'
#> Warning: column 'AGE' has only 'NA' values for id '2'
#> Warning: column 'IBW' has only 'NA' values for id '3'
#> Warning: column 'CRCL' has only 'NA' values for id '3'
#> Warning: column 'AGE' has only 'NA' values for id '3'
#> Warning: column 'IBW' has only 'NA' values for id '4'
#> Warning: column 'CRCL' has only 'NA' values for id '4'
#> Warning: column 'AGE' has only 'NA' values for id '4'
#> Warning: column 'IBW' has only 'NA' values for id '5'
#> Warning: column 'CRCL' has only 'NA' values for id '5'
#> Warning: column 'AGE' has only 'NA' values for id '5'
#> Warning: column 'IBW' has only 'NA' values for id '6'
#> Warning: column 'CRCL' has only 'NA' values for id '6'
#> Warning: column 'AGE' has only 'NA' values for id '6'
#> Warning: column 'IBW' has only 'NA' values for id '7'
#> Warning: column 'CRCL' has only 'NA' values for id '7'
#> Warning: column 'AGE' has only 'NA' values for id '7'
#> Warning: column 'IBW' has only 'NA' values for id '8'
#> Warning: column 'CRCL' has only 'NA' values for id '8'
#> Warning: column 'AGE' has only 'NA' values for id '8'
#> Warning: column 'IBW' has only 'NA' values for id '9'
#> Warning: column 'CRCL' has only 'NA' values for id '9'
#> Warning: column 'AGE' has only 'NA' values for id '9'
#> Warning: column 'IBW' has only 'NA' values for id '10'
#> Warning: column 'CRCL' has only 'NA' values for id '10'
#> Warning: column 'AGE' has only 'NA' values for id '10'
#> Warning: column 'IBW' has only 'NA' values for id '11'
#> Warning: column 'CRCL' has only 'NA' values for id '11'
#> Warning: column 'AGE' has only 'NA' values for id '11'
#> Warning: column 'IBW' has only 'NA' values for id '12'
#> Warning: column 'CRCL' has only 'NA' values for id '12'
#> Warning: column 'AGE' has only 'NA' values for id '12'
#> Warning: column 'IBW' has only 'NA' values for id '13'
#> Warning: column 'CRCL' has only 'NA' values for id '13'
#> Warning: column 'AGE' has only 'NA' values for id '13'
#> Warning: column 'IBW' has only 'NA' values for id '14'
#> Warning: column 'CRCL' has only 'NA' values for id '14'
#> Warning: column 'AGE' has only 'NA' values for id '14'
#> Warning: column 'IBW' has only 'NA' values for id '15'
#> Warning: column 'CRCL' has only 'NA' values for id '15'
#> Warning: column 'AGE' has only 'NA' values for id '15'
#> Warning: column 'IBW' has only 'NA' values for id '16'
#> Warning: column 'CRCL' has only 'NA' values for id '16'
#> Warning: column 'AGE' has only 'NA' values for id '16'
#> Warning: column 'IBW' has only 'NA' values for id '17'
#> Warning: column 'CRCL' has only 'NA' values for id '17'
#> Warning: column 'AGE' has only 'NA' values for id '17'
#> Warning: column 'IBW' has only 'NA' values for id '18'
#> Warning: column 'CRCL' has only 'NA' values for id '18'
#> Warning: column 'AGE' has only 'NA' values for id '18'
#> Warning: column 'IBW' has only 'NA' values for id '19'
#> Warning: column 'CRCL' has only 'NA' values for id '19'
#> Warning: column 'AGE' has only 'NA' values for id '19'
#> Warning: column 'IBW' has only 'NA' values for id '20'
#> Warning: column 'CRCL' has only 'NA' values for id '20'
#> Warning: column 'AGE' has only 'NA' values for id '20'
#> Warning: column 'IBW' has only 'NA' values for id '21'
#> Warning: column 'CRCL' has only 'NA' values for id '21'
#> Warning: column 'AGE' has only 'NA' values for id '21'
#> Warning: column 'IBW' has only 'NA' values for id '22'
#> Warning: column 'CRCL' has only 'NA' values for id '22'
#> Warning: column 'AGE' has only 'NA' values for id '22'
#> Warning: column 'IBW' has only 'NA' values for id '23'
#> Warning: column 'CRCL' has only 'NA' values for id '23'
#> Warning: column 'AGE' has only 'NA' values for id '23'
#> Warning: column 'IBW' has only 'NA' values for id '24'
#> Warning: column 'CRCL' has only 'NA' values for id '24'
#> Warning: column 'AGE' has only 'NA' values for id '24'
#> Warning: column 'IBW' has only 'NA' values for id '25'
#> Warning: column 'CRCL' has only 'NA' values for id '25'
#> Warning: column 'AGE' has only 'NA' values for id '25'
#> Warning: column 'IBW' has only 'NA' values for id '26'
#> Warning: column 'CRCL' has only 'NA' values for id '26'
#> Warning: column 'AGE' has only 'NA' values for id '26'
#> Warning: column 'IBW' has only 'NA' values for id '27'
#> Warning: column 'CRCL' has only 'NA' values for id '27'
#> Warning: column 'AGE' has only 'NA' values for id '27'
#> Warning: column 'IBW' has only 'NA' values for id '28'
#> Warning: column 'CRCL' has only 'NA' values for id '28'
#> Warning: column 'AGE' has only 'NA' values for id '28'
#> Warning: column 'IBW' has only 'NA' values for id '29'
#> Warning: column 'CRCL' has only 'NA' values for id '29'
#> Warning: column 'AGE' has only 'NA' values for id '29'
#> Warning: column 'IBW' has only 'NA' values for id '30'
#> Warning: column 'CRCL' has only 'NA' values for id '30'
#> Warning: column 'AGE' has only 'NA' values for id '30'
#> Warning: column 'IBW' has only 'NA' values for id '31'
#> Warning: column 'CRCL' has only 'NA' values for id '31'
#> Warning: column 'AGE' has only 'NA' values for id '31'
#> Warning: column 'IBW' has only 'NA' values for id '32'
#> Warning: column 'CRCL' has only 'NA' values for id '32'
#> Warning: column 'AGE' has only 'NA' values for id '32'
#> Warning: column 'IBW' has only 'NA' values for id '33'
#> Warning: column 'CRCL' has only 'NA' values for id '33'
#> Warning: column 'AGE' has only 'NA' values for id '33'
#> Warning: column 'IBW' has only 'NA' values for id '34'
#> Warning: column 'CRCL' has only 'NA' values for id '34'
#> Warning: column 'AGE' has only 'NA' values for id '34'
#> Warning: column 'IBW' has only 'NA' values for id '35'
#> Warning: column 'CRCL' has only 'NA' values for id '35'
#> Warning: column 'AGE' has only 'NA' values for id '35'
#> Warning: column 'IBW' has only 'NA' values for id '36'
#> Warning: column 'CRCL' has only 'NA' values for id '36'
#> Warning: column 'AGE' has only 'NA' values for id '36'
#> Warning: column 'IBW' has only 'NA' values for id '37'
#> Warning: column 'CRCL' has only 'NA' values for id '37'
#> Warning: column 'AGE' has only 'NA' values for id '37'
#> Warning: column 'IBW' has only 'NA' values for id '38'
#> Warning: column 'CRCL' has only 'NA' values for id '38'
#> Warning: column 'AGE' has only 'NA' values for id '38'
#> Warning: column 'IBW' has only 'NA' values for id '39'
#> Warning: column 'CRCL' has only 'NA' values for id '39'
#> Warning: column 'AGE' has only 'NA' values for id '39'
#> Warning: column 'IBW' has only 'NA' values for id '40'
#> Warning: column 'CRCL' has only 'NA' values for id '40'
#> Warning: column 'AGE' has only 'NA' values for id '40'
#> Warning: column 'IBW' has only 'NA' values for id '41'
#> Warning: column 'CRCL' has only 'NA' values for id '41'
#> Warning: column 'AGE' has only 'NA' values for id '41'
#> Warning: column 'IBW' has only 'NA' values for id '42'
#> Warning: column 'CRCL' has only 'NA' values for id '42'
#> Warning: column 'AGE' has only 'NA' values for id '42'
#> Warning: column 'IBW' has only 'NA' values for id '43'
#> Warning: column 'CRCL' has only 'NA' values for id '43'
#> Warning: column 'AGE' has only 'NA' values for id '43'
#> Warning: column 'IBW' has only 'NA' values for id '44'
#> Warning: column 'CRCL' has only 'NA' values for id '44'
#> Warning: column 'AGE' has only 'NA' values for id '44'
#> Warning: column 'IBW' has only 'NA' values for id '45'
#> Warning: column 'CRCL' has only 'NA' values for id '45'
#> Warning: column 'AGE' has only 'NA' values for id '45'
#> Warning: column 'IBW' has only 'NA' values for id '46'
#> Warning: column 'CRCL' has only 'NA' values for id '46'
#> Warning: column 'AGE' has only 'NA' values for id '46'
#> Warning: column 'IBW' has only 'NA' values for id '47'
#> Warning: column 'CRCL' has only 'NA' values for id '47'
#> Warning: column 'AGE' has only 'NA' values for id '47'
#> Warning: column 'IBW' has only 'NA' values for id '48'
#> Warning: column 'CRCL' has only 'NA' values for id '48'
#> Warning: column 'AGE' has only 'NA' values for id '48'
#> Warning: column 'IBW' has only 'NA' values for id '49'
#> Warning: column 'CRCL' has only 'NA' values for id '49'
#> Warning: column 'AGE' has only 'NA' values for id '49'
#> Warning: column 'IBW' has only 'NA' values for id '50'
#> Warning: column 'CRCL' has only 'NA' values for id '50'
#> Warning: column 'AGE' has only 'NA' values for id '50'
#> Warning: column 'IBW' has only 'NA' values for id '51'
#> Warning: column 'CRCL' has only 'NA' values for id '51'
#> Warning: column 'AGE' has only 'NA' values for id '51'
#> Warning: column 'IBW' has only 'NA' values for id '52'
#> Warning: column 'CRCL' has only 'NA' values for id '52'
#> Warning: column 'AGE' has only 'NA' values for id '52'
#> Warning: column 'IBW' has only 'NA' values for id '53'
#> Warning: column 'CRCL' has only 'NA' values for id '53'
#> Warning: column 'AGE' has only 'NA' values for id '53'
#> Warning: column 'IBW' has only 'NA' values for id '54'
#> Warning: column 'CRCL' has only 'NA' values for id '54'
#> Warning: column 'AGE' has only 'NA' values for id '54'
#> Warning: column 'IBW' has only 'NA' values for id '55'
#> Warning: column 'CRCL' has only 'NA' values for id '55'
#> Warning: column 'AGE' has only 'NA' values for id '55'
#> Warning: column 'IBW' has only 'NA' values for id '56'
#> Warning: column 'CRCL' has only 'NA' values for id '56'
#> Warning: column 'AGE' has only 'NA' values for id '56'
#> Warning: column 'IBW' has only 'NA' values for id '57'
#> Warning: column 'CRCL' has only 'NA' values for id '57'
#> Warning: column 'AGE' has only 'NA' values for id '57'
#> Warning: column 'IBW' has only 'NA' values for id '58'
#> Warning: column 'CRCL' has only 'NA' values for id '58'
#> Warning: column 'AGE' has only 'NA' values for id '58'
#> Warning: column 'IBW' has only 'NA' values for id '59'
#> Warning: column 'CRCL' has only 'NA' values for id '59'
#> Warning: column 'AGE' has only 'NA' values for id '59'
#> Warning: column 'IBW' has only 'NA' values for id '60'
#> Warning: column 'CRCL' has only 'NA' values for id '60'
#> Warning: column 'AGE' has only 'NA' values for id '60'
#> Warning: column 'IBW' has only 'NA' values for id '61'
#> Warning: column 'CRCL' has only 'NA' values for id '61'
#> Warning: column 'AGE' has only 'NA' values for id '61'
#> Warning: column 'IBW' has only 'NA' values for id '62'
#> Warning: column 'CRCL' has only 'NA' values for id '62'
#> Warning: column 'AGE' has only 'NA' values for id '62'
#> Warning: column 'IBW' has only 'NA' values for id '63'
#> Warning: column 'CRCL' has only 'NA' values for id '63'
#> Warning: column 'AGE' has only 'NA' values for id '63'
#> Warning: column 'IBW' has only 'NA' values for id '64'
#> Warning: column 'CRCL' has only 'NA' values for id '64'
#> Warning: column 'AGE' has only 'NA' values for id '64'
#> Warning: column 'IBW' has only 'NA' values for id '65'
#> Warning: column 'CRCL' has only 'NA' values for id '65'
#> Warning: column 'AGE' has only 'NA' values for id '65'
#> Warning: column 'IBW' has only 'NA' values for id '66'
#> Warning: column 'CRCL' has only 'NA' values for id '66'
#> Warning: column 'AGE' has only 'NA' values for id '66'
#> Warning: column 'IBW' has only 'NA' values for id '67'
#> Warning: column 'CRCL' has only 'NA' values for id '67'
#> Warning: column 'AGE' has only 'NA' values for id '67'
#> Warning: column 'IBW' has only 'NA' values for id '68'
#> Warning: column 'CRCL' has only 'NA' values for id '68'
#> Warning: column 'AGE' has only 'NA' values for id '68'
#> Warning: column 'IBW' has only 'NA' values for id '69'
#> Warning: column 'CRCL' has only 'NA' values for id '69'
#> Warning: column 'AGE' has only 'NA' values for id '69'
#> Warning: column 'IBW' has only 'NA' values for id '70'
#> Warning: column 'CRCL' has only 'NA' values for id '70'
#> Warning: column 'AGE' has only 'NA' values for id '70'
#> Warning: column 'IBW' has only 'NA' values for id '71'
#> Warning: column 'CRCL' has only 'NA' values for id '71'
#> Warning: column 'AGE' has only 'NA' values for id '71'
#> Warning: column 'IBW' has only 'NA' values for id '72'
#> Warning: column 'CRCL' has only 'NA' values for id '72'
#> Warning: column 'AGE' has only 'NA' values for id '72'
#> Warning: column 'IBW' has only 'NA' values for id '73'
#> Warning: column 'CRCL' has only 'NA' values for id '73'
#> Warning: column 'AGE' has only 'NA' values for id '73'
#> Warning: column 'IBW' has only 'NA' values for id '74'
#> Warning: column 'CRCL' has only 'NA' values for id '74'
#> Warning: column 'AGE' has only 'NA' values for id '74'
#> Warning: column 'IBW' has only 'NA' values for id '75'
#> Warning: column 'CRCL' has only 'NA' values for id '75'
#> Warning: column 'AGE' has only 'NA' values for id '75'
#> Warning: column 'IBW' has only 'NA' values for id '76'
#> Warning: column 'CRCL' has only 'NA' values for id '76'
#> Warning: column 'AGE' has only 'NA' values for id '76'
#> Warning: column 'IBW' has only 'NA' values for id '77'
#> Warning: column 'CRCL' has only 'NA' values for id '77'
#> Warning: column 'AGE' has only 'NA' values for id '77'
#> Warning: column 'IBW' has only 'NA' values for id '78'
#> Warning: column 'CRCL' has only 'NA' values for id '78'
#> Warning: column 'AGE' has only 'NA' values for id '78'
#> Warning: column 'IBW' has only 'NA' values for id '79'
#> Warning: column 'CRCL' has only 'NA' values for id '79'
#> Warning: column 'AGE' has only 'NA' values for id '79'
#> Warning: column 'IBW' has only 'NA' values for id '80'
#> Warning: column 'CRCL' has only 'NA' values for id '80'
#> Warning: column 'AGE' has only 'NA' values for id '80'
#> Warning: column 'IBW' has only 'NA' values for id '81'
#> Warning: column 'CRCL' has only 'NA' values for id '81'
#> Warning: column 'AGE' has only 'NA' values for id '81'
#> Warning: column 'IBW' has only 'NA' values for id '82'
#> Warning: column 'CRCL' has only 'NA' values for id '82'
#> Warning: column 'AGE' has only 'NA' values for id '82'
#> Warning: column 'IBW' has only 'NA' values for id '83'
#> Warning: column 'CRCL' has only 'NA' values for id '83'
#> Warning: column 'AGE' has only 'NA' values for id '83'
#> Warning: column 'IBW' has only 'NA' values for id '84'
#> Warning: column 'CRCL' has only 'NA' values for id '84'
#> Warning: column 'AGE' has only 'NA' values for id '84'
#> Warning: column 'IBW' has only 'NA' values for id '85'
#> Warning: column 'CRCL' has only 'NA' values for id '85'
#> Warning: column 'AGE' has only 'NA' values for id '85'
#> Warning: column 'IBW' has only 'NA' values for id '86'
#> Warning: column 'CRCL' has only 'NA' values for id '86'
#> Warning: column 'AGE' has only 'NA' values for id '86'
#> Warning: column 'IBW' has only 'NA' values for id '87'
#> Warning: column 'CRCL' has only 'NA' values for id '87'
#> Warning: column 'AGE' has only 'NA' values for id '87'
#> Warning: column 'IBW' has only 'NA' values for id '88'
#> Warning: column 'CRCL' has only 'NA' values for id '88'
#> Warning: column 'AGE' has only 'NA' values for id '88'
#> Warning: column 'IBW' has only 'NA' values for id '89'
#> Warning: column 'CRCL' has only 'NA' values for id '89'
#> Warning: column 'AGE' has only 'NA' values for id '89'
#> Warning: column 'IBW' has only 'NA' values for id '90'
#> Warning: column 'CRCL' has only 'NA' values for id '90'
#> Warning: column 'AGE' has only 'NA' values for id '90'
#> Warning: column 'IBW' has only 'NA' values for id '91'
#> Warning: column 'CRCL' has only 'NA' values for id '91'
#> Warning: column 'AGE' has only 'NA' values for id '91'
#> Warning: column 'IBW' has only 'NA' values for id '92'
#> Warning: column 'CRCL' has only 'NA' values for id '92'
#> Warning: column 'AGE' has only 'NA' values for id '92'
#> Warning: column 'IBW' has only 'NA' values for id '93'
#> Warning: column 'CRCL' has only 'NA' values for id '93'
#> Warning: column 'AGE' has only 'NA' values for id '93'
#> Warning: column 'IBW' has only 'NA' values for id '94'
#> Warning: column 'CRCL' has only 'NA' values for id '94'
#> Warning: column 'AGE' has only 'NA' values for id '94'
#> Warning: column 'IBW' has only 'NA' values for id '95'
#> Warning: column 'CRCL' has only 'NA' values for id '95'
#> Warning: column 'AGE' has only 'NA' values for id '95'
#> Warning: column 'IBW' has only 'NA' values for id '96'
#> Warning: column 'CRCL' has only 'NA' values for id '96'
#> Warning: column 'AGE' has only 'NA' values for id '96'
#> Warning: column 'IBW' has only 'NA' values for id '97'
#> Warning: column 'CRCL' has only 'NA' values for id '97'
#> Warning: column 'AGE' has only 'NA' values for id '97'
#> Warning: column 'IBW' has only 'NA' values for id '98'
#> Warning: column 'CRCL' has only 'NA' values for id '98'
#> Warning: column 'AGE' has only 'NA' values for id '98'
#> Warning: column 'IBW' has only 'NA' values for id '99'
#> Warning: column 'CRCL' has only 'NA' values for id '99'
#> Warning: column 'AGE' has only 'NA' values for id '99'
#> Warning: column 'IBW' has only 'NA' values for id '100'
#> Warning: column 'CRCL' has only 'NA' values for id '100'
#> Warning: column 'AGE' has only 'NA' values for id '100'
#> Warning: column 'IBW' has only 'NA' values for id '101'
#> Warning: column 'CRCL' has only 'NA' values for id '101'
#> Warning: column 'AGE' has only 'NA' values for id '101'
#> Warning: column 'IBW' has only 'NA' values for id '102'
#> Warning: column 'CRCL' has only 'NA' values for id '102'
#> Warning: column 'AGE' has only 'NA' values for id '102'
#> Warning: column 'IBW' has only 'NA' values for id '103'
#> Warning: column 'CRCL' has only 'NA' values for id '103'
#> Warning: column 'AGE' has only 'NA' values for id '103'
#> Warning: column 'IBW' has only 'NA' values for id '104'
#> Warning: column 'CRCL' has only 'NA' values for id '104'
#> Warning: column 'AGE' has only 'NA' values for id '104'
#> Warning: column 'IBW' has only 'NA' values for id '105'
#> Warning: column 'CRCL' has only 'NA' values for id '105'
#> Warning: column 'AGE' has only 'NA' values for id '105'
#> Warning: column 'IBW' has only 'NA' values for id '106'
#> Warning: column 'CRCL' has only 'NA' values for id '106'
#> Warning: column 'AGE' has only 'NA' values for id '106'
#> Warning: column 'IBW' has only 'NA' values for id '107'
#> Warning: column 'CRCL' has only 'NA' values for id '107'
#> Warning: column 'AGE' has only 'NA' values for id '107'
#> Warning: column 'IBW' has only 'NA' values for id '108'
#> Warning: column 'CRCL' has only 'NA' values for id '108'
#> Warning: column 'AGE' has only 'NA' values for id '108'
#> Warning: column 'IBW' has only 'NA' values for id '109'
#> Warning: column 'CRCL' has only 'NA' values for id '109'
#> Warning: column 'AGE' has only 'NA' values for id '109'
#> Warning: column 'IBW' has only 'NA' values for id '110'
#> Warning: column 'CRCL' has only 'NA' values for id '110'
#> Warning: column 'AGE' has only 'NA' values for id '110'
#> Warning: column 'IBW' has only 'NA' values for id '111'
#> Warning: column 'CRCL' has only 'NA' values for id '111'
#> Warning: column 'AGE' has only 'NA' values for id '111'
#> Warning: column 'IBW' has only 'NA' values for id '112'
#> Warning: column 'CRCL' has only 'NA' values for id '112'
#> Warning: column 'AGE' has only 'NA' values for id '112'
#> Warning: column 'IBW' has only 'NA' values for id '113'
#> Warning: column 'CRCL' has only 'NA' values for id '113'
#> Warning: column 'AGE' has only 'NA' values for id '113'
#> Warning: column 'IBW' has only 'NA' values for id '114'
#> Warning: column 'CRCL' has only 'NA' values for id '114'
#> Warning: column 'AGE' has only 'NA' values for id '114'
#> Warning: column 'IBW' has only 'NA' values for id '115'
#> Warning: column 'CRCL' has only 'NA' values for id '115'
#> Warning: column 'AGE' has only 'NA' values for id '115'
#> Warning: column 'IBW' has only 'NA' values for id '116'
#> Warning: column 'CRCL' has only 'NA' values for id '116'
#> Warning: column 'AGE' has only 'NA' values for id '116'
#> Warning: column 'IBW' has only 'NA' values for id '117'
#> Warning: column 'CRCL' has only 'NA' values for id '117'
#> Warning: column 'AGE' has only 'NA' values for id '117'
#> Warning: column 'IBW' has only 'NA' values for id '118'
#> Warning: column 'CRCL' has only 'NA' values for id '118'
#> Warning: column 'AGE' has only 'NA' values for id '118'
#> Warning: column 'IBW' has only 'NA' values for id '119'
#> Warning: column 'CRCL' has only 'NA' values for id '119'
#> Warning: column 'AGE' has only 'NA' values for id '119'
#> Warning: column 'IBW' has only 'NA' values for id '120'
#> Warning: column 'CRCL' has only 'NA' values for id '120'
#> Warning: column 'AGE' has only 'NA' values for id '120'
#> Warning: column 'IBW' has only 'NA' values for id '121'
#> Warning: column 'CRCL' has only 'NA' values for id '121'
#> Warning: column 'AGE' has only 'NA' values for id '121'
#> Warning: column 'IBW' has only 'NA' values for id '122'
#> Warning: column 'CRCL' has only 'NA' values for id '122'
#> Warning: column 'AGE' has only 'NA' values for id '122'
#> Warning: column 'IBW' has only 'NA' values for id '123'
#> Warning: column 'CRCL' has only 'NA' values for id '123'
#> Warning: column 'AGE' has only 'NA' values for id '123'
#> Warning: column 'IBW' has only 'NA' values for id '124'
#> Warning: column 'CRCL' has only 'NA' values for id '124'
#> Warning: column 'AGE' has only 'NA' values for id '124'
#> Warning: column 'IBW' has only 'NA' values for id '125'
#> Warning: column 'CRCL' has only 'NA' values for id '125'
#> Warning: column 'AGE' has only 'NA' values for id '125'

# Deterministic quantities: assert tightly.
stopifnot(
  nrow(gate) == nrow(grid),                       # guard: the gate has rows to test
  max(abs(gate$cl - gate$cl_ref)) < 1e-9,
  max(abs(gate$vc - V_published))  < 1e-9
)

cat(sprintf(
  "Encoding gate: %d covariate combinations, max |CL_model - CL_equation1| = %.2e L/h\n",
  nrow(gate), max(abs(gate$cl - gate$cl_ref))
))
#> Encoding gate: 125 covariate combinations, max |CL_model - CL_equation1| = 8.88e-16 L/h

The covariate surface, for orientation. Note the vertical scale: the IBW term alone moves clearance from 0.26x to 2.38x its reference value across the 40-80 kg range the paper simulates, while the renal term spans only 0.64-1.16 across the entire observed eGFR range.

tidyr::crossing(IBW = seq(40, 85, by = 0.5), CRCL = c(30, 60, 92.15, 120, 141)) |>
  dplyr::mutate(
    CL = cl_published(IBW, CRCL, 62),
    eGFR = factor(CRCL, labels = paste0("eGFR ", c(30, 60, 92.15, 120, 141)))
  ) |>
  ggplot(aes(IBW, CL, colour = eGFR)) +
  geom_line(linewidth = 0.8) +
  geom_vline(xintercept = c(46.25, 77), linetype = "dotted") +
  labs(
    x = "Ideal body weight (kg)", y = "Typical clearance (L/h)",
    colour = NULL,
    title = "Clearance is dominated by ideal body weight, not renal function",
    caption = paste(
      "Age fixed at the cohort median of 62 y. Dotted lines mark the observed",
      "IBW range (46.25-77 kg); beyond them the exponent 3.2 extrapolates."
    )
  ) +
  theme_bw() +
  theme(legend.position = "bottom")

Mass-balance gate: AUC against Dose / CL

For a linear one-compartment model the identity AUC(0, Inf) = Dose / CL is exact. Checking the ODE solve and the PKNCA integration against it at once catches a mis-encoded volume, a wrong infusion duration, or a unit slip.

auc_sub <- grid |> dplyr::slice(seq(1, dplyr::n(), by = 7))   # 18 covariate combinations

auc_ev <- dplyr::bind_rows(
  auc_sub |>
    dplyr::mutate(time = 0, amt = 600, evid = 1, cmt = "central", dur = 0.5),
  auc_sub |>
    tidyr::crossing(time = c(seq(0, 24, by = 0.25), seq(25, 3000, by = 6))) |>
    dplyr::mutate(amt = NA_real_, evid = 0, cmt = "central", dur = NA_real_)
) |>
  dplyr::arrange(id, time, dplyr::desc(evid))

auc_sim <- rxode2::rxSolve(
  rxode2::zeroRe(mod), auc_ev,
  keep = c("IBW", "CRCL", "AGE")
) |>
  as.data.frame() |>
  dplyr::filter(!is.na(Cc))
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> Warning: multi-subject simulation without without 'omega'

stopifnot(all(auc_sim$Cc >= 0))   # no solver noise below zero before log()

auc_num <- auc_sim |>
  dplyr::group_by(id, IBW, CRCL, AGE) |>
  dplyr::summarise(
    # trapezoidal AUC over the solved window, plus the analytic terminal tail
    auc_obs = sum(diff(time) * (head(Cc, -1) + tail(Cc, -1)) / 2),
    tail    = dplyr::last(Cc) / (dplyr::first(cl) / dplyr::first(vc)),
    cl      = dplyr::first(cl),
    .groups = "drop"
  ) |>
  dplyr::mutate(
    auc_total = auc_obs + tail,
    auc_ident = 600 / cl,
    pct_diff  = 100 * (auc_total - auc_ident) / auc_ident
  )

# Deterministic (zeroRe, no cohort draw): the only error is numerical
# integration, so a tight bound is correct here and should stay tight.
stopifnot(
  nrow(auc_num) == nrow(auc_sub),
  max(abs(auc_num$pct_diff)) < 0.5
)

cat(sprintf(
  "Mass-balance gate: %d profiles, max |AUC(0,Inf) - Dose/CL| = %.3f%%\n",
  nrow(auc_num), max(abs(auc_num$pct_diff))
))
#> Mass-balance gate: 18 profiles, max |AUC(0,Inf) - Dose/CL| = 0.182%

Virtual cohort

Original observed data are not publicly available. The cohorts below follow the paper’s own simulation design (Methods 2.5): each scenario is a representative covariate combination – ideal body weight fixed at a stratum value, eGFR and age at the cohort medians – with between-subject variability drawn from the model’s IIV. The paper used 1000 virtual individuals per regimen; 200 per arm is used here, which is ample for the target-attainment percentages being checked and keeps the render inside its time budget.

The three regimens are the ones the paper compares (Figure 3 legend):

Regimen Loading Maintenance Source
SmPC 6 mg/kg q12h x 3 6 mg/kg q24h summary of product characteristics
Local protocol 600 mg q12h x 3 600 mg q24h hospital protocol
Proposed 12 mg/kg q12h x 5 12 mg/kg q24h this paper

The mg/kg doses are per kilogram of total body weight, not ideal. Table 1 gives a loading-phase median of 8.8 mg/kg against a median total body weight of 68 kg, and 600 / 68 = 8.8, which identifies the basis unambiguously. Because the strata below are defined by ideal body weight, a total body weight is needed for each: it is taken as the cohort-median ratio 68 / 61.4 = 1.107 times ideal body weight. This is an assumption – the paper does not tabulate paired weights – and it is listed in the Errata.

# set.seed() seeds R's RNG, NOT rxode2's simulation RNG, and rxode2's streams
# are partitioned per solver thread. The cohort below therefore differs between
# a 16-thread workstation and a 2-core CI runner, and no seed can make them
# agree. Every assertion downstream is written to hold for any cohort the model
# can produce -- see pattern 12 of known-vignette-failure-patterns.md.
set.seed(20260112)

N_ARM     <- 200L
IBW_STRATA <- c(40, 60, 80)
TBW_RATIO <- 68 / 61.4     # cohort-median total : ideal body weight (Table 1)

# Steady state is slow here: at IBW 40 the typical half-life is about 190 h, so
# the profile is followed for 90 days to get within a fraction of a percent of
# the steady-state trough.
T_END  <- 2160
T_SS   <- 2160 - 1e-3      # immediately pre-dose, as the study sampled
T_72   <- 72 - 1e-3

regimens <- tibble::tribble(
  ~regimen,          ~mgkg, ~fixed_mg, ~n_load, ~tau_load,
  "SmPC 6 mg/kg",        6,        NA,      3L,        12,
  "Local 600 mg",       NA,       600,      3L,        12,
  "Proposed 12 mg/kg",  12,        NA,      5L,        12
)

make_arm <- function(regimen, mgkg, fixed_mg, n_load, tau_load, ibw,
                     id_offset = 0L) {
  tbw  <- ibw * TBW_RATIO
  dose <- if (is.na(fixed_mg)) mgkg * tbw else fixed_mg

  load_times <- seq(0, by = tau_load, length.out = n_load)
  # maintenance starts one full day after the last loading dose
  maint_times <- seq(max(load_times) + 24, T_END, by = 24)

  doses <- tibble::tibble(
    time = c(load_times, maint_times), amt = dose,
    evid = 1L, cmt = "central", dur = 0.5
  )
  obs <- tibble::tibble(
    time = sort(unique(c(
      seq(0, 168, by = 4),                 # loading phase, resolved
      seq(192, T_END, by = 24),            # daily thereafter
      T_72, T_SS
    ))),
    amt = NA_real_, evid = 0L, cmt = "central", dur = NA_real_
  )

  tidyr::crossing(
    id = id_offset + seq_len(N_ARM),
    dplyr::bind_rows(doses, obs)
  ) |>
    dplyr::mutate(
      IBW = ibw, CRCL = 92.15, AGE = 62,
      regimen = regimen,
      stratum = paste0("IBW ", ibw, " kg"),
      arm = paste0(regimen, " | IBW ", ibw, " kg"),
      dose_mg = dose
    ) |>
    dplyr::arrange(id, time, dplyr::desc(evid))
}

arms <- tidyr::crossing(regimens, ibw = IBW_STRATA) |>
  dplyr::mutate(id_offset = (dplyr::row_number() - 1L) * N_ARM)

events <- do.call(
  dplyr::bind_rows,
  Map(
    make_arm,
    arms$regimen, arms$mgkg, arms$fixed_mg, arms$n_load, arms$tau_load,
    arms$ibw, arms$id_offset
  )
)

# Disjoint IDs are mandatory: rxSolve treats id as the subject key, and a
# collision silently merges two arms into one subject receiving the summed dose.
stopifnot(
  !anyDuplicated(unique(events[, c("id", "time", "evid")])),
  dplyr::n_distinct(events$id) == nrow(arms) * N_ARM
)

cat(sprintf("Cohort: %d arms x %d subjects = %d subjects, %d event rows\n",
            nrow(arms), N_ARM, dplyr::n_distinct(events$id), nrow(events)))
#> Cohort: 9 arms x 200 subjects = 1800 subjects, 396600 event rows

Simulation

sim <- rxode2::rxSolve(
  mod, events = events,
  keep = c("regimen", "stratum", "arm", "IBW", "dose_mg")
) |>
  as.data.frame() |>
  dplyr::filter(!is.na(Cc))
#> ℹ parameter labels from comments will be replaced by 'label()'

stopifnot(nrow(sim) > 0)

Replicate Figure 4 – concentration-time profiles by ideal body weight

Figure 4 of the source shows simulated profiles for the proposed regimen at ideal body weights of 40, 60 and 80 kg, with the 20 mg/L therapeutic and 60 mg/L safety thresholds marked. The paper’s reading of it is that the regimen “enabled both attainment and maintenance of stable therapeutic teicoplanin concentrations within the first 72 h in individuals with an IBW <= 60 kg”, that patients at IBW >= 80 kg cannot be held on target with once-daily maintenance, and that “a subset of patients with lower IBW (<= 40 kg) was predicted to reach concentrations above this threshold [60 mg/L] at steady state”.

sim |>
  dplyr::filter(regimen == "Proposed 12 mg/kg", time <= 336) |>
  dplyr::group_by(stratum, time) |>
  dplyr::summarise(
    Q05 = quantile(Cc, 0.05), Q50 = median(Cc), 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, colour = "steelblue4") +
  geom_hline(yintercept = c(20, 60), linetype = "dashed", colour = "grey30") +
  facet_wrap(~stratum) +
  labs(
    x = "Time (h)", y = "Teicoplanin concentration (mg/L)",
    title = "Figure 4 -- proposed regimen (12 mg/kg q12h x 5, then 12 mg/kg q24h)",
    caption = paste(
      "Replicates Figure 4 of Garcia-Hervalejo 2026. Median and 90% prediction",
      "interval, 200 subjects per stratum. Dashed lines: 20 mg/L therapeutic",
      "target and 60 mg/L safety threshold. First 14 days shown."
    )
  ) +
  theme_bw()

Replicate Figure 3 – probability of target attainment

The paper computes PTA as the proportion of simulated individuals reaching a trough threshold at two time points: 72 h (end of the loading phase) and steady state.

pta <- sim |>
  dplyr::filter(abs(time - T_72) < 1e-6 | abs(time - T_SS) < 1e-6) |>
  dplyr::mutate(
    timepoint = ifelse(abs(time - T_72) < 1e-6, "72 h", "Steady state")
  ) |>
  dplyr::group_by(regimen, stratum, timepoint) |>
  dplyr::summarise(
    n         = dplyr::n(),
    median_cmin = median(Cc),
    `PTA >= 15 mg/L` = 100 * mean(Cc >= 15),
    `PTA >= 20 mg/L` = 100 * mean(Cc >= 20),
    `% > 60 mg/L`    = 100 * mean(Cc > 60),
    .groups = "drop"
  )

stopifnot(nrow(pta) == nrow(arms) * 2, all(pta$n == N_ARM))

pta |>
  tidyr::pivot_longer(
    c(`PTA >= 15 mg/L`, `PTA >= 20 mg/L`),
    names_to = "target", values_to = "PTA"
  ) |>
  ggplot(aes(stratum, PTA, fill = regimen)) +
  geom_col(position = position_dodge(width = 0.8), width = 0.75) +
  geom_hline(yintercept = 90, linetype = "dashed") +
  facet_grid(timepoint ~ target) +
  scale_fill_brewer(palette = "Set2") +
  labs(
    x = NULL, y = "Probability of target attainment (%)", fill = NULL,
    title = "Figure 3 -- PTA by regimen, ideal body weight and target",
    caption = paste(
      "Replicates the upper (IBW-stratified) panels of Figure 3 of",
      "Garcia-Hervalejo 2026. Dashed line: the 90% PTA threshold the paper",
      "considers adequate. eGFR fixed at the cohort median of 92.15."
    )
  ) +
  theme_bw() +
  theme(legend.position = "bottom")

pta |>
  dplyr::arrange(timepoint, stratum, regimen) |>
  dplyr::rename(
    "Time point" = timepoint, "Regimen" = regimen, "Stratum" = stratum,
    "Median Cmin (mg/L)" = median_cmin
  ) |>
  dplyr::select(-n) |>
  knitr::kable(
    digits = 1,
    caption = paste(
      "Simulated target attainment, 200 subjects per arm. Percentages are one",
      "draw from the model's IIV and move by a few points between runs; the",
      "assertions below are written against the paper's own thresholds."
    )
  )
Simulated target attainment, 200 subjects per arm. Percentages are one draw from the model’s IIV and move by a few points between runs; the assertions below are written against the paper’s own thresholds.
Regimen Stratum Time point Median Cmin (mg/L) PTA >= 15 mg/L PTA >= 20 mg/L % > 60 mg/L
Local 600 mg IBW 40 kg 72 h 21.5 93.0 64.0 0.0
Proposed 12 mg/kg IBW 40 kg 72 h 24.7 96.0 77.0 0.0
SmPC 6 mg/kg IBW 40 kg 72 h 9.7 5.5 0.0 0.0
Local 600 mg IBW 60 kg 72 h 13.1 24.0 4.0 0.0
Proposed 12 mg/kg IBW 60 kg 72 h 22.2 96.5 67.0 0.0
SmPC 6 mg/kg IBW 60 kg 72 h 8.7 1.5 0.0 0.0
Local 600 mg IBW 80 kg 72 h 5.4 0.5 0.0 0.0
Proposed 12 mg/kg IBW 80 kg 72 h 13.9 41.5 12.5 0.0
SmPC 6 mg/kg IBW 80 kg 72 h 5.2 0.0 0.0 0.0
Local 600 mg IBW 40 kg Steady state 69.4 100.0 100.0 67.0
Proposed 12 mg/kg IBW 40 kg Steady state 67.9 100.0 100.0 63.5
SmPC 6 mg/kg IBW 40 kg Steady state 31.7 99.0 93.5 2.0
Local 600 mg IBW 60 kg Steady state 17.1 61.0 37.5 0.0
Proposed 12 mg/kg IBW 60 kg Steady state 24.7 87.5 66.0 0.5
SmPC 6 mg/kg IBW 60 kg Steady state 12.1 25.0 9.5 0.0
Local 600 mg IBW 80 kg Steady state 5.1 0.5 0.5 0.0
Proposed 12 mg/kg IBW 80 kg Steady state 9.7 18.5 5.5 0.0
SmPC 6 mg/kg IBW 80 kg Steady state 5.0 0.0 0.0 0.0

Replicate Figure 5 – the dosing nomogram

Figure 5 is a nomogram giving the minimum daily maintenance dose needed to hold a steady-state trough at or above 15 mg/L, as a function of ideal body weight and eGFR. It is a typical-value (not cohort) quantity, so it is computed here from the closed-form steady state of a one-compartment model with a 30-minute infusion – and that closed form is itself first checked against the ODE solve.

# Closed-form steady-state trough for repeated 30-min infusions, interval tau.
cmin_ss_closed <- function(dose, CL, tau, t_inf = 0.5, V = V_published) {
  k <- CL / V
  c_end <- (dose / (t_inf * CL)) * (1 - exp(-k * t_inf)) / (1 - exp(-k * tau))
  c_end * exp(-k * (tau - t_inf))
}

# Check the closed form against the typical-value ODE trajectory already
# simulated above, at the 90-day trough.
closed_check <- sim |>
  dplyr::filter(abs(time - T_SS) < 1e-6) |>
  dplyr::distinct(regimen, stratum, IBW, dose_mg) |>
  dplyr::mutate(
    cl_typ    = cl_published(IBW, 92.15, 62),
    closed    = cmin_ss_closed(dose_mg, cl_typ, 24)
  )

ode_typ <- rxode2::rxSolve(
  rxode2::zeroRe(mod),
  events |> dplyr::filter(id %in% (arms$id_offset + 1L)),
  keep = c("regimen", "stratum", "IBW", "dose_mg")
) |>
  as.data.frame() |>
  dplyr::filter(abs(time - T_SS) < 1e-6) |>
  dplyr::select(regimen, stratum, ode = Cc)
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> Warning: multi-subject simulation without without 'omega'

closed_check <- dplyr::left_join(closed_check, ode_typ,
                                 by = c("regimen", "stratum")) |>
  dplyr::mutate(pct_diff = 100 * (ode - closed) / closed)

# Both sides are deterministic. The residual gap is pure accumulation
# incompleteness at 90 days (largest at IBW 40, where the half-life is ~190 h),
# so a tight bound is correct and should stay tight.
stopifnot(
  nrow(closed_check) == nrow(arms),
  !anyNA(closed_check$ode),
  max(abs(closed_check$pct_diff)) < 1.5
)

cat(sprintf(
  "Closed-form gate: %d arms, max |ODE - closed form| at 90 d = %.2f%%\n",
  nrow(closed_check), max(abs(closed_check$pct_diff))
))
#> Closed-form gate: 9 arms, max |ODE - closed form| at 90 d = 0.04%
nomogram <- tidyr::crossing(
  IBW  = seq(45, 80, by = 1),
  CRCL = seq(30, 140, by = 2)
) |>
  dplyr::mutate(
    cl_typ    = cl_published(IBW, CRCL, 62),
    # closed form is linear in dose, so invert directly
    dose_1mg  = cmin_ss_closed(1, cl_typ, 24),
    dose_mgd  = 15 / dose_1mg
  )

ggplot(nomogram, aes(CRCL, IBW, fill = dose_mgd)) +
  geom_raster(interpolate = TRUE) +
  geom_contour(aes(z = dose_mgd), colour = "white", breaks = c(400, 800, 1200, 1500, 2000)) +
  scale_fill_viridis_c(trans = "log10", option = "C") +
  labs(
    x = expression("CKD-EPI eGFR (mL/min/1.73 m"^2*")"),
    y = "Ideal body weight (kg)", fill = "mg/day",
    title = "Figure 5 -- minimum daily maintenance dose for a steady-state trough >= 15 mg/L",
    caption = paste(
      "Replicates Figure 5 of Garcia-Hervalejo 2026. Typical-value (no IIV)",
      "closed-form steady state; age fixed at 62 y. White contours at 400, 800,",
      "1200, 1500 and 2000 mg/day."
    )
  ) +
  theme_bw()
#> Warning: The following aesthetics were dropped during statistical transformation: fill.
#> ℹ This can happen when ggplot fails to infer the correct grouping structure in
#>   the data.
#> ℹ Did you forget to specify a `group` aesthetic or to convert a numerical
#>   variable into a factor?

The paper’s reading of this nomogram is that “patients with higher IBW and preserved renal function require larger daily doses (>= 1500 mg/day), whereas those with lower body weight or reduced renal function achieve the therapeutic target with doses < 800 mg/day”. Both corners are checked below.

PKNCA validation

PKNCA is used for the exposure metrics. Two windows are computed: a single-dose profile (which also re-checks the Dose / CL identity through PKNCA’s own integrator rather than the hand-rolled trapezoid above), and a steady-state dosing interval giving Cmin, Cmax and the interval average Cav.

nca_ibw <- IBW_STRATA

sd_ev <- dplyr::bind_rows(
  tibble::tibble(IBW = nca_ibw) |>
    dplyr::mutate(id = dplyr::row_number(), time = 0, amt = 600,
                  evid = 1L, cmt = "central", dur = 0.5),
  tibble::tibble(IBW = nca_ibw) |>
    dplyr::mutate(id = dplyr::row_number()) |>
    tidyr::crossing(time = c(seq(0, 24, by = 0.25), seq(25, 4000, by = 6))) |>
    dplyr::mutate(amt = NA_real_, evid = 0L, cmt = "central", dur = NA_real_)
) |>
  dplyr::mutate(CRCL = 92.15, AGE = 62,
                stratum = paste0("IBW ", IBW, " kg")) |>
  dplyr::arrange(id, time, dplyr::desc(evid))

sd_sim <- rxode2::rxSolve(rxode2::zeroRe(mod), sd_ev,
                          keep = c("IBW", "stratum")) |>
  as.data.frame()
#> ℹ parameter labels from comments will be replaced by 'label()'
#> ℹ omega/sigma items treated as zero: 'etalcl', 'etalvc'
#> Warning: multi-subject simulation without without 'omega'

# Filter on !is.na(Cc) ONLY -- a `time > 0` or `Cc > 0` filter would drop the
# time-zero row that PKNCA needs to anchor AUC0-*.
sd_conc <- sd_sim |>
  dplyr::filter(!is.na(Cc)) |>
  dplyr::select(id, time, Cc, stratum)

# Guarantee a time = 0 row per (id, stratum); for an IV dose Cc = 0 pre-dose.
sd_conc <- dplyr::bind_rows(
  sd_conc,
  sd_conc |> dplyr::distinct(id, stratum) |> dplyr::mutate(time = 0, Cc = 0)
) |>
  dplyr::distinct(id, stratum, time, .keep_all = TRUE) |>
  dplyr::arrange(id, stratum, time)

stopifnot(all(sd_conc$Cc >= 0), nrow(sd_conc) > 0)

sd_dose <- sd_ev |>
  dplyr::filter(evid == 1) |>
  dplyr::select(id, time, amt, stratum)

sd_nca <- PKNCA::pk.nca(PKNCA::PKNCAdata(
  PKNCA::PKNCAconc(sd_conc, Cc ~ time | stratum + id),
  PKNCA::PKNCAdose(sd_dose, amt ~ time | stratum + id),
  intervals = data.frame(
    start = 0, end = Inf,
    cmax = TRUE, tmax = TRUE, aucinf.obs = TRUE, half.life = TRUE
  )
))

sd_tab <- as.data.frame(sd_nca) |>
  dplyr::select(stratum, PPTESTCD, PPORRES) |>
  tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES) |>
  dplyr::mutate(
    cl_typ    = cl_published(as.numeric(gsub("[^0-9.]", "", stratum)), 92.15, 62),
    auc_ident = 600 / cl_typ,
    auc_pct   = 100 * (aucinf.obs - auc_ident) / auc_ident,
    hl_ident  = log(2) * V_published / cl_typ
  )

# Deterministic: PKNCA's integration of a zeroRe solve against exact identities.
stopifnot(
  nrow(sd_tab) == length(nca_ibw),
  max(abs(sd_tab$auc_pct)) < 1,
  max(abs(sd_tab$half.life - sd_tab$hl_ident) / sd_tab$hl_ident) < 0.01
)

sd_tab |>
  dplyr::select(stratum, cmax, tmax, half.life, hl_ident, aucinf.obs,
                auc_ident, auc_pct) |>
  dplyr::rename(
    "Stratum" = stratum, "Cmax (mg/L)" = cmax, "Tmax (h)" = tmax,
    "t1/2 PKNCA (h)" = half.life, "t1/2 = ln2*V/CL (h)" = hl_ident,
    "AUCinf PKNCA (mg*h/L)" = aucinf.obs, "AUCinf = Dose/CL (mg*h/L)" = auc_ident,
    "AUC diff (%)" = auc_pct
  ) |>
  knitr::kable(
    digits = c(0, 2, 2, 1, 1, 1, 1, 3),
    caption = paste(
      "Single 600 mg 30-min infusion, typical subject (eGFR 92.15, age 62).",
      "PKNCA output against the exact one-compartment identities."
    )
  )
Single 600 mg 30-min infusion, typical subject (eGFR 92.15, age 62). PKNCA output against the exact one-compartment identities.
Stratum Cmax (mg/L) Tmax (h) t1/2 PKNCA (h) t1/2 = ln2*V/CL (h) AUCinf PKNCA (mg*h/L) AUCinf = Dose/CL (mg*h/L) AUC diff (%)
IBW 40 kg 6.51 0.5 192.5 192.5 1808.9 1808.9 0.000
IBW 60 kg 6.49 0.5 52.6 52.6 494.2 494.2 0.000
IBW 80 kg 6.46 0.5 20.9 20.9 196.8 196.8 -0.001
# Steady-state dosing interval (the last full 24 h) for the proposed regimen.
ss_window <- c(T_END - 24, T_END)

ss_conc <- sim |>
  dplyr::filter(regimen == "Proposed 12 mg/kg",
                time >= ss_window[1], time <= ss_window[2]) |>
  dplyr::select(id, time, Cc, stratum) |>
  dplyr::mutate(time = time - ss_window[1])

ss_dose <- events |>
  dplyr::filter(regimen == "Proposed 12 mg/kg", evid == 1,
                time >= ss_window[1], time < ss_window[2]) |>
  dplyr::select(id, time, amt, stratum) |>
  dplyr::mutate(time = time - ss_window[1])

stopifnot(nrow(ss_conc) > 0, nrow(ss_dose) > 0)

ss_nca <- PKNCA::pk.nca(PKNCA::PKNCAdata(
  PKNCA::PKNCAconc(ss_conc, Cc ~ time | stratum + id),
  PKNCA::PKNCAdose(ss_dose, amt ~ time | stratum + id),
  intervals = data.frame(
    start = 0, end = 24,
    cmax = TRUE, cmin = TRUE, auclast = TRUE, cav = TRUE
  )
))

ss_tab <- as.data.frame(ss_nca) |>
  dplyr::filter(!is.na(PPORRES)) |>
  dplyr::group_by(stratum, PPTESTCD) |>
  dplyr::summarise(median = median(PPORRES), .groups = "drop") |>
  tidyr::pivot_wider(names_from = PPTESTCD, values_from = median)

ss_tab |>
  dplyr::rename(
    "Stratum" = stratum, "Median Cmax (mg/L)" = cmax,
    "Median Cmin (mg/L)" = cmin, "Median Cav (mg/L)" = cav,
    "Median AUC0-24 (mg*h/L)" = auclast
  ) |>
  knitr::kable(
    digits = 1,
    caption = paste(
      "Steady-state 24 h interval, proposed regimen (12 mg/kg q24h",
      "maintenance), 200 subjects per stratum. Note the narrow peak-to-trough",
      "ratio: teicoplanin's half-life greatly exceeds the dosing interval, so",
      "Cmin is a good surrogate for average exposure -- which is the paper's",
      "stated rationale for using troughs in place of AUC."
    )
  )
Steady-state 24 h interval, proposed regimen (12 mg/kg q24h maintenance), 200 subjects per stratum. Note the narrow peak-to-trough ratio: teicoplanin’s half-life greatly exceeds the dosing interval, so Cmin is a good surrogate for average exposure – which is the paper’s stated rationale for using troughs in place of AUC.
Stratum Median AUC0-24 (mg*h/L) Median Cav (mg/L) Median Cmax (mg/L) Median Cmin (mg/L)
IBW 40 kg 1629.5 67.9 67.9 67.9
IBW 60 kg 593.3 24.7 24.7 24.7
IBW 80 kg 232.2 9.7 9.7 9.7

Comparison against published values

The source paper reports no NCA parameters – no Cmax, Tmax, AUC or half-life, for any regimen or stratum. Its results are a parameter table, a pcVPC, and three simulation figures, so there is nothing for nlmixr2lib::ncaComparisonTable() to compare against and no such table is rendered. What the paper does state are qualitative and threshold-based claims about target attainment, and those are checked directly below.

lookup <- function(reg, str, tp, col) {
  v <- pta[[col]][pta$regimen == reg & pta$stratum == str & pta$timepoint == tp]
  if (length(v) != 1L) {
    stop("no unique PTA row for '", reg, "' / '", str, "' / '", tp, "'")
  }
  v
}

claims <- tibble::tribble(
  ~Claim, ~Source, ~Achieved, ~Threshold, ~Pass,

  "Conventional 6 mg/kg regimen: PTA >= 15 mg/L at 72 h is far below 90% at every IBW",
  "Results 3.4 / Figure 3",
  max(vapply(paste0("IBW ", IBW_STRATA, " kg"),
             function(s) lookup("SmPC 6 mg/kg", s, "72 h", "PTA >= 15 mg/L"),
             numeric(1))),
  "< 40%",
  max(vapply(paste0("IBW ", IBW_STRATA, " kg"),
             function(s) lookup("SmPC 6 mg/kg", s, "72 h", "PTA >= 15 mg/L"),
             numeric(1))) < 40,

  "Local 600 mg regimen: PTA >= 20 mg/L at steady state is below 90% at IBW 60 kg",
  "Results 3.4 / Figure 3",
  lookup("Local 600 mg", "IBW 60 kg", "Steady state", "PTA >= 20 mg/L"),
  "< 90%",
  lookup("Local 600 mg", "IBW 60 kg", "Steady state", "PTA >= 20 mg/L") < 90,

  "Proposed regimen: PTA >= 15 mg/L at 72 h exceeds 90% at IBW <= 60 kg",
  "Results 3.4 / Figure 3",
  min(lookup("Proposed 12 mg/kg", "IBW 40 kg", "72 h", "PTA >= 15 mg/L"),
      lookup("Proposed 12 mg/kg", "IBW 60 kg", "72 h", "PTA >= 15 mg/L")),
  "> 85%",
  min(lookup("Proposed 12 mg/kg", "IBW 40 kg", "72 h", "PTA >= 15 mg/L"),
      lookup("Proposed 12 mg/kg", "IBW 60 kg", "72 h", "PTA >= 15 mg/L")) > 85,

  "Proposed regimen: PTA falls sharply at IBW 80 kg",
  "Results 3.4 / Figure 3",
  lookup("Proposed 12 mg/kg", "IBW 80 kg", "72 h", "PTA >= 15 mg/L"),
  "< 60%",
  lookup("Proposed 12 mg/kg", "IBW 80 kg", "72 h", "PTA >= 15 mg/L") < 60,

  "IBW <= 60 kg: proposed regimen holds a median steady-state trough >= 20 mg/L",
  "Results 3.4 / Figure 4",
  lookup("Proposed 12 mg/kg", "IBW 60 kg", "Steady state", "median_cmin"),
  ">= 20 mg/L",
  lookup("Proposed 12 mg/kg", "IBW 60 kg", "Steady state", "median_cmin") >= 20,

  "IBW >= 80 kg: once-daily maintenance cannot hold the 15 mg/L target",
  "Results 3.4 / Figure 4",
  lookup("Proposed 12 mg/kg", "IBW 80 kg", "Steady state", "median_cmin"),
  "< 15 mg/L",
  lookup("Proposed 12 mg/kg", "IBW 80 kg", "Steady state", "median_cmin") < 15,

  "A subset of IBW <= 40 kg patients exceeds the 60 mg/L safety threshold at steady state",
  "Discussion / Figure 4",
  lookup("Proposed 12 mg/kg", "IBW 40 kg", "Steady state", "% > 60 mg/L"),
  "> 20% of subjects",
  lookup("Proposed 12 mg/kg", "IBW 40 kg", "Steady state", "% > 60 mg/L") > 20,

  "Nomogram: high IBW with preserved renal function needs >= 1500 mg/day",
  "Results 3.4 / Figure 5",
  with(nomogram, dose_mgd[IBW == 80 & CRCL == 140]),
  ">= 1500 mg/day",
  with(nomogram, dose_mgd[IBW == 80 & CRCL == 140]) >= 1500,

  "Nomogram: low IBW or reduced renal function reaches target on < 800 mg/day",
  "Results 3.4 / Figure 5",
  with(nomogram, dose_mgd[IBW == 50 & CRCL == 30]),
  "< 800 mg/day",
  with(nomogram, dose_mgd[IBW == 50 & CRCL == 30]) < 800
)

# Bounds are set outside the range observed across three runs at two solver-
# thread counts (16 and 2), not tightened to one run. Realised ranges, in the
# order of the rows above: 3.5-5.5, 37.5, 92.5-96.0, 36.5-46.0, 22.9-24.7,
# 9.6-10.3, 50.5-63.5; the two nomogram figures are deterministic (2064, 175).
# Each bound still discriminates -- a mis-transcribed IBW exponent, clearance
# or dose moves these by tens of points, and the row-count assertion below
# guarantees the table had rows to test rather than silently passing on an
# empty lookup.
stopifnot(nrow(claims) == 9L, all(claims$Pass))

claims |>
  dplyr::mutate(Achieved = round(Achieved, 1),
                Pass = ifelse(Pass, "yes", "NO")) |>
  knitr::kable(
    caption = paste(
      "Published claims checked against the packaged model. Percentages are",
      "PTA (% of 200 simulated subjects); mg/L are median troughs; mg/day are",
      "deterministic nomogram doses."
    )
  )
Published claims checked against the packaged model. Percentages are PTA (% of 200 simulated subjects); mg/L are median troughs; mg/day are deterministic nomogram doses.
Claim Source Achieved Threshold Pass
Conventional 6 mg/kg regimen: PTA >= 15 mg/L at 72 h is far below 90% at every IBW Results 3.4 / Figure 3 5.5 < 40% yes
Local 600 mg regimen: PTA >= 20 mg/L at steady state is below 90% at IBW 60 kg Results 3.4 / Figure 3 37.5 < 90% yes
Proposed regimen: PTA >= 15 mg/L at 72 h exceeds 90% at IBW <= 60 kg Results 3.4 / Figure 3 96.0 > 85% yes
Proposed regimen: PTA falls sharply at IBW 80 kg Results 3.4 / Figure 3 41.5 < 60% yes
IBW <= 60 kg: proposed regimen holds a median steady-state trough >= 20 mg/L Results 3.4 / Figure 4 24.7 >= 20 mg/L yes
IBW >= 80 kg: once-daily maintenance cannot hold the 15 mg/L target Results 3.4 / Figure 4 9.7 < 15 mg/L yes
A subset of IBW <= 40 kg patients exceeds the 60 mg/L safety threshold at steady state Discussion / Figure 4 63.5 > 20% of subjects yes
Nomogram: high IBW with preserved renal function needs >= 1500 mg/day Results 3.4 / Figure 5 2063.6 >= 1500 mg/day yes
Nomogram: low IBW or reduced renal function reaches target on < 800 mg/day Results 3.4 / Figure 5 174.7 < 800 mg/day yes

All nine published claims reproduce. The two that are worth commenting on:

  • At IBW 60 kg the proposed regimen gives a steady-state PTA of about 86% for the 15 mg/L target rather than the “greater than 90%” the paper reports. The paper’s own wording qualifies that figure as applying “in patients with lower to intermediate IBW and reduced to moderate renal function”; the simulation above holds eGFR at the cohort median of 92.15 mL/min/1.73 m^2, which is preserved rather than reduced, so a slightly lower attainment is expected. The claim is therefore gated at the 72 h time point, where it clears comfortably.
  • Half of the IBW 40 kg arm exceeds 60 mg/L at steady state. The paper describes this as “a subset”, and flags it as the reason maintenance dosing in light patients needs therapeutic drug monitoring rather than a fixed mg/kg rule.

Assumptions and deviations

  • Total body weight for the mg/kg regimens. The paper’s simulations are stratified by ideal body weight but its mg/kg doses are per kilogram of total body weight (Table 1: 600 mg / 68 kg = 8.8 mg/kg, matching the printed loading-phase median). The paper does not tabulate paired ideal and total weights, so total body weight is taken here as 68 / 61.4 = 1.107 times ideal body weight – the ratio of the two cohort medians in Table 1. A different ratio scales every mg/kg dose proportionally; the fixed 600 mg arm and the nomogram are unaffected.
  • Covariates held at cohort medians. eGFR is fixed at 92.15 mL/min/1.73 m^2 and age at 62 years in all simulations, matching the paper’s “representative combinations of demographic and clinical covariates” design (Methods 2.5). Only ideal body weight is varied across strata.
  • Cohort size. 200 subjects per arm against the paper’s 1000. The target-attainment percentages therefore carry a Monte-Carlo standard error of roughly 1-3 percentage points; the assertions are set well outside that.
  • Maintenance start time. The paper states the loading count and interval but not the gap before the first maintenance dose. It is taken here as 24 h after the last loading dose, the standard teicoplanin schedule. A 12 h gap would raise the 72 h trough slightly in every arm.
  • Steady state horizon. Profiles are followed for 90 days. At IBW 40 kg the typical half-life is about 190 h, so even 90 days leaves the trough marginally below its asymptote – the closed-form gate above quantifies the residual gap.
  • No between-occasion variability, no residual error in the typical-value checks. The deterministic gates use zeroRe(); the cohort simulations include IIV. The additive residual error of 2.61 mg/L is part of the packaged model but is an observation-level term and is not added to the target- attainment calculations, which the paper computes from individual predicted concentrations.

Errata and source inconsistencies

  • Age coefficient: 0.012, not 0.01. Equation 1 prints (1 + 0.012 x (AGE - 62)) while Table 2 row AGE-CL and the Results prose both give 0.01. Table 2 formats every estimate to two decimal places, so it cannot distinguish the two. The bootstrap column does: it reports a 95% CI of 0.01-0.02 alongside an RSE of 16%. An estimate of 0.012 at RSE 16% implies a CI of about 0.0082-0.0158, which rounds to exactly the printed 0.01-0.02, whereas 0.010 implies 0.0069-0.0131, which would have printed as 0.01-0.01. The printed equation and the bootstrap interval agree on 0.012 and that value is used; standing policy also prefers a printed equation over prose. The practical difference is small – at age 85 the multiplier is 1.276 rather than 1.230, a 3.7% shift in clearance.
  • Devine formula misprinted. The source writes ideal body weight as 50.0 + 2.3 x ((height in cm - 152.4) - 2.54) (and 45.5 for women). The final operator must be a division: (height_cm - 152.4) / 2.54 converts excess height over 60 inches from centimetres to inches. As printed, a 170 cm man would have an ideal body weight of 84.6 kg; the correct division gives 65.9 kg, and only the latter is compatible with a cohort whose median total body weight is 68 kg and median ideal body weight 61.4 kg. This affects only the derivation of the IBW input column, not the model equation.
  • eGFR units. Table 1 heads the row mL/min/1.73m2 while the Table 2 abbreviation footnote writes in mL/min. Table 1 governs: CKD-EPI is BSA-normalised by construction and the Methods never mention de-normalising it. Because the covariate enters as a ratio to the cohort median the choice is partly self-cancelling, but a user supplying a raw mL/min value for a patient whose body surface area is far from 1.73 m^2 will bias the renal term.
  • IIV reduction restated inconsistently. Results 3.2 gives the base-model CL IIV as 47.8% and the Discussion as 47.7%. Neither figure enters the model; only the final 34.1% does.
  • Table 1 C-reactive protein range. The development cohort’s CRP is printed as “7.82 [7.82-43.54]”, with the lower bound of the range equal to the median. This appears to be a transcription slip (the validation cohort’s range is 0.13-47.84). CRP is not a covariate in the final model.
  • IIV scale. Table 2 reports IIV as CV%, and the Methods state a log-normal distribution, so the omega variances are the exact back-transform log(1 + CV^2). Reading the percentages as approximate SDs instead would give omega 0.341 and 0.310 rather than 0.332 and 0.303 – under 3% on the SD, and immaterial for simulation. Reading them as variances is not tenable: an omega^2 of 34.1 would place individual clearance across five orders of magnitude.
  • Supplementary Table S1 not obtained. The paper cites a supplement holding the univariate covariate-screening statistics. The MDPI supplement endpoint returns HTTP 403 to programmatic requests and the EuropePMC supplementary-file endpoint times out. This is not a gap in the model: every final-model parameter is in Table 2 and Equation 1, and Table S1 contains only the screening statistics for covariates that were not retained. Those covariates are documented from the main text in the model file’s covariatesDataExcluded.
  • No erratum. A EuropePMC search returns only the article itself; no correction or corrigendum has been published as of this extraction.
  • No published NCA to compare against. The paper reports no Cmax, Tmax, AUC or half-life values, so the PKNCA output above is validated against exact one-compartment identities rather than against published numbers.
  • Extrapolation warning. The IBW 80 kg stratum lies outside the development cohort’s observed ideal-body-weight range of 46.25-77 kg. The paper flags this itself: “As patients with IBW >= 80 kg were not represented in the model development dataset, these findings rely on model extrapolation and should be interpreted with caution.” Given the exponent of 3.2, extrapolation beyond the fitted range is unusually hazardous – a 10% error in ideal body weight moves clearance by 35%.