| name | sdtm-oak |
| description | Derives CDISC SDTM domains from raw clinical (EDC/eCRF) data using the {sdtm.oak} R package. Use when a user needs to map raw study data to SDTM Events (AE, CM, MH), Findings (VS, LB, EG), or Interventions (EX) domains following the sdtm.oak algorithm framework. Produces executable, submission- ready R code with controlled terminology recoding, ISO 8601 date derivation, sequence numbering, and study day calculation.
|
| license | MIT |
| metadata | {"author":"pharma-skills contributors","version":"0.1","pharmaverse":"true"} |
| compatibility | Requires R with sdtm.oak (>= 0.2.0), dplyr, and tibble installed. Requires raw EDC/eCRF data and a controlled terminology (CT) specification CSV. Designed for use in a GxP-compliant environment.
|
sdtm-oak
Derives CDISC SDTM domains from raw clinical data using the {sdtm.oak}
algorithm framework. Outputs executable R code with full derivation traceability.
See references/oak-functions.md for the full
function reference.
Inputs
Before generating code, confirm:
| Input | Required | Notes |
|---|
| Raw EDC dataset | Yes | e.g. ae_raw, vs_raw — raw CRF form data |
| CT specification | Yes | CSV in CDISC codelist format; load via read_ct_spec() |
| Domain specification | Yes | Variable list, CT codelists per variable, date formats |
| DM domain | For study day / BLFL | Provides RFSTDTC for derive_study_day() and derive_blfl() |
Always inspect the raw dataset first — raw column names vary by EDC system
and study. Print names(raw_dat) and head(raw_dat) before writing any
derivations.
Core algorithms
sdtm.oak provides six mapping algorithms. Choose based on whether the target
variable has controlled terminology (CT) and whether the value is derived from
raw data or hardcoded.
| Algorithm | CT? | Source | Use for |
|---|
assign_no_ct() | No | Raw column | Free-text variables: AETERM, CMTRT, VSORRES |
assign_ct() | Yes | Raw column | CT-mapped from raw: AESEV, AESER, SEX, RACE |
hardcode_no_ct() | No | Fixed value | Study-constant free-text: STUDYID, custom flags |
hardcode_ct() | Yes | Fixed value | Domain constants validated against CT: DOMAIN |
assign_datetime() | — | Raw date col(s) | Any --DTC variable: AESTDTC, VSDTC, EXSTDTC |
condition_add() | — | Condition expr | Gate any of the above to a row subset |
All six functions share the same id_vars join key (default: oak_id_vars())
and the same tgt_dat pipe pattern — pass the growing SDTM dataset as
tgt_dat to accumulate variables.
Workflow
Follow these steps in order. Write code section by section, not as a single block.
Step 1 — Setup and data inspection
library(sdtm.oak)
library(dplyr)
ae_raw <- <load raw AE data>
cat("Columns:\n"); print(names(ae_raw))
cat("Rows:", nrow(ae_raw), "\n")
print(head(ae_raw, 3))
Step 2 — Generate oak ID variables
generate_oak_id_vars() adds three key columns used as join keys throughout
all subsequent derivations. Call this once on the raw dataset.
ae_oak <- generate_oak_id_vars(
raw_dat = ae_raw,
pat_var = "patient_number",
raw_src = "AE_FORM"
)
Step 3 — Load controlled terminology
ct_spec <- read_ct_spec_example()
assert_ct_spec(ct_spec)
Step 4 — Hardcode domain constants
Fixed values that apply to every record in the domain. Use hardcode_ct() for
values validated against CT (DOMAIN); use hardcode_no_ct() for free-text constants.
ae_domain <- ae_oak |>
hardcode_ct(
tgt_var = "DOMAIN",
tgt_val = "AE",
raw_dat = ae_raw,
raw_var = "AETERM",
ct_spec = ct_spec,
ct_clst = "DOMAIN"
)
Step 5 — Assign free-text variables (assign_no_ct)
Use for variables with no CT restriction — raw text carried directly.
ae_domain <- ae_domain |>
assign_no_ct(
tgt_var = "AETERM",
raw_dat = ae_raw,
raw_var = "ae_term"
) |>
assign_no_ct(
tgt_var = "AELOC",
raw_dat = ae_raw,
raw_var = "ae_location"
)
Step 6 — Assign CT-mapped variables (assign_ct)
Use for variables whose values must be recoded to CDISC controlled terminology.
Supply ct_clst matching the codelist name in your CT spec.
ae_domain <- ae_domain |>
assign_ct(
tgt_var = "AESEV",
raw_dat = ae_raw,
raw_var = "severity",
ct_spec = ct_spec,
ct_clst = "AESEV"
) |>
assign_ct(
tgt_var = "AESER",
raw_dat = ae_raw,
raw_var = "serious_ae",
ct_spec = ct_spec,
ct_clst = "NY"
) |>
assign_ct(
tgt_var = "AEREL",
raw_dat = ae_raw,
raw_var = "causality",
ct_spec = ct_spec,
ct_clst = "AEREL"
) |>
assign_ct(
tgt_var = "AEOUT",
raw_dat = ae_raw,
raw_var = "outcome",
ct_spec = ct_spec,
ct_clst = "AEOUT"
)
Step 7 — Assign datetime variables (assign_datetime)
Use for all --DTC variables. Never use as.Date(), as.POSIXct(), or
string manipulation for SDTM dates — always use assign_datetime().
ae_domain <- ae_domain |>
assign_datetime(
tgt_var = "AESTDTC",
raw_dat = ae_raw,
raw_var = "onset_date",
raw_fmt = "d-m-y"
) |>
assign_datetime(
tgt_var = "AEENDTC",
raw_dat = ae_raw,
raw_var = "resolution_date",
raw_fmt = "d-m-y"
)
For combined date-time (e.g. separate date and time columns):
ae_domain <- ae_domain |>
assign_datetime(
tgt_var = "AESTDTC",
raw_dat = ae_raw,
raw_var = c("onset_date", "onset_time"),
raw_fmt = c("d-m-y", "H:M")
)
Step 8 — Conditional derivations (condition_add)
Use condition_add() to restrict a derivation to a subset of records. Wrap
the target dataset in condition_add(), then pass it as tgt_dat.
ae_domain <- ae_domain |>
(\(dat) assign_ct(
tgt_dat = condition_add(dat, AEOUT == "FATAL"),
tgt_var = "AEDTHFL",
raw_dat = ae_raw,
raw_var = "death_flag",
ct_spec = ct_spec,
ct_clst = "NY"
))()
Step 9 — Add STUDYID and USUBJID
Derive subject-level identifiers after domain variables are built.
ae_domain <- ae_domain |>
hardcode_no_ct(
tgt_var = "STUDYID",
tgt_val = "CDISCPILOT01",
raw_dat = ae_raw,
raw_var = "patient_number"
) |>
assign_no_ct(
tgt_var = "USUBJID",
raw_dat = ae_raw,
raw_var = "patient_number"
)
Step 10 — Study day derivation
Requires DM domain (provides RFSTDTC).
ae_domain <- derive_study_day(
sdtm_in = ae_domain,
dm_domain = dm,
tgdt = "AESTDTC",
refdt = "RFSTDTC",
study_day_var = "AESTDY"
) |>
derive_study_day(
sdtm_in = _,
dm_domain = dm,
tgdt = "AEENDTC",
refdt = "RFSTDTC",
study_day_var = "AEENDY"
)
Step 11 — Sequence number
ae_domain <- derive_seq(
sdtm_in = ae_domain,
tgt_var = "AESEQ"
)
Step 12 — Supplemental domain (SUPP--)
If the study collects non-standard variables, split them to SUPPAE.
result <- generate_sdtm_supp(
sdtm_dataset = ae_domain,
idvar = "AESEQ",
supp_qual_info = supp_spec,
qnam_var = "QNAM",
label_var = "QLABEL",
orig_var = "QORIG"
)
ae_final <- result$sdtm
suppae <- result$supp
Step 13 — Final checks
required_vars <- c("STUDYID", "DOMAIN", "USUBJID", "AESEQ",
"AETERM", "AESTDTC")
missing_vars <- setdiff(required_vars, names(ae_final))
if (length(missing_vars) > 0) {
stop("Missing required AE variables: ", paste(missing_vars, collapse = ", "))
}
stopifnot(
ae_final |>
count(STUDYID, USUBJID, AESEQ) |>
filter(n > 1) |>
nrow() == 0
)
cat("AE domain: ", nrow(ae_final), "records,",
n_distinct(ae_final$USUBJID), "subjects\n")
Findings domains (VS, LB, EG)
Findings domains (one record per subject per test per visit) follow a different
stacking pattern — derive each TESTCD separately, then bind_rows().
sysbp <- generate_oak_id_vars(vs_raw, pat_var = "patient_number",
raw_src = "VS_FORM") |>
hardcode_ct(tgt_var = "VSTESTCD", tgt_val = "SYSBP",
raw_dat = vs_raw, raw_var = "SYSBP_result",
ct_spec = ct_spec, ct_clst = "VSTESTCD") |>
hardcode_no_ct(tgt_var = "VSTEST", tgt_val = "Systolic Blood Pressure",
raw_dat = vs_raw, raw_var = "SYSBP_result") |>
assign_no_ct(tgt_var = "VSORRES", raw_dat = vs_raw, raw_var = "SYSBP_result") |>
assign_no_ct(tgt_var = "VSORRESU", raw_dat = vs_raw, raw_var = "SYSBP_unit") |>
assign_datetime(tgt_var = "VSDTC", raw_dat = vs_raw,
raw_var = "visit_date", raw_fmt = "d-m-y")
diabp <- generate_oak_id_vars(vs_raw, pat_var = "patient_number",
raw_src = "VS_FORM") |>
hardcode_ct(tgt_var = "VSTESTCD", tgt_val = "DIABP", ...) |>
...
vs_domain <- bind_rows(sysbp, diabp, pulse, weight, height, temp) |>
hardcode_ct(tgt_var = "DOMAIN", tgt_val = "VS",
raw_dat = vs_raw, raw_var = "patient_number",
ct_spec = ct_spec, ct_clst = "DOMAIN") |>
derive_seq(tgt_var = "VSSEQ")
For findings, also derive VSBLFL (baseline flag) when applicable:
vs_domain <- derive_blfl(
sdtm_in = vs_domain,
dm_domain = dm,
tgt_var = "VSBLFL",
ref_var = "VSDTC",
baseline_visits = c("BASELINE", "DAY 1")
)
# REVIEW: annotation rules
Place a # REVIEW: comment whenever a derivation contains a protocol-specific
decision that a QC reviewer must verify. Required locations:
| Location | What to annotate |
|---|
generate_oak_id_vars() | pat_var column name and raw_src label |
Every assign_datetime() | raw_fmt format string — must match actual raw data |
Every assign_ct() | ct_clst codelist name — wrong codelist silently miscodes |
Every condition_add() | The business rule the condition implements |
derive_blfl() baseline_visits | Protocol baseline visit definition |
derive_study_day() refdt | Reference date choice (RFSTDTC vs RFXSTDTC) |
Any hardcoded tgt_val | Confirm value is correct for this study |
Common errors to avoid
- Using
as.Date(), substr(), or format() on raw date columns instead of
assign_datetime() — ISO 8601 partial date handling and unknown-date
placeholders ("UN", "UNK") are only correctly handled by assign_datetime()
- Skipping
generate_oak_id_vars() — all assign_* and hardcode_* functions
require oak_id, raw_source, and patient_number columns to be present as
join keys; the dataset will silently return wrong results without them
- Passing the wrong
raw_dat to an assign_* call — raw_dat must always be
the original raw dataset, not the growing tgt_dat; mixing them causes
incorrect joins
- Using
mutate() or rename() for variable mapping instead of assign_no_ct()
— mutate bypasses the oak traceability framework and does not respect id_vars
join semantics
- Using
assign_no_ct() for CT-mapped variables — values will not be recoded
to CDISC terminology; use assign_ct() whenever a codelist applies
- Using
assign_ct() with the wrong ct_clst — no error is raised; unmatched
values are silently uppercased; always verify output distribution after derivation
- Skipping
assert_ct_spec() before first use — malformed CT specs produce
silent miscoding
- Not calling
derive_seq() — --SEQ is required for all SDTM domains; do not
derive it manually with row_number()
- Mixing up
hardcode_ct() vs hardcode_no_ct() for DOMAIN — DOMAIN must use
hardcode_ct() (validated against the DOMAIN codelist); using hardcode_no_ct()
will accept any string
- For findings domains: building the full stacked dataset with
bind_rows()
before adding common variables (DOMAIN, USUBJID, study day) — add per-test
variables in each parameter block, then add common variables after stacking
Output checklist
Before returning code, verify: