Exposure-response and population PK analysis datasets, built to a documented structure instead of a new one every study.

Open-source clinical R has a standard for almost everything except this. admiral will build you an ADaM dataset, rtables will summarise it — but exposure-response and population PK analysis datasets have no CDISC structure, so every study invents one, and the pharmacometrician receives something different each time: a different name for time after dose, a different rule for values below the limit of quantification, a different guess at whether the dose record comes before or after the sample taken at the same hour.

eradam produces those datasets to one structure, documents it, and returns the documentation as data.

PC + EX + ADSL  ->  ADPC        (concentrations, explicit BLQ policy)
                ->  PopPK       (dosing/observation records interleaved, NONMEM items)
                ->  exposure    (CMAX, TMAX, CMIN, CTROUGH, AUCLST)
                ->  terminal    (LAMZ, LAMZHL, AUCIFO/AUCIFP, %extrapolated + fit diagnostics)
                ->  partial     (AUCINT over a stated window, edge rule documented)
                ->  ADER        (exposure joined to response, quantile groups)

Install

install.packages(
  "https://clincoder.cloud/eradam/eradam_0.0.0.9001.tar.gz",
  repos = NULL, type = "source"
)

Worked example

Everything below runs on the fixtures shipped with the package — no network, no private data.

library(eradam)

pc   <- eradam_example("pc")     # 576 concentrations, 24 subjects, 2 analytes
ex   <- eradam_example("ex")     # 2 doses per subject, at 0 h and 24 h
adsl <- eradam_example("adsl")
resp <- eradam_example("resp")   # percent change from baseline in a biomarker

# 1. concentration analysis dataset: BLQ imputed at half the limit, and said so
adpc <- make_adpc(pc, adsl, ex, blq = "half")
adpc[1:3, c("USUBJID", "PARAMCD", "NFRLT", "AFRLT", "ARRLT", "AVAL", "BLQFL", "DTYPE")]
#> # A tibble: 3 x 8
#>   USUBJID      PARAMCD NFRLT AFRLT ARRLT   AVAL BLQFL DTYPE
#>   <chr>        <chr>   <dbl> <dbl> <dbl>  <dbl> <chr> <chr>
#> 1 ERADAM01-001 DRUGX     0   0     0      0.25  Y     LOQ
#> 2 ERADAM01-001 DRUGX     0.5 0.58  0.58 230.    N     NA
#> 3 ERADAM01-001 DRUGX     1   1.01  1.01 348.    N     NA

# 2. population PK dataset: dose records first when a dose and a sample share a time
pk <- make_poppk(adpc, ex, adsl, covariates = c("AGE", "SEX", "WTBL", "CRCL"))
pk[pk$ID == 1, c("ID", "TIME", "TAD", "AMT", "DV", "MDV", "EVID", "CMT")][1:4, ]
#> # A tibble: 4 x 8
#>      ID  TIME   TAD   AMT     DV   MDV  EVID   CMT
#>   <int> <dbl> <dbl> <dbl>  <dbl> <int> <int> <int>
#> 1     1 0     0        25   NA       1     1     1
#> 2     1 0     0        NA    0.25    0     0     2
#> 3     1 0     0        NA    0.1     0     0     3
#> 4     1 0.497 0.497    NA   79.6     0     0     3

# 3. exposure metrics, AUC by the linear trapezoidal rule
expo <- derive_exposure_metrics(adpc[adpc$PARAMCD == "DRUGX", ], ex)
expo[1, c("USUBJID", "CMAX", "TMAX", "CTROUGH", "AUCLST", "AUCLSTD")]
#> # A tibble: 1 x 6
#>   USUBJID       CMAX  TMAX CTROUGH AUCLST AUCLSTD
#>   <chr>        <dbl> <dbl>   <dbl>  <dbl>   <dbl>
#> 1 ERADAM01-001  378.  2.03    14.8  6686.    134.

# 4. terminal phase: lambda z with its fit diagnostics, half-life, AUC to infinity
tp <- derive_terminal_phase(adpc[adpc$PARAMCD == "DRUGX", ], method = "lin_up_log_down")
tp[1:2, c("USUBJID", "LAMZ", "LAMZHL", "LAMZNPT", "LAMZLL", "LAMZUL", "R2ADJ", "AUCPEO", "AUCPEFL")]
#> # A tibble: 2 x 9
#>   USUBJID        LAMZ LAMZHL LAMZNPT LAMZLL LAMZUL R2ADJ AUCPEO AUCPEFL
#>   <chr>         <dbl>  <dbl>   <int>  <dbl>  <dbl> <dbl>  <dbl> <chr>
#> 1 ERADAM01-001 0.148    4.69       3   28.0   48.0 0.999   1.67 N
#> 2 ERADAM01-002 0.101    6.85       3   28.1   48.0 0.996   5.48 N

