---
title: "CDISC Pilot 01 — clinical-trial programming in SAS and R"
subtitle: "Double-programming an FDA-grade analysis package — SDTM to ADaM, a medication-level dataset, dictionary coding, tables/figures/listings, and PROC COMPARE QC — on a publicly redistributable clinical-trial dataset"
author: "Paulina Del Mundo Del Fierro, MD, MPH"
date: last-modified
format:
html:
theme: [cosmo, ../_theme-light.scss]
highlight-style: github
toc: true
toc-depth: 3
toc-location: right
code-tools: true
code-copy: true
code-fold: true
code-summary: "Show code"
code-overflow: wrap
number-sections: true
fig-responsive: true
fig-width: 8
fig-height: 5
mainfont: "IBM Plex Sans, system-ui, sans-serif"
monofont: "IBM Plex Mono, Menlo, monospace"
embed-resources: true
include-in-header:
text: |
<script>
window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
</script>
<script defer src="/_vercel/insights/script.js"></script>
execute:
echo: true
eval: true
warning: false
message: false
freeze: auto
---
[← All traces](https://paulinadelmundomd.com/traces/cases) · [The pathway](https://paulinadelmundomd.com/traces/)
::: {.callout-tip icon=false}
## Abstract
Every clinical trial submitted to the FDA arrives as a precisely-structured package of datasets, and pharma companies typically write that package *twice* — once in SAS (the industry-standard tool) and once independently in another language — then compare the two outputs to catch programmer errors before the trial database is locked. This notebook demonstrates that double-programming workflow on a publicly-shared Alzheimer's-trial dataset that CDISC (the industry standards body) releases specifically so the workflow can be practiced and audited in public. The SAS half is written like a real submission package; the R half uses an open-source package the pharma industry maintains together (`{admiral}`). Both halves read the same raw clinical data and produce the three analysis datasets that drive most submission tables: one subject-level summary, one adverse-event table, and one lab-chemistry table. Around that core it also walks the rest of a programmer's day in SAS — a medication-level analysis dataset built from the concomitant-medication and exposure domains, dictionary coding, the tables/figures/listings the statistical analysis plan calls for, and the `PROC COMPARE` double-programming check that signs an output off.
:::
::: {.callout-note icon=false}
## Status — the R derivation executes; SAS-vs-R reconciliation is the remaining step
**What runs on render.** The R half executes end-to-end: it reads the CDISC Pilot SDTM domains from `{pharmaversesdtm}`, derives ADSL, ADAE, and ADLBC with `{admiral}`, writes a v5 transport XPT, and renders the ICH E3 §14.2.1 demographics table — all from data shipped in the package, no external download.
**What's still a specification.** The SAS submission package (`sas/adsl.sas`, `sas/adae.sas`, `sas/adlbc.sas`, `sas/t_14_2_01_demog.sas`) is written and embedded below, but the SAS programs are not executed here, so the closing cross-language reconciliation — diffing the SAS-derived, R-derived, and CDISC-reference ADSLs byte-for-byte — does not run (it needs the SAS OnDemand outputs). That, plus a define-XML-driven `{xportr}`/`{metacore}` conformance layer, is the remaining work to make this a fully self-checking submission package. The SAS in the programming sections that follow (the medication-level dataset, dictionary coding, the TFL trio, and the `PROC COMPARE` check) is shown the same way — submission-faithful source, read here rather than run.
:::
## Why this analysis
Every interventional clinical trial submitted to the FDA, EMA, or PMDA arrives as a CDISC package: collected data conforms to **SDTM** (Study Data Tabulation Model, IG v3.4), and analysis-ready data conforms to **ADaM** (Analysis Data Model, IG v1.3). Submission programmers typically write the same derivations twice — primary in SAS, secondary in either SAS or R — and the two outputs must reconcile before database lock.
This notebook is that double-programming exercise on a public dataset:
1. **Read** the CDISC Pilot SDTM domains (DM, AE, EX, LB) from the `{pharmaversesdtm}` package.
2. **Derive** ADSL, ADAE, and ADLBC using ADaM IG v1.3 conventions — twice, once in SAS, once in R/`{admiral}`.
3. **Reconcile** the two derivations against each other and against CDISC's published reference ADaM.
4. **Report** an ICH E3 §14.2.1 demographics table and the reconciliation result.
The CDISC Pilot is a fictional Alzheimer's trial (placebo / Xanomeline low dose / Xanomeline high dose) released by CDISC under a permissive license expressly so the standards can be taught and audited in public; the `{pharmaversesdtm}` package ships its SDTM domains as R data. See <https://github.com/cdisc-org/sdtm-adam-pilot-project>.
::: {.callout-note collapse="true"}
## Going deeper — why double-programming is a *replication* design, not a review design
Having a colleague read your code catches typos and obvious logic errors, but a reviewer reads along the same logical track you wrote on: if your mental model of the derivation is wrong, theirs tends to bend the same way. Double-programming is stronger because it is *replication*, not review. Two programmers, ideally in two languages, start from the same spec and the same raw data and derive independently. A bug in the SAS branch is unlikely to recur as the identical bug in the R/`{admiral}` flow, so a byte-level diff of the two outputs surfaces exactly the discrepancies a shared-track review misses.
The failure mode it catches is the plausible-but-wrong line. A derivation that types `EXDOSE > 0` where the spec meant `EXDOSE >= 0` compiles, runs, passes a Pinnacle 21 conformance check, and is silently wrong by a handful of subjects: invisible to any single-implementation check, visible the moment two independent derivations disagree. What byte-level reconciliation does *not* catch is a specification-level error. If both implementations are written to the same wrong define-XML spec, they agree with each other and are both wrong. Reconciliation tests the programming, not the specification.
This is also why pharma pays the upfront cost of CDISC at all. Agreeing on SDTM and ADaM before a single analysis runs is the same investment a research group makes when it writes a data dictionary before fieldwork: cost paid early to make a whole class of downstream operations (pooling, review, reconciliation, audit) mechanical rather than bespoke. The transferable discipline is writing every derivation against a specification auditable by someone who was not in the room when the program was written.
:::
::: {.callout-note}
The `.qmd` source for this page, the SAS programs, and the YAML define-XML extract live on [GitHub](https://github.com/paulinedmdf/paulinadelmundomd/tree/main/traces/applied/03-cdisc-adam-pilot).
:::
---
## Setup
```{r}
#| label: setup-r
suppressPackageStartupMessages({
library(admiral) # ADaM derivations
library(xportr) # writes v5 transport, validates against CDISC CT
library(haven) # reads/writes XPT
library(metacore) # reads define-xml -> R object
library(metatools) # applies metacore to a dataset
library(dplyr)
library(tidyr)
library(gtsummary)
library(here)
})
DATA_SDTM <- here::here("traces", "applied", "03-cdisc-adam-pilot", "data", "sdtm")
DATA_REF <- here::here("traces", "applied", "03-cdisc-adam-pilot", "data", "adam_reference")
DATA_OUT <- here::here("traces", "applied", "03-cdisc-adam-pilot", "data", "adam_r")
SAS_OUT <- here::here("traces", "applied", "03-cdisc-adam-pilot", "sas", "output")
dir.create(DATA_OUT, showWarnings = FALSE, recursive = TRUE)
```
## Read SDTM
::: {.pathway-link}
**On the pathway** → [01 · Measurement](https://paulinadelmundomd.com/traces/#rung-1): data standards and provenance, the CDISC SDTM structure a datapoint inherits before any analysis touches it.
:::
```{r}
#| label: read-sdtm
# SDTM domains from the CDISC Pilot (Xanomeline) study, supplied as R data by the
# pharmaverse {pharmaversesdtm} package — no external download or `make data` step.
dm <- pharmaversesdtm::dm
ae <- pharmaversesdtm::ae
ex <- pharmaversesdtm::ex
lb <- pharmaversesdtm::lb
list(DM = dim(dm), AE = dim(ae), EX = dim(ex), LB = dim(lb))
```
## Derive ADSL (R / `{admiral}`)
::: {.pathway-link}
**On the pathway** → [00 · Framing the study](https://paulinadelmundomd.com/traces/#rung-0): defining the analysis populations (ITT, Safety, Per-Protocol, mITT) before anything is estimated.
:::
ADSL is the subject-level analysis dataset — exactly one record per `USUBJID` — and the parent of every other ADaM. The derivations below follow ADaM IG v1.3 §3.1 and the CDISC Pilot 01 ADSL spec.
::: {.callout-note collapse="true"}
## Going deeper — analysis populations: ITT, Safety, Per-Protocol, mITT
A clinical trial doesn't have one denominator — it has four, each suited to a different question, and every analysis must say which population it's run on:
| Population | Flag | Who's in | Answers |
|---|---|---|---|
| **Intent-to-Treat (ITT)** | `ITTFL` | Every randomised subject, analysed as randomised | "What is the effect of *being assigned* the treatment?" — the policy-relevant estimand |
| **Safety** | `SAFFL` | Every subject who received ≥ 1 dose, analysed as treated | "What harms occurred in people actually exposed?" — the AE denominator |
| **Per-Protocol** | `PPROTFL` | Subjects with no major protocol deviations | "What is the effect of *receiving* the treatment as intended?" |
| **Modified ITT** | `MITTFL` | Pre-specified narrower ITT — e.g., ≥ 1 dose AND a baseline measurement | "ITT with a minimum-quality data filter applied" |
**ITT is the regulatory default for efficacy** because it preserves randomisation. Excluding non-compliers introduces a non-random comparison: adherent subjects are systematically different from non-adherent ones (the "healthy-adherer" effect), so dropping them biases the treatment effect toward whatever adherence is correlated with. ITT keeps the comparison clean at the cost of *diluting* the treatment effect when adherence is poor — a feature for regulators (it's conservative), a frustration for sponsors. **Safety is the default for harms** for the symmetric reason: you can't have a drug-related AE from a drug you never took, so the AE denominator has to be the actually-dosed cohort. In a hypothetical 254-subject trial, 250 might receive at least one dose, 238 of those complete the protocol cleanly, and 3 randomised subjects might withdraw before dosing — so efficacy reports $n = 254$ (ITT), the AE table reports $n = 250$ (Safety), and the dose-response analysis reports $n = 238$ (PP). All three appear in the Clinical Study Report, each in the population that answers the question being asked.
:::
```{r}
#| label: derive-adsl
adsl <- dm |>
mutate(TRT01P = ARM, TRT01A = ACTARM) |>
# Treatment start/end from EX (first/last dosing record)
derive_vars_merged(
dataset_add = ex,
by_vars = exprs(STUDYID, USUBJID),
new_vars = exprs(TRTSDT = convert_dtc_to_dt(EXSTDTC)),
filter_add = EXDOSE > 0 & !is.na(EXSTDTC),
order = exprs(EXSTDTC),
mode = "first"
) |>
derive_vars_merged(
dataset_add = ex,
by_vars = exprs(STUDYID, USUBJID),
new_vars = exprs(TRTEDT = convert_dtc_to_dt(EXENDTC)),
filter_add = EXDOSE > 0 & !is.na(EXENDTC),
order = exprs(EXENDTC),
mode = "last"
) |>
derive_vars_duration(new_var = TRTDURD, start_date = TRTSDT, end_date = TRTEDT) |>
mutate(
SAFFL = if_else(!is.na(TRTSDT), "Y", "N"),
ITTFL = if_else(ARM != "Screen Failure", "Y", "N"),
AGEGR1 = case_when(
AGE < 65 ~ "<65",
AGE <= 80 ~ "65-80",
TRUE ~ ">80"
),
AGEGR1N = case_when(AGEGR1 == "<65" ~ 1, AGEGR1 == "65-80" ~ 2, TRUE ~ 3)
)
cat(sprintf("ADSL: %d subjects x %d variables\n", nrow(adsl), ncol(adsl)))
```
::: {.callout-note}
The full derivation (~25 variables) lives in the source `.qmd`; the chunk above shows the patterns. `{admiral}` ships unit tests against the CDISC reference ADSL — every helper used here is covered.
:::
### Same derivation in SAS
The primary submission program — same logic, expressed in SAS.
```{.sas filename="sas/adsl.sas"}
{{< include sas/adsl.sas >}}
```
## Derive ADAE and ADLBC
::: {.pathway-link}
**On the pathway** → [01 · Measurement](https://paulinadelmundomd.com/traces/#rung-1): operationalizing the endpoints, the treatment-emergent flag and the baseline-adjusted change variables.
:::
ADAE is occurrence-level (one record per AE per subject). The submission-critical derivation is `TRTEMFL` — treatment-emergent flag — gated on AE start date relative to `TRTSDT`.
ADLBC is the canonical Basic Data Structure: one record per parameter per visit per subject, with `PARAMCD`, `AVAL`, `AVISIT`, `ABLFL` (baseline flag), and `CHG` (change from baseline). The lab unit conversions key off the CDISC LB controlled terminology.
::: {.callout-note collapse="true"}
## Going deeper — what `TRTEMFL` is actually attributing
A patient reports a headache on day 14 of dosing. Did the drug cause it? For any single event we can never really know — that's why randomised trials compare *groups*. But for the safety table, we still need a deterministic rule for *counting*, and "treatment-emergent" is that rule:
$$\text{AE start date} \geq \text{TRTSDT}, \quad \text{and} \quad \text{AE start date} \leq \text{TRTEDT} + \Delta,$$
where $\Delta$ is a protocol-specified post-treatment follow-up window. Events before `TRTSDT` are pre-existing; events after `TRTEDT + Δ` are post-treatment and analysed separately. The choice of $\Delta$ follows a pharmacokinetic principle: it should cover roughly 5 drug half-lives, by which point plasma concentration is under 5% of peak. For donepezil ($t_{1/2} \approx 70$ h), 5 half-lives is ~14 days, so a 14- or 28-day window is justified; for drugs with long half-lives (monoclonal antibodies, $t_{1/2}$ = weeks), $\Delta$ stretches to 60–90 days. Without the upper bound, every AE the subject ever reports for the rest of their life would count as treatment-emergent — clearly wrong. Concrete example: Subject 101 with `TRTSDT = 2008-04-01`, `TRTEDT = 2008-09-30`, and $\Delta = 28$ days reports a headache on 2008-03-15 (pre-treatment, `TRTEMFL = "N"`), insomnia on 2008-05-20 (during treatment, `TRTEMFL = "Y"`), and nausea on 2008-11-15 (46 days post-treatment, `TRTEMFL = "N"`, `POSTFL = "Y"`). Only the insomnia row contributes to the standard TEAE table; the other two appear in the listings but not the headline counts.
:::
::: {.callout-note collapse="true"}
## Going deeper — baseline-adjusted analysis: why `CHG` and `PCHG` reduce variance
A clinical lab value like serum creatinine has a baseline distribution spanning roughly 0.5–1.5 mg/dL across subjects, and most of that between-subject variance is constitutional (kidney size, muscle mass, age) — nothing to do with the drug. Comparing raw Week-12 values between arms drowns the treatment signal in constitutional noise. The fix is to compare each patient's *change from their own baseline*: $\text{CHG} = \text{AVAL} - \text{BASE}$ (and $\text{PCHG} = 100 \cdot \text{CHG}/\text{BASE}$ for percent change). Under bivariate normality with within-subject correlation $\rho$, the variance reduction is exactly:
$$\text{Var}(\text{CHG}) = \text{Var}(\text{AVAL}) + \text{Var}(\text{BASE}) - 2\rho \cdot \text{SD}(\text{AVAL}) \cdot \text{SD}(\text{BASE}).$$
For lab markers with typical $\rho \approx 0.7$, change-from-baseline has 30–40% of the raw-value variance, so the same trial detects ~0.6× the effect size at the same power. Concrete example: two arms with $n = 100$ each, Week-12 creatinine placebo mean 0.95 (SD 0.20) vs active 0.85 (SD 0.20). The raw two-sample t has SE 0.028 and standardised effect 3.5; with $\rho = 0.7$, Var(CHG) = 0.024, SE = 0.022, standardised effect 4.5. Same data, sharper conclusion. The Analysis Baseline Flag (`ABLFL = "Y"`) marks exactly one pre-treatment record per `(USUBJID, PARAMCD)` — usually the latest pre-dose value. **ANCOVA is the gold-standard refinement**: instead of using CHG as the outcome directly, model $\text{AVAL} = \beta_0 + \beta_1 \cdot \text{TRT} + \beta_2 \cdot \text{BASE}$, which is at least as efficient as change-from-baseline and unbiased even under small baseline imbalances. CHG/PCHG remain in ADLBC as the standard *display* metric, even when the formal analysis uses ANCOVA.
:::
```{r}
#| label: derive-adae-adlbc
adae <- ae |>
derive_vars_merged(
dataset_add = adsl,
by_vars = exprs(STUDYID, USUBJID),
new_vars = exprs(TRTSDT, TRTEDT, TRT01A)
) |>
mutate(
ASTDT = convert_dtc_to_dt(AESTDTC),
AENDT = convert_dtc_to_dt(AEENDTC)
) |>
derive_var_trtemfl(
new_var = TRTEMFL,
start_date = ASTDT,
end_date = AENDT,
trt_start_date = TRTSDT,
trt_end_date = TRTEDT
)
adlbc <- lb |>
filter(LBCAT == "CHEMISTRY") |>
derive_vars_merged(adsl, by_vars = exprs(STUDYID, USUBJID),
new_vars = exprs(TRTSDT, TRT01A)) |>
mutate(PARAMCD = LBTESTCD, AVAL = LBSTRESN, AVISIT = VISIT, ABLFL = LBBLFL) |>
derive_var_base(by_vars = exprs(STUDYID, USUBJID, PARAMCD)) |>
derive_var_chg() |>
derive_var_pchg()
cat(sprintf("ADAE: %d records (%d treatment-emergent) | ADLBC: %d chemistry records\n",
nrow(adae), sum(adae$TRTEMFL == "Y", na.rm = TRUE), nrow(adlbc)))
```
### Same derivations in SAS
```{.sas filename="sas/adae.sas"}
{{< include sas/adae.sas >}}
```
```{.sas filename="sas/adlbc.sas"}
{{< include sas/adlbc.sas >}}
```
## Write conformant XPTs
```{r}
#| label: write-xpt
# Write a v5 SAS transport (XPT) file. A full submission package drives
# variable-level metadata (length, label, type, format) from the define-XML
# spec via {xportr} + {metacore}; that conformance layer is the next step once a
# define-XML extract is wired in. Here we write the analysis-ready ADSL directly.
haven::write_xpt(adsl, file.path(DATA_OUT, "adsl.xpt"), version = 5)
cat("Wrote", file.path(DATA_OUT, "adsl.xpt"), "\n")
```
## Demographics — ICH E3 §14.2.1
::: {.pathway-link}
**On the pathway** → [06 · Recommendation](https://paulinadelmundomd.com/traces/#rung-6): reporting standards, the conventions (here ICH E3) that make a result auditable rather than taken on faith.
:::
::: {.callout-note collapse="true"}
## Going deeper — why ICH E3 §14.2.1 demands this specific layout
*ICH E3 — Structure and Content of Clinical Study Reports* (CPMP/ICH/137/95) is the harmonised template FDA, EMA, and PMDA all expect for the Clinical Study Report. Section 14.2.1 is "Demographic and Other Baseline Characteristics" — the first inferential-adjacent table reviewers read, used to verify that randomisation produced comparable arms. The layout *always* has treatment arms as columns and patient characteristics as rows because clinical reviewers scan *across a row* to spot imbalance on each characteristic; stacking arms as rows would force a mental pivot per characteristic. The standardised format is optimised for that specific cognitive task. Statistics chosen by variable type: continuous and roughly symmetric → **mean (SD)** (preserves both location and spread); continuous and skewed → **median (Q1–Q3)** (mean/SD on heavily-skewed data exaggerate both); categorical → **n (%)** (percentage alone hides rare-cell instability, raw count alone hides relative magnitude). **No formal hypothesis tests are run** — randomisation already guarantees baseline balance in expectation, so testing "are they similar?" is testing whether randomisation worked, which is circular; and testing across ~15 characteristics guarantees spurious p-values that invite ad-hoc covariate adjustments. The proper way to handle baseline imbalance is to *pre-specify* covariate adjustment in the SAP, not test post-hoc. The denominator is the **safety population** because subjects who were never dosed have nothing to contribute to "are the treated arms comparable?" A typical layout for this trial would show, say, Age (mean SD) 75.2 (8.1) / 74.8 (7.9) / 75.5 (8.4) across the placebo and two Xanomeline arms — well within trivial noise; reviewer scans, confirms randomisation worked, and moves to the efficacy tables.
:::
```{r}
#| label: tab-14-2-01
adsl |>
filter(SAFFL == "Y") |>
select(TRT01A, AGE, AGEGR1, SEX, RACE, ETHNIC) |>
gtsummary::tbl_summary(
by = TRT01A,
statistic = list(
all_continuous() ~ "{mean} ({sd})",
all_categorical() ~ "{n} ({p}%)"
),
digits = list(all_continuous() ~ 1)
) |>
gtsummary::add_overall() |>
gtsummary::modify_caption("**Table 14-2.01** — Demographic and baseline characteristics, safety population")
```
## A medication-level dataset (ADCM)
::: {.pathway-link}
**On the pathway** → [01 · Measurement](https://paulinadelmundomd.com/traces/#rung-1): the unit of analysis, chosen before any table — one row per subject for ADSL, one row per record for a basic-data-structure dataset.
:::
ADSL is one row per subject, but much of a submission is *basic-data-structure* (BDS) datasets with a finer grain. A medication-level dataset has one row per reported medication per subject, so it cannot live on ADSL; it is built from the concomitant-medications domain (`CM`) and given a treatment-emergent flag by merging the first-dose date from exposure (`EX`). The same shape underlies ADAE (one row per adverse event) derived above.
```sas
/* First dose date per subject, from the exposure domain */
proc sql;
create table firstdose as
select usubjid,
min(input(exstdtc, ?? yymmdd10.)) as trtsdt format=date9.
from sdtm.ex
where exdose > 0
group by usubjid;
quit;
proc sort data=sdtm.cm out=cm; by usubjid; run;
data adcm;
merge cm(in=incm) firstdose;
by usubjid;
if incm;
format cmstdt date9.;
cmstdt = input(cmstdtc, ?? yymmdd10.); /* tolerant of partial ISO dates */
/* Treatment-emergent: medication started on or after first dose */
trtemfl = ifc(cmstdt ne . and trtsdt ne . and cmstdt >= trtsdt, "Y", "N");
run;
```
## Dictionary coding and medication mapping
::: {.pathway-link}
**On the pathway** → [01 · Measurement](https://paulinadelmundomd.com/traces/#rung-1): data standards and provenance — a coded value inherits structure from a controlled dictionary before any analysis touches it.
:::
A trial records medications as free text, and analysis needs them grouped by therapeutic class. Coding maps each verbatim term to a standardized preferred term and a class, conventionally through the WHO Drug Dictionary, whose Anatomical Therapeutic Chemical (ATC) hierarchy supplies the class levels a summary table groups on. The dictionary itself is licensed, but the mechanic is a lookup join, identical against any reference table: match the coded term, attach the class. The same discipline applies to adverse events through MedDRA and to medical history.
```sas
/* Verbatim CMTRT is coded to CMDECOD; attach the ATC class for grouping. */
proc sql;
create table adcm as
select a.*, b.atc_class
from adcm as a
left join atcref as b
on upcase(a.cmdecod) = upcase(b.cmdecod);
quit;
```
## Tables, figures, and listings (TFLs)
::: {.pathway-link}
**On the pathway** → [06 · Recommendation](https://paulinadelmundomd.com/traces/#rung-6): reporting — the shells are pre-specified in the statistical analysis plan, so each layout is fixed before the data are seen.
:::
The deliverables of a trial analysis are tables, figures, and listings, rendered to RTF or PDF through ODS. A table summarizes (`PROC REPORT` over counts or statistics), a figure plots (`PROC SGPLOT`), and a listing prints records verbatim (`PROC REPORT` with no grouping). The demographics table above is the R rendering of one such shell; the SAS equivalents:
```sas
/* TABLE: demographics by treatment, safety population */
proc freq data=adsl(where=(saffl = "Y")) noprint;
tables trt01a * agegr1 / out=demo_n;
run;
ods rtf file="t14-1-1-demographics.rtf" style=journal;
proc report data=demo_n nowd;
column agegr1 trt01a, count;
define agegr1 / group "Age group (years)";
define trt01a / across "Treatment";
define count / "n";
run;
ods rtf close;
/* FIGURE: mean systolic BP over visits, by treatment, with 95% CIs */
ods graphics on;
proc sgplot data=advs(where=(paramcd = "SYSBP"));
vline avisitn / response=aval group=trt01a stat=mean limitstat=clm;
xaxis label="Visit";
yaxis label="Mean systolic BP (mmHg)";
run;
/* LISTING: every concomitant medication for one subject */
proc report data=adcm(where=(usubjid = "01-701-1015")) nowd;
column usubjid cmtrt atc_class cmstdt trtemfl;
define cmtrt / "Reported term";
run;
```
## Reruns as the data refreshes
Trial outputs are produced many times as the data refreshes and the database moves toward lock, so the programs are written to rerun without editing. Parameterizing a step in a macro and calling it per parameter regenerates a whole family of tables on one submit; a driver program that `%INCLUDE`s the dataset and output steps in order reruns the full deliverable set against the latest extract.
```sas
%macro vs_table(paramcd=, title=);
ods rtf file="vs_¶mcd..rtf" style=journal;
title "&title";
proc means data=advs(where=(paramcd = "¶mcd")) n mean std;
class trt01a avisitn;
var aval;
run;
ods rtf close;
%mend;
%vs_table(paramcd=SYSBP, title=Systolic blood pressure by visit)
%vs_table(paramcd=DIABP, title=Diastolic blood pressure by visit)
```
## Reconciliation: SAS vs R vs CDISC reference
::: {.pathway-link}
**On the pathway** → [∗ · Defend it](https://paulinadelmundomd.com/traces/#rung-defend): double-programming reconciliation, an independent re-derivation that tries to break the result rather than confirm it.
:::
The closing chunk pulls (a) the ADSL produced by `sas/adsl.sas`, (b) the ADSL produced above in R, and (c) the reference ADSL CDISC published with the pilot, sorts all three on `USUBJID`, normalizes column order via the metacore spec, and asserts they are identical. It carries `eval: false` here because it needs the SAS OnDemand outputs, which are not part of this repo; once those are wired in, a non-empty diff would fail the render.
```{r}
#| label: reconcile
#| eval: false
adsl_sas <- haven::read_xpt(file.path(SAS_OUT, "adsl.xpt"))
adsl_r <- haven::read_xpt(file.path(DATA_OUT, "adsl.xpt"))
adsl_ref <- haven::read_xpt(file.path(DATA_REF, "adsl.xpt"))
normalize <- function(d) d |> arrange(USUBJID) |> select(sort(names(d)))
stopifnot(
identical(normalize(adsl_sas), normalize(adsl_r)),
identical(normalize(adsl_r), normalize(adsl_ref))
)
```
On the SAS side the same reconciliation is a `PROC COMPARE`: the second programmer re-derives the dataset from the same SDTM without seeing the first program, and a compare that reports no unequal values is the sign-off — any difference is a finding to resolve before the number ships.
```sas
/* Independent re-derivation (adsl_qc) reconciled against production (adsl) */
proc compare base=adsl compare=adsl_qc
out=diffs outnoequal listall criterion=1e-8;
id usubjid;
run;
```
## Pinnacle 21 conformance
In a full submission package, the `{xportr}` writers enforce variable-level metadata (length, type, label, format) against the metacore spec — the same rule families Pinnacle 21 community edition checks — and the export is then run through Pinnacle 21 for a conformance report. That metadata-driven conformance layer is the next build here (it needs a define-XML extract wired into `{metacore}`); the XPT written above is the analysis-ready data, not yet a metadata-validated submission file.
## Limitations
- **Cross-language reconciliation is specified, not run.** The R derivations execute on render against `{pharmaversesdtm}`, but the closing reconciliation chunk — which would diff the SAS, R, and CDISC-reference ADSLs — carries `eval: false` because it needs the SAS OnDemand outputs, which are not part of this repo. Until those are wired in, the SAS half is a complete submission-style specification rather than executed output, and the byte-for-byte SAS-vs-R check is the remaining deliverable.
- **Fictional data.** No efficacy or safety claim is made about Xanomeline from this analysis; the CDISC Pilot is a teaching artifact, not a real trial.
- **One TLF, not the full SAP.** A real submission produces ~80 tables across efficacy, safety, PK, and disposition; this notebook stops at ADSL/ADAE/ADLBC and Table 14-2.01.
- **No PGx layer in v1.** A pharmacogenomics ADGEN appendix using CDISC PGx terminology is on the roadmap; the Pilot 01 dataset does not ship genotypes.
## References
- CDISC SDTM Implementation Guide v3.4 (2021)
- CDISC ADaM Implementation Guide v1.3 (2021); ADaMIG-OCCDS v1.1 (for ADAE)
- ICH Topic E3 — Structure and Content of Clinical Study Reports, §14.2 (1995)
- pharmaverse `{admiral}` documentation: <https://pharmaverse.github.io/admiral/>
- CDISC Pilot Project repository: <https://github.com/cdisc-org/sdtm-adam-pilot-project>
---
## Appendix — SAS source
All six SAS programs embedded verbatim, in the run order documented in [`sas/README.md`](https://github.com/paulinedmdf/paulinadelmundomd/blob/main/traces/applied/03-cdisc-adam-pilot/sas/README.md). Click any filename heading to open the file on GitHub.
### [`sas/setup.sas`](https://github.com/paulinedmdf/paulinadelmundomd/blob/main/traces/applied/03-cdisc-adam-pilot/sas/setup.sas)
```{.sas filename="sas/setup.sas"}
{{< include sas/setup.sas >}}
```
### [`sas/t_14_2_01_demog.sas`](https://github.com/paulinedmdf/paulinadelmundomd/blob/main/traces/applied/03-cdisc-adam-pilot/sas/t_14_2_01_demog.sas)
```{.sas filename="sas/t_14_2_01_demog.sas"}
{{< include sas/t_14_2_01_demog.sas >}}
```
### [`sas/xpt_export.sas`](https://github.com/paulinedmdf/paulinadelmundomd/blob/main/traces/applied/03-cdisc-adam-pilot/sas/xpt_export.sas)
```{.sas filename="sas/xpt_export.sas"}
{{< include sas/xpt_export.sas >}}
```
---
::: {.callout-note appearance="minimal"}
*Built with [Quarto](https://quarto.org/), [`{admiral}`](https://pharmaverse.github.io/admiral/), and SAS OnDemand for Academics. Source: [GitHub](https://github.com/paulinedmdf/paulinadelmundomd/tree/main/traces/applied/03-cdisc-adam-pilot).*
:::
::: {.callout-note}
**Learn the methods.** The [From Data to Bedside pathway →](https://paulinadelmundomd.com/traces) walks the theory behind analyses like this one, rung by rung.
:::