--- 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`](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 ```r library(sdtm.oak) library(dplyr) # Load raw data — replace with actual source ae_raw <- # e.g. sdtm.oak::ae_raw for the package example # ALWAYS inspect before writing derivations 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. ```r # REVIEW: Set pat_var to the column holding the subject/patient identifier # in this raw dataset. Set raw_src to the raw dataset object name (e.g. "ae_raw", # "vs_raw") — pharmaverse convention uses the dataset name, not a CRF form label. ae_oak <- generate_oak_id_vars( raw_dat = ae_raw, pat_var = "patient_number", # confirm column name from Step 1 inspection raw_src = "ae_raw" # pharmaverse convention: use the raw dataset name ) # Adds: oak_id (row key), raw_source (form label), patient_number (subj ID) ``` ### Step 3 — Load controlled terminology ```r # REVIEW: Replace path with the study CT spec CSV. # For the sdtm.oak package example data, use read_ct_spec_example(). ct_spec <- read_ct_spec_example() # or: read_ct_spec("path/to/ct_spec.csv") # Validate before use 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. ```r ae_domain <- ae_oak |> # DOMAIN is a CT-controlled variable — use hardcode_ct hardcode_ct( tgt_var = "DOMAIN", tgt_val = "AE", raw_dat = ae_raw, raw_var = "AETERM", # presence filter: only rows with a non-NA AE term 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. ```r ae_domain <- ae_domain |> assign_no_ct( tgt_var = "AETERM", raw_dat = ae_raw, raw_var = "ae_term" # REVIEW: confirm raw column name ) |> 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. ```r # REVIEW: Confirm ct_clst names match the codelist_code column in ct_spec. # Wrong ct_clst silently returns the uppercased raw value — verify outputs. 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()`. ```r # REVIEW: raw_fmt must exactly match the date format in the raw data. # Use "y-m-d" for ISO (2024-03-15), "d/m/y" for European (15/03/2024), # "m/d/y" for US (03/15/2024). Check format from Step 1 inspection. # Supply a list of alternatives if the format is inconsistent across records. ae_domain <- ae_domain |> assign_datetime( tgt_var = "AESTDTC", raw_dat = ae_raw, raw_var = "onset_date", raw_fmt = "d-m-y" # REVIEW: confirm raw date format ) |> assign_datetime( tgt_var = "AEENDTC", raw_dat = ae_raw, raw_var = "resolution_date", raw_fmt = "d-m-y" # REVIEW: confirm raw date format ) ``` For combined date-time (e.g. separate date and time columns): ```r ae_domain <- ae_domain |> assign_datetime( tgt_var = "AESTDTC", raw_dat = ae_raw, raw_var = c("onset_date", "onset_time"), # two columns raw_fmt = c("d-m-y", "H:M") # one format per column ) ``` ### 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`. ```r # REVIEW: condition_add() criteria must reflect the study protocol. # Document the business rule the condition implements. ae_domain <- ae_domain |> # Example: derive AEDTHFL only for fatal outcome records (\(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. ```r ae_domain <- ae_domain |> hardcode_no_ct( tgt_var = "STUDYID", tgt_val = "CDISCPILOT01", # REVIEW: replace with actual study ID raw_dat = ae_raw, raw_var = "patient_number" ) |> assign_no_ct( tgt_var = "USUBJID", raw_dat = ae_raw, raw_var = "patient_number" # REVIEW: confirm USUBJID construction rule ) ``` ### Step 10 — Study day derivation Requires DM domain (provides RFSTDTC). ```r # REVIEW: Confirm which DTC variable is the reference date for this domain # (RFSTDTC for most event domains; RFXSTDTC for findings relative to dosing). 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 ```r 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. ```r # REVIEW: Confirm which variables belong in SUPPAE vs the main domain. # Non-standard variables must not appear in the parent domain. result <- generate_sdtm_supp( sdtm_dataset = ae_domain, idvar = "AESEQ", supp_qual_info = supp_spec, # dataframe: QNAM, QLABEL, QORIG per variable qnam_var = "QNAM", label_var = "QLABEL", orig_var = "QORIG" ) ae_final <- result$sdtm suppae <- result$supp ``` ### Step 13 — Final checks ```r # Required SDTM variables for AE domain 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 = ", ")) } # No duplicate sequence numbers 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()`. ```r # REVIEW: Each parameter block must align with the CT codelist for VSTESTCD. # Stack only parameters in scope for this study per the CRF and SAP. # Parameter 1: Systolic Blood Pressure sysbp <- generate_oak_id_vars(vs_raw, pat_var = "patient_number", raw_src = "vs_raw") |> 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") # Parameter 2: Diastolic Blood Pressure — same pattern, different raw_var diabp <- generate_oak_id_vars(vs_raw, pat_var = "patient_number", raw_src = "vs_raw") |> hardcode_ct(tgt_var = "VSTESTCD", tgt_val = "DIABP", ...) |> ... # Stack all parameters 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: ```r # REVIEW: Confirm baseline visit name(s) from the protocol. vs_domain <- derive_blfl( sdtm_in = vs_domain, dm_domain = dm, tgt_var = "VSBLFL", ref_var = "VSDTC", baseline_visits = c("BASELINE", "DAY 1") # REVIEW: protocol-specific ) ``` --- ## `# 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; `raw_src` must be the raw dataset name (e.g. `"ae_raw"`) | | 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: - [ ] Raw dataset inspected (`names()` + `head()`) before first derivation - [ ] `generate_oak_id_vars()` called first with correct `pat_var` and `raw_src` - [ ] CT spec loaded and validated with `assert_ct_spec()` - [ ] DOMAIN hardcoded with `hardcode_ct()` — not `hardcode_no_ct()` - [ ] All `--DTC` variables derived with `assign_datetime()` — not `as.Date()` - [ ] Every `assign_datetime()` has a `# REVIEW:` on `raw_fmt` - [ ] Every `assign_ct()` has a `# REVIEW:` on `ct_clst` - [ ] Every `condition_add()` has a `# REVIEW:` on the business rule - [ ] `raw_dat` in each `assign_*` call is the **original raw dataset** - [ ] `derive_seq()` called to generate `--SEQ` - [ ] `derive_study_day()` called for event/finding date study days (requires DM) - [ ] Required domain variables present (`stopifnot()` or `stop()` guard) - [ ] No duplicate `--SEQ` values (`stopifnot()` check) - [ ] Findings domains: per-test blocks stacked with `bind_rows()` before adding common variables