# 10 of the 24 profiles get NA, and say why rather than returning a number
sum(is.na(tp$LAMZ))
#> [1] 10
unique(tp$LAMZNRS[!is.na(tp$LAMZNRS)])[1]
#> [1] "Only 2 quantifiable points after TMAX (28); 3 required."

# 5. partial AUC over a stated window, with the edge rule documented
derive_partial_auc(adpc[adpc$PARAMCD == "DRUGX", ], 0, 24)[1:2, ]
#> # A tibble: 2 x 8
#>   USUBJID      PARAMCD AUCINTLL AUCINTUL AUCINT NOBSINT INTRPFL AUCINTRS
#>   <chr>        <chr>      <dbl>    <dbl>  <dbl>   <int> <chr>   <chr>
#> 1 ERADAM01-001 DRUGX          0       24  3304.       9 Y       NA
#> 2 ERADAM01-002 DRUGX          0       24  5514.       9 Y       NA

# 6. exposure-response dataset with labelled exposure quartiles
ader <- make_ader(expo, resp)
round(tapply(ader$AVAL, ader$EXPQ, mean), 1)
#>  Q1 (6680-11900) Q2 (11900-22400) Q3 (22400-32200) Q4 (32200-53000)
#>            -17.9            -27.9            -32.5            -41.7

The structure is the deliverable

Every variable the package produces, its meaning and where it came from, returned as data so it can go straight into a define document or a data specification:

eradam_structure("poppk")
#> # A tibble: 13 x 4
#>    dataset variable label                                          source
#>    <chr>   <chr>    <chr>                                          <chr>
#>  1 poppk   ID       Sequential integer subject identifier (1..n)…  Derived from USUBJID
#>  2 poppk   USUBJID  Unique subject identifier, retained for trac…  `pc` / `ex`
#>  3 poppk   TIME     Actual time from first dose (h)                `pc$AFRLT` / `ex$AFRLT`
#>  …

The same table is rendered into each function’s help page, so the code and the documentation cannot drift apart.

Conventions followed, and conventions chosen

Where a real standard exists, eradam follows it and says which:

  • NFRLT, AFRLT, NRRLT, ARRLT and the negative pre-dose ARRLT come from the CDISC ADaM Implementation Guide for Non-compartmental Analysis Input Data (ADNCA v1.0).
  • DTYPE = "LOQ" is the ADaM controlled-terminology value for a value imputed from the limit of quantification. AVAL, AVALC, AVALU, PARAMCD, PARAM, ANL01FL are ADaM BDS variables.
  • CMAX, TMAX, CMIN, CTROUGH, AUCLST are CDISC PPTESTCD parameter codes. So are the terminal-phase and partial-area codes: LAMZ, LAMZHL, LAMZLL, LAMZUL, LAMZNPT, R2, R2ADJ, AUCIFO, AUCIFP, AUCPEO, AUCPEP, CLST, TLST and AUCINT, used with their CDISC meanings.
  • ID, TIME, AMT, DV, MDV, EVID, CMT are NONMEM data items with their NONMEM meanings.
  • AUC by the linear trapezoidal rule, and the “linear up / log down” variant as used by non-compartmental analysis software.
  • The best-fit rule for the λz window — regress log concentration on time over every terminal window from three points up, and take the longest window whose adjusted R² is within 0.0001 of the best — is the accepted pharmacometric convention, as is excluding TMAX and everything before it, the three-point minimum, t½ = ln(2) / λz, AUCIFO = AUCLST + CLST / λz, AUCIFP = AUCLST + CLSTP / λz, and the 20% extrapolated-fraction acceptance threshold.
  • Interpolating a partial-AUC window edge linearly under method = "linear" and logarithmically on a descending segment under "lin_up_log_down" is what non-compartmental analysis software does.

