Mapping Adverse Events to the SDTM AE Domain

An adverse-events dataset carries the safety signal a regulator scrutinizes hardest, and it is unforgiving of shortcuts: an over-cleaned AETERM erases the reporter’s verbatim words, a MedDRA version mismatch shifts a preferred term between coding runs, and a partial onset date silently mis-orders events on the patient timeline. This deep-dive is the tactical companion to CDISC SDTM Transformation Pipelines and sits within the wider Clinical Data Architecture & EDC Standards reference, where the EDC is an immutable, read-only source of truth. It is written for clinical data managers and Python ETL engineers who need a deterministic, defensible procedure for turning an EDC adverse-event log into the Events-class AE domain — verbatim term preserved, MedDRA decodes attached, ISO-8601 dates with partial handling, and the full battery of safety qualifier flags — while leaving the treatment-emergent (TEAE) flag itself to the analysis layer.

The AE Build Flow

AE is an Events-class domain built around a verbatim term and its MedDRA decode, one record per reported event per subject. The flow below builds it in six ordered steps, each isolating a single responsibility.

The six-step SDTM AE build flow A vertical pipeline of six numbered stages that build the AE domain from an EDC adverse-event log. First the verbatim AETERM is preserved and AESEQ assigned. Second MedDRA coding is attached as AELLT, AEDECOD, AEBODSYS and AESOC. Third the AESTDTC and AEENDTC dates are normalized to ISO-8601 with partial-date handling. Fourth the safety qualifier flags AESER, AEREL, AEOUT, AETOXGR, AEACN and AESEV are mapped. Fifth treatment-emergent inputs such as onset versus RFXSTDTC are captured for the analysis layer. Sixth partial-date imputation flags and the MedDRA version are routed to SUPPAE. 1 2 3 4 5 6 Preserve AETERM verbatim · assign AESEQ Attach MedDRA coding AELLT · AEDECOD · AESOC Normalize AE dates AESTDTC · AEENDTC · partials Map safety flags AESER · AEREL · AEOUT · AETOXGR Capture TEAE inputs onset vs RFXSTDTC Route to SUPPAE imputation flags · MedDRA ver.

Why the AE Domain Punishes Shortcuts

AE is hard because it must be simultaneously faithful to the reporter and conformant to the standard. The reporter’s exact words live in AETERM and must be preserved verbatim — trimming, title-casing, or spell-correcting them destroys the original observation that a safety reviewer relies on. The standardized meaning lives in a parallel set of MedDRA-derived variables (AELLT, AEDECOD, AEBODSYS, AESOC), and because MedDRA is versioned twice a year, the exact dictionary version is itself data that must travel with the record. Onset and resolution dates (AESTDTC, AEENDTC) frequently arrive partial or open-ended: an ongoing event legitimately has no end date, and a partial onset must be preserved as a truncated ISO-8601 string rather than invented. Layered on top is a battery of safety qualifier flags — seriousness, causality, outcome, toxicity grade, action taken, severity — each governed by controlled terminology. Every one of these is a place where a silent defect distorts the safety picture, which is why the procedure below is deterministic, following the SDTM standard and its Events-class rules.

Step 1: Preserve AETERM Verbatim and Assign AESEQ

Carry AETERM through exactly as collected, and assign AESEQ per subject after a stable sort on onset date so the sequence is reproducible.

# ALCOA+ (Original): AETERM is the reporter's verbatim text — preserved byte for
# byte. AESEQ is assigned after a stable mergesort for reproducibility.
import pandas as pd

def stage_ae_terms(log: pd.DataFrame) -> pd.DataFrame:
    ae = pd.DataFrame(index=log.index)
    ae["STUDYID"] = log["STUDYID"].astype("string")
    ae["DOMAIN"] = "AE"
    ae["USUBJID"] = log["USUBJID"].astype("string")
    ae["AETERM"] = log["AE_VERBATIM"].astype("string")   # no strip, no case change
    ae = ae.sort_values(["USUBJID", "AE_ONSET_RAW"], kind="mergesort")
    ae["AESEQ"] = ae.groupby("USUBJID").cumcount() + 1
    return ae.reset_index(drop=True)

The mergesort guarantees that events tying on onset keep their input order, so AESEQ is byte-identical run to run — the property Pinnacle 21 uniqueness checks depend on.

Step 2: Attach the MedDRA Coding Hierarchy

MedDRA coding maps the verbatim term to a Lowest Level Term and its parents. Join the coded output against the pinned MedDRA version and stamp that version onto every row.

# ALCOA+ (Attributable, Traceable): MedDRA decode joined from a pinned dictionary
# version, which is recorded per row so coding is reconstructable.
MEDDRA_VERSION = "27.1"

