library(admiral)
library(dplyr)
library(lubridate)
dm <- tribble(
~STUDYID, ~USUBJID, ~AGE, ~SEX, ~ARM, ~COUNTRY,
"XYZ-1", "01-101", 64, "F", "Drug A", "TUR",
"XYZ-1", "01-102", 71, "M", "Drug A", "TUR",
"XYZ-1", "01-103", 58, "F", "Placebo", "DEU",
"XYZ-1", "01-104", 80, "M", "Placebo", "DEU",
"XYZ-1", "01-105", 47, "F", "Drug A", "GBR"
)
ex <- tribble(
~STUDYID, ~USUBJID, ~EXSTDTC, ~EXENDTC,
"XYZ-1", "01-101", "2026-01-10", "2026-06-30",
"XYZ-1", "01-102", "2026-01-14", "2026-03-02",
"XYZ-1", "01-103", "2026-01-11", "2026-06-28",
"XYZ-1", "01-104", "2026-01-20", "2026-05-15"
# 01-105 randomised but never dosed - watch what happens to her flags
)ADaM Derivations in R with admiral: a Mini ADSL, End to End
If your statistical programming lives in the CDISC world, the question is no longer can R do ADaM — the pharmaverse exists precisely for this, and its flagship package admiral is developed openly by Roche, GSK and friends. The real question is what a derivation actually looks like. Here is a miniature but honest ADSL flow: SDTM-shaped inputs in, analysis-ready dataset out, every variable traceable.
SDTM-shaped inputs
In production these come from your SDTM datasets (DM, EX, DS). For a self-contained example we synthesise tiny versions with the same structure:
Derive treatment dates and flags with admiral
derive_vars_merged() is admiral’s workhorse: merge a summarised source onto the target with explicit semantics, no silent many-to-many surprises.
adsl <- dm |>
derive_vars_merged(
dataset_add = ex,
by_vars = exprs(STUDYID, USUBJID),
order = exprs(EXSTDTC),
mode = "first",
new_vars = exprs(TRTSDTC = EXSTDTC)
) |>
derive_vars_merged(
dataset_add = ex,
by_vars = exprs(STUDYID, USUBJID),
order = exprs(EXENDTC),
mode = "last",
new_vars = exprs(TRTEDTC = EXENDTC)
) |>
derive_vars_dt(new_vars_prefix = "TRTS", dtc = TRTSDTC) |>
derive_vars_dt(new_vars_prefix = "TRTE", dtc = TRTEDTC) |>
mutate(
SAFFL = if_else(!is.na(TRTSDT), "Y", "N"), # safety population: treated
ITTFL = "Y", # ITT: all randomised
TRT01P = ARM,
TRT01A = if_else(SAFFL == "Y", ARM, NA_character_)
)
adsl |> select(USUBJID, TRT01P, TRTSDT, TRTEDT, SAFFL, ITTFL)# A tibble: 5 × 6
USUBJID TRT01P TRTSDT TRTEDT SAFFL ITTFL
<chr> <chr> <date> <date> <chr> <chr>
1 01-101 Drug A 2026-01-10 2026-06-30 Y Y
2 01-102 Drug A 2026-01-14 2026-03-02 Y Y
3 01-103 Placebo 2026-01-11 2026-06-28 Y Y
4 01-104 Placebo 2026-01-20 2026-05-15 Y Y
5 01-105 Drug A NA NA N Y
Subject 01-105 shows why flags matter: randomised, so ITTFL = "Y" — but never dosed, so SAFFL = "N" and she has no actual treatment. An intention-to-treat table includes her; a safety table must not. The estimand language of ICH E9(R1) starts exactly here.
Age groups and treatment duration
adsl <- adsl |>
mutate(
AGEGR1 = case_when(AGE < 65 ~ "<65", AGE < 75 ~ "65-<75", TRUE ~ ">=75"),
TRTDURD = as.integer(TRTEDT - TRTSDT) + 1
)
adsl |> select(USUBJID, AGE, AGEGR1, TRTDURD)# A tibble: 5 × 4
USUBJID AGE AGEGR1 TRTDURD
<chr> <dbl> <chr> <dbl>
1 01-101 64 <65 172
2 01-102 71 65-<75 48
3 01-103 58 <65 169
4 01-104 80 >=75 116
5 01-105 47 <65 NA
Why this beats a hand-rolled script
- Traceability — each admiral call names its source, keys and selection rule; the derivation is the documentation your define.xml points to.
- Convention over improvisation — variable names (
TRT01P,SAFFL,AGEGR1) follow ADaM, so downstream TLF code and reviewers both know what they are looking at. - Testable — derivations are functions, so they can carry unit tests; your QC programmer reruns them against the spec instead of eyeballing a DATA step.
In production you would add DTHFL/DTHDT from DS, last-known-alive dates, and time-to-event variables via derive_vars_duration() — the grammar stays identical.
Need ADaM-style datasets or their QC done in R? That’s part of our CRO services — or see the SAS to R migration guide if the whole pipeline is moving.