Where no standard exists, the package says so rather than implying otherwise. These are eradam’s own conventions:

  • the wide one-row-per-subject layout for exposure, terminal-phase and partial AUC results — CDISC would carry each parameter as its own record in PP/ADPP;
  • the CTROUGH definition, the BLQFL/LLOQ names, the compartment numbering, and every EXP* variable in the exposure-response dataset;
  • CLSTP for the fitted concentration at TLST, AUCPEFL for the 20% flag, LAMZNRS for the reason λz is missing, and AUCINTLL/AUCINTUL/NOBSINT/ INTRPFL/AUCINTRS around AUCINT — the parameters are standard, these wrapper names are not;
  • returning NA with a written reason instead of a number when a λz fit fails, rather than the alternative practice of reporting the fit anyway and leaving the reviewer to spot it;
  • refusing to compute a partial AUC whose window falls outside the observed range. Back-extrapolating to C = 0 at time zero and forward-extrapolating the tail with λz are both real conventions used elsewhere; picking one silently is how two “AUC0-24” columns end up disagreeing, so this package returns NA and the reason instead. Use derive_terminal_phase() when you want the tail, because it says how much it extrapolated;
  • AUCLST in derive_terminal_phase() runs to the last positive concentration (the non-compartmental convention), while derive_exposure_metrics() runs its AUCLST over every non-missing record. The two agree unless a profile has trailing zeros — which it will after make_adpc(blq = "zero").

All of it is documented in ?derive_exposure_metrics, ?derive_terminal_phase, ?derive_partial_auc, ?make_adpc, ?make_poppk and ?make_ader.

Scope and limits

What this package does not do, stated so it does not have to be discovered:

  • No steady-state parameters. AUCTAU, CAVG, CLSS, accumulation ratio and fluctuation are absent. derive_partial_auc() over a dosing interval gives the same number as AUCTAU, under the name AUCINT, and nothing else.
  • No clearance, volume or moment parameters. CL, CLF, VZ, VZF, MRT, AUMC and VSS are absent, so derive_terminal_phase() stops at λz, and AUCINF.
  • One profile per by group. derive_terminal_phase() treats everything in a group as a single profile. On the shipped two-dose fixture that is visible: TMAX for 10 of the 24 subjects falls after the second dose, leaving only two terminal points, and those 10 correctly return NA with the reason. For a multiple-dose study, subset to the interval you mean before calling.
  • No manual λz window. The window is chosen by the best-fit rule or not at all; there is no way to force a specific set of points, which a regulatory NCA usually needs.
  • No sparse sampling. Every parameter assumes a full profile per subject; Bailer-type variance estimation for destructive or sparse designs is absent.
  • No BLQ handling inside the NCA functions. They use the concentrations they are given. The policy lives in make_adpc(), on purpose, so it is visible in the delivered dataset — which is why derive_partial_auc(pc, 0, 24) on the raw fixture returns NA: the 0 h sample is below the limit of quantification and still missing until make_adpc() has decided what to do with it.
  • No validated-software claim. These are open implementations of published rules with their arithmetic written down and tested against analytic values. A regulatory submission wants a validated NCA package; use one.

?derive_exposure_metrics, ?derive_terminal_phase and ?derive_partial_auc each repeat the limits that apply to them.

Validated by design

  • testthat suite asserting on values, including the AUC of a hand-computed curve (0, 10, 20, 10 at 0, 1, 2, 3 h: 35 linear, 34.42695 linear up / log down) and the record ordering and event flags of the population PK dataset.
  • The terminal-phase arithmetic is checked against a synthetic mono-exponential decay C = 100 exp(-0.1 t) where every answer has a closed form. Measured on that curve: λz recovers 0.1 to 1.4e-17 absolute, to 8.9e-16 h, and AUCIFO/AUCIFP reproduce the analytic 1000 exactly under lin_up_log_down (the log trapezoid is exact on an exponential). Under the linear trapezoid the same AUCIFO reads +2.144% high, which the tests pin as a number rather than hide behind a loose tolerance.
  • Partial AUC over the full observed window equals AUCLST exactly, and AUC[2, 5] on the same exponential matches the analytic value to 0 relative error under lin_up_log_down (3.3e-3 under the linear rule, from the chord interpolation at the 5 h edge).
  • Every example, test and vignette runs offline against the shipped fixtures.
  • Built on the pharmaverse: the ADSL merge goes through admiral::derive_vars_merged(), and every return value is a plain tibble that admiral, rtables and xportr accept unchanged.

License

MIT (c) Bhanoji Duppada