def attach_meddra(ae: pd.DataFrame, coded: pd.DataFrame) -> pd.DataFrame:
    # coded: one row per AETERM verbatim -> LLT/PT/HLT/SOC codes and decodes
    ae = ae.merge(coded, on="AETERM", how="left", validate="many_to_one")
    ae["AELLT"] = ae["llt_name"]         # Lowest Level Term
    ae["AELLTCD"] = ae["llt_code"]
    ae["AEDECOD"] = ae["pt_name"]        # Preferred Term (dictionary-derived)
    ae["AEBODSYS"] = ae["soc_name"]      # primary System Organ Class (body system)
    ae["AESOC"] = ae["soc_name"]
    ae["_MEDDRA_VER"] = MEDDRA_VERSION   # -> SUPPAE / define.xml
    uncoded = ae["AEDECOD"].isna()
    ae.loc[uncoded, "AEDECOD"] = "__UNCODED__"   # surfaced for medical coder review
    return ae

An uncoded verbatim is tagged __UNCODED__ and surfaced for the medical coder rather than left blank or guessed — coding is a controlled activity, and the pipeline must never fabricate a preferred term.

Step 3: Normalize AESTDTC and AEENDTC with Partial-Date Handling

Coerce onset and end to ISO-8601, preserving partial precision and leaving AEENDTC null for ongoing events. Record any imputation as a flag, never inside the --DTC itself.

# 21 CFR Part 11 (Accurate): partial dates preserved as truncated ISO-8601;
# ongoing events keep a null AEENDTC. Imputation is flagged, not baked in.
import re

_ISO = re.compile(r"^\d{4}(-\d{2}(-\d{2})?)?$")

def normalize_ae_dates(ae: pd.DataFrame) -> pd.DataFrame:
    def iso_or_none(v):
        v = (v or "").strip()
        return v if _ISO.match(v) else None            # never invent precision
    ae["AESTDTC"] = ae["AE_ONSET_RAW"].map(iso_or_none)
    ae["AEENDTC"] = ae["AE_END_RAW"].map(iso_or_none)   # None == ongoing
    # Flag partials for SUPPAE; do NOT zero-fill to a full date.
    ae["_ST_PARTIAL"] = ae["AESTDTC"].str.len().lt(10).fillna(False)
    ae["AEENRF"] = ae["AEENDTC"].isna().map({True: "ONGOING", False: pd.NA})
    return ae

A partial onset like 2026-03 stays 2026-03; zero-filling it to 2026-03-01 would fabricate a day the reporter never gave and could reorder the event against exposure.

Step 4: Map the Safety Qualifier Flags

Map seriousness, causality, outcome, toxicity grade, action taken, and severity against their controlled-terminology codelists. Seriousness (AESER) is Y/N; severity (AESEV) and toxicity grade (AETOXGR) are distinct concepts and must not be conflated.

# ALCOA+ (Accurate): each safety flag harmonized against its CT codelist;
# AESEV (mild/moderate/severe) is NOT the same axis as AETOXGR (CTCAE grade).
YN = {"Yes": "Y", "No": "N"}
SEV = {"Mild": "MILD", "Moderate": "MODERATE", "Severe": "SEVERE"}
OUT = {"Recovered": "RECOVERED/RESOLVED", "Recovering": "RECOVERING/RESOLVING",
       "Not recovered": "NOT RECOVERED/NOT RESOLVED", "Fatal": "FATAL"}
REL = {"Related": "RELATED", "Not related": "NOT RELATED"}

def map_safety_flags(ae: pd.DataFrame, log: pd.DataFrame) -> pd.DataFrame:
    ae["AESER"] = log["SERIOUS"].map(lambda v: YN.get(v, f"__UNMAPPED__:{v}"))
    ae["AEREL"] = log["CAUSALITY"].map(lambda v: REL.get(v, f"__UNMAPPED__:{v}"))
    ae["AEOUT"] = log["OUTCOME"].map(lambda v: OUT.get(v, f"__UNMAPPED__:{v}"))
    ae["AESEV"] = log["SEVERITY"].map(lambda v: SEV.get(v, f"__UNMAPPED__:{v}"))
    ae["AETOXGR"] = log["CTCAE_GRADE"].astype("string")   # 1-5, kept as-is
    ae["AEACN"] = log["ACTION_TAKEN"].astype("string")    # CT: DOSE REDUCED, etc.
    return ae

Keeping AESEV (the mild/moderate/severe clinical intensity axis) separate from AETOXGR (the CTCAE numeric grade) matters because collapsing them loses information a reviewer needs; both are legitimate, non-interchangeable qualifiers.

Step 5: Capture Treatment-Emergent Inputs

The treatment-emergent (TEAE) flag is an analysis derivation and belongs in ADaM, not SDTM. AE’s job is to carry the inputs — onset relative to first exposure — cleanly, so the analysis layer can derive TEAE deterministically.

# SDTM/ADaM boundary: AE carries the inputs (onset, RFXSTDTC); the TEAE flag is
# derived in ADaM, never in the tabulation domain.
def capture_teae_inputs(ae: pd.DataFrame, dm: pd.DataFrame) -> pd.DataFrame:
    rfxstdtc = dm.set_index("USUBJID")["RFXSTDTC"]
    ae["_RFXSTDTC"] = ae["USUBJID"].map(rfxstdtc)   # first exposure, from DM
    # Expose a comparison-ready onset without asserting emergence here.
    ae["_ONSET_CMP"] = ae["AESTDTC"].str.slice(0, 10)
    return ae

