The trial-design-dependent SDTM domains, from metadata instead of by hand.

Open-source SDTM tooling covers the mechanical domains well. What is still hand-written on every study is the part that depends on the trial design:

Gap sdtmgap The SAS idiom it replaces
Element code on exposure derive_etcd() The per-study EX-to-TA join plus the BY USUBJID RETAIN/counter step
Subject Elements derive_se() PROC SORT + RETAIN/LAG to stitch element start/stop dates
EPOCH on any domain derive_epoch() PROC SQL non-equi join on between, plus the tie-break usually left out
Subject Visits derive_sv() PROC SQL union + PROC MEANS MIN/MAX by USUBJID VISITNUM
Supplemental qualifiers make_supp() The per-QNAM DATA step block, copied and edited every study

Everything returns a plain data frame, so it drops straight into an existing pipeline. It complements sdtm.oak and admiral rather than replacing either, and does not depend on them.

Install

install.packages(
  "https://clincoder.cloud/sdtmgap/sdtmgap_0.0.0.9000.tar.gz",
  repos = NULL, type = "source"
)

Worked example

Runs as-is on the bundled CDISCPILOT01 data — no data package, no network.

library(sdtmgap)

dm <- sdtmgap_example("lzzt_dm")   # CDISCPILOT01 Demographics, 306 subjects
ex <- sdtmgap_example("lzzt_ex")   # Exposure, 591 records, no ETCD
sv <- sdtmgap_example("lzzt_sv")   # the study's own Subject Visits
ta <- sdtmgap_example("lzzt_ta")   # Trial Arms
te <- sdtmgap_example("lzzt_te")   # Trial Elements

# 1. The element code EX does not carry, read off Trial Arms
ex <- derive_etcd(ex, dm, ta)
table(ex$ETCD)
#> HIE HIM HIS  LO PBO
#>  28  72  72 193 226

# 2. Element source: screening from the first visit, treatment from the dosing
#    records, follow-up to the end of participation. Ends come from contiguity.
first_visit <- stats::aggregate(SVSTDTC ~ USUBJID, data = sv, FUN = min)
elements <- rbind(
  data.frame(USUBJID = first_visit$USUBJID, ETCD = "SCRN",
             SESTDTC = first_visit$SVSTDTC, SEENDTC = NA_character_),
  with(ex, data.frame(USUBJID, ETCD, SESTDTC = EXSTDTC, SEENDTC = NA_character_)),
  with(dm[!is.na(dm$RFENDTC) & !is.na(dm$RFPENDTC) & dm$RFPENDTC > dm$RFENDTC, ],
       data.frame(USUBJID, ETCD = "FOLO", SESTDTC = RFENDTC, SEENDTC = RFPENDTC))
)
elements$DOMAIN <- "SE"

se <- derive_se(dm = dm, exposure = elements, te = te, ta = ta)
#> SDTM SE (889 records)
#> i 306 subject(s) with elements, 889 element record(s).
#> i 52 element(s) took EPOCH from the element alone (arm not in TA).
#> i 229 element(s) got no EPOCH from TA (arm/element not in TA).

# 3. EPOCH on any domain, by interval join on the subject's elements
table(derive_epoch(sv, se, dtc_var = "SVSTDTC")$EPOCH, useNA = "ifany")
#> Screening Treatment      <NA>
#>       622      2826       111

# 4. Subject Visits from the collected domains
derive_sv(list(ex = ex), dm)

# 5. SUPPQUAL from a spec; IDVAR defaults to the parent's --SEQ
make_supp(sdtmgap_example("edge_vs"),
          data.frame(QNAM = "VSCLSIG", QLABEL = "Clinically Significant",
                     QORIG = "CRF", QEVAL = "INVESTIGATOR"))

Does it agree with the real study?

CDISCPILOT01 ships its own SE, built independently by the study team. Joining that to the same TA gives a reference EPOCH for all 3559 SV records, so only the derivation differs. Measured, and asserted in tests/testthat/test-lzzt.R:

Comparison Agreement
EPOCH, records both sides place 3436 / 3436 = 100.0%
EPOCH, all SV records (both-missing counts as agreement) 3528 / 3559 = 99.1%
Element (ETCD), records both sides place 3270 / 3544 = 92.3%
Element start date, elements both sides derive 614 / 733 = 83.8%

No record is put in a different epoch. The 31 EPOCH differences are all about whether to assign: 19 records the study calls Treatment sit in our follow-up element, which CDISCPILOT01’s TA never places in an epoch; 12 are unscheduled visits before the study’s screening element starts, which ours covers.

Element-level agreement is lower for two systematic reasons, both explainable: CDISCPILOT01 starts each element on the day the previous one ended while sdtmgap starts it on the date the source record gives, and the study’s SE was built from the planned arm (ARMCD) while sdtmgap prefers the actual arm (ACTARMCD) — 12 subjects differ between the two.

Design decisions worth knowing

  • Boundaries are inclusive at both ends. SDTMIG elements are contiguous, so a record on a shared boundary matches two elements; the later one wins. A first-dose visit on the day screening ends is TREATMENT.
  • Partial dates are widened, not coerced. "2026-02" is matched as the interval 2026-02-01 .. 2026-02-28 (leap-aware) and written through to the output exactly as collected.
  • Month arithmetic in TEDUR is clamped, not overflowed. P1M from 2026-01-31 ends 2026-02-27 (one month lands on 2026-02-28), not 2026-03-02. Composite forms (P1M15D, P1Y2M3W4D) resolve largest unit first.
  • Nothing is silently dropped. derive_epoch() returns the rows it was given, in order, with EPOCH missing where no element matched. make_supp() does drop missing QVAL — because the standard requires it.
  • Nothing is silently invented. Where CDISCPILOT01’s TA does not place an element in an epoch, EPOCH comes back missing rather than filled with the obvious guess.

Scope and limits

Read these before trusting output.

  1. derive_etcd() resolves the element by ordering only. It uses the arm’s EPOCH == "TREATMENT" elements in TAETORD order: one element takes every dosing record, and n <= k records take the first n of k elements in start-date order. A design whose treatment elements are distinguished by dose level, a titration rule or a branch on response is not resolved by that, and you must attach ETCD from whatever variable carries the distinction. CDISCPILOT01’s high-dose arm happens to be titrated in visit order, which is a property of that trial, not a general law. Where ordering cannot decide (n > k > 1), derive_etcd() aborts and names the subjects.
  2. Element boundaries come from the source records, not from a contiguity snap-back. SEENDTC is filled from the next element’s start when the source has no end date, but a start that the source does give is never moved. That is the main cause of the 92.3% element agreement above.
  3. derive_epoch() places a record at its date’s earliest possible instant. A time-of-day is used when present, but a record with only "2026-02" is placed as if on the 1st. It is never excluded from an element it might belong to, but it may be placed in the earlier of two it could belong to.
  4. Durations with a time component are not resolved. PT12H, P1DT12H, the alternate P0003-06-04 form, and an all-zero duration all return NA and leave the element open-ended. A date-valued SEENDTC cannot carry hours.
  5. Interop with sdtm.oak is untested. sdtmgap deliberately does not depend on it and returns plain tibbles, which sdtm.oak accepts by construction — but sdtm.oak is not installed on the machine this package was built and checked on, so no chain of the two has ever been run. Treat “drops straight into an sdtm.oak pipeline” as a design intent, not a verified claim. Interop with admiral, rtables and xportr rests on the same plain-data-frame contract and is likewise unexercised here.
  6. Trial design beyond TE/TA is bundled but not consumed. lzzt_tv, lzzt_ti and lzzt_ts ship so the design is complete and inspectable; no function reads them. Visit windowing from TV in particular is not implemented — derive_sv() uses collected dates only.

Fixtures

  • lzzt_*CDISCPILOT01, the “LZZT” xanomeline Alzheimer’s study from the CDISC SDTM/ADaM pilot submission package, published by the CDISC Data Exchange Standards Team and coded to SDTMIG 3.1.2. Real data: lzzt_te, lzzt_ta, lzzt_tv, lzzt_ti, lzzt_ts, lzzt_dm, lzzt_ex, lzzt_se, lzzt_sv. Whole datasets, not samples; lzzt_dm and lzzt_ex keep only the columns the examples use, and text was transcoded from Windows-1252 to ASCII.
  • edge_* — a fictional 7-subject study that exists only to hold the awkward cases CDISCPILOT01 does not have, so they can be tested at all: an element with no end date, a subject with no follow-up element, a screen failure with no elements, partial dates, a visit on a shared element boundary, and a record dated before the study began.

Vignette

vignette("getting-started", package = "sdtmgap") runs the whole pipeline on CDISCPILOT01 and measures the agreement above.

License

MIT (c) Bhanoji Duppada