Leaving the TEAE determination to the ADAE Adverse-Event Analysis Dataset keeps the SDTM domain a faithful tabulation of what was collected, with the analysis logic applied once, downstream, where it belongs.

Step 6: Route Imputation Flags and MedDRA Version to SUPPAE

Non-standard qualifiers — the partial-date imputation flags and the MedDRA dictionary version — travel to SUPPAE, keyed on AESEQ.

# SDTM rule (Complete): imputation flags and MedDRA version preserved in SUPPAE,
# keyed on AESEQ back to the parent AE record.
def build_suppae(ae: pd.DataFrame) -> pd.DataFrame:
    supp = []
    for _, r in ae.iterrows():
        base = {"STUDYID": r["STUDYID"], "RDOMAIN": "AE", "USUBJID": r["USUBJID"],
                "IDVAR": "AESEQ", "IDVARVAL": r["AESEQ"], "QEVAL": "SPONSOR"}
        if r["_ST_PARTIAL"]:
            supp.append({**base, "QNAM": "AESTIMP", "QLABEL": "Start Date Imputed",
                         "QVAL": "Y", "QORIG": "DERIVED"})
        supp.append({**base, "QNAM": "AEMDVER", "QLABEL": "MedDRA Version",
                     "QVAL": r["_MEDDRA_VER"], "QORIG": "ASSIGNED"})
    return pd.DataFrame(supp)

Keying SUPPAE on AESEQ is what lets a reviewer walk from a supplemental flag back to the exact adverse-event record it qualifies.

Verification and Audit Trail

Confirming AE is correct is a regulated activity. Capture this evidence on every run so the build can be reconstructed at inspection:

Evidence Field / check Regulatory purpose
Verbatim fidelity AETERM equals source, unmodified Preserves the original observation (Original)
Coding traceability AEMDVER in SUPPAE; no __UNCODED__ MedDRA decode is reconstructable (Attributable)
Date integrity Partials preserved; AESTIMP flag set No fabricated precision (Accurate)
Sequence uniqueness USUBJID/AESEQ unique Records individually addressable (Complete)
Flag membership Safety flags in CT; no __UNMAPPED__ Conformant qualifiers (Accurate)

Archive the Pinnacle 21 AE report, the uncoded-term register, and the pinned MedDRA version alongside the CT version stamped in define.xml.

Edge Cases and Vendor-Specific Gotchas

Partial AE dates and imputation flags. A year- or month-only onset must be preserved as truncated ISO-8601 and flagged in SUPPAE via AESTIMP, never zero-filled. Baking an invented day into AESTDTC corrupts relative-day math and can flip an event’s treatment-emergent status downstream.

Ongoing adverse events with no end date. An event unresolved at data cut legitimately has a null AEENDTC; encode the ongoing status with AEENRF=ONGOING rather than inventing an end date or dropping the record. Filling AEENDTC with the cut-off date falsely asserts resolution.

MedDRA version control. MedDRA updates twice a year and preferred terms can move between versions, so stamp the coding version onto every AE record (AEMDVER in SUPPAE) and re-run coding as a controlled, versioned event. Mixing two MedDRA versions in one submission is a conformance and interpretability failure.

Frequently Asked Questions

Should the treatment-emergent (TEAE) flag be derived in the AE domain?

No. The TEAE flag is an analysis-population derivation and belongs in ADaM, typically ADAE, not in the SDTM tabulation domain. AE’s responsibility is to carry the inputs — the onset date and the first-exposure reference date from DM — cleanly, so the analysis layer can derive TEAE once, deterministically, downstream.

Can I clean up the AETERM verbatim text before loading it into AE?

No. AETERM must preserve the reporter’s exact words; trimming, title-casing, or spell-correcting them destroys the original observation a safety reviewer relies on. The standardized meaning is captured separately in the MedDRA-derived AEDECOD and its parent terms, which is where normalization legitimately happens.

How do I represent an adverse event that is still ongoing?

Leave AEENDTC null and record the ongoing status with AEENRF=ONGOING. Never fill the end date with the data-cut-off date, because that falsely asserts the event resolved on that day; the null end date is the conformant representation of an unresolved event.

What is the difference between AESEV and AETOXGR?

AESEV is the clinical intensity on the mild/moderate/severe axis, while AETOXGR is the numeric CTCAE toxicity grade (1 to 5). They are distinct, non-interchangeable qualifiers, so map each from its own source field and codelist rather than deriving one from the other.

Why does the MedDRA version need to be stored per record?

MedDRA is republished twice a year and a preferred term can change between versions, so the coding is only reconstructable if the exact dictionary version travels with the data. Storing it as AEMDVER in SUPPAE and in define.xml lets a reviewer reproduce every decode and prevents two versions from silently mixing in one submission.