Mapping Vital Signs to the SDTM VS Domain
Vital signs look like the easy domain until you meet the details: a single EDC form captures systolic and diastolic pressure, pulse, temperature, respiration, height, and weight side by side, but SDTM demands them stacked one measurement per row under VSTESTCD, with temperature converted, position recorded, missing readings explained, and triplicate blood-pressure captures preserved rather than averaged. This guide is the hands-on companion to the CDISC SDTM Transformation Pipelines guide and sits inside the wider Clinical Data Architecture & EDC Standards reference, and it is written for clinical data managers and Python ETL engineers who need a deterministic, defensible procedure for turning a wide EDC vitals form into a conformant VS dataset. Each step isolates one responsibility — pivot to the VS grain, standardize units, record status and position, and gate — with its regulatory rationale alongside the code.
The VS Mapping Flow
The pipeline pivots the wide EDC vitals record into one row per test, standardizes each result into VSSTRESN, records not-done status and position, and gates the frame before it reaches a monitored environment.
Root Cause: Why a Wide Vitals Form Fights the VS Model
The mismatch is structural. The EDC collects vitals horizontally — one row per subject-visit with a column each for systolic, diastolic, pulse, temperature, and the rest — because that is how a nurse records them on a single form. VS is a Findings-class domain with a vertical --TESTCD/--ORRES grain, so those columns must become rows, each tagged with the correct VSTESTCD (SYSBP, DIABP, PULSE, TEMP, HEIGHT, WEIGHT, RESP) and its own unit. Temperature is the classic trap: sites record in Celsius or Fahrenheit depending on region, and both must land in one standardized VSSTRESU. A reading that was not taken cannot be a blank cell — SDTM needs VSSTAT = NOT DONE with a VSREASND reason. Blood pressure is frequently captured in triplicate, and the temptation to average belongs to analysis, not tabulation. Height is collected once at screening and must not be fabricated forward into later visits. Each of these is a place a silent defect enters, which is why the pivot is deterministic rather than best-effort.
Step-by-Step Implementation
Step 1: Land the Wide Vitals Record and Anchor Provenance
Read the EDC vitals extract with every value as a string, and hash the raw row before any reshape so the as-collected form is reconstructable. Keeping values uncoerced matters because a not-done reading may arrive as an empty string or a sentinel, and premature numeric coercion would erase that distinction.
# ALCOA+ (Original): hash the wide vitals row before any reshape so the
# as-collected form can be reconstructed and defended at inspection.
import hashlib
import pandas as pd
WIDE_COLS = ["SYSBP", "DIABP", "PULSE", "TEMP", "RESP", "HEIGHT", "WEIGHT"]
def land_vitals(csv_path: str) -> pd.DataFrame:
df = pd.read_csv(csv_path, dtype=str, keep_default_na=False)
df["STUDYID"], df["DOMAIN"] = "CDP-204", "VS"
df["source_hash"] = df.apply(
lambda r: hashlib.sha256(
"|".join(str(r[c]) for c in df.columns).encode("utf-8")
).hexdigest(),
axis=1,
)
return df
The same read-only landing discipline used across ingestion is documented in Pandas DataFrames for Clinical Data Cleaning; the extract is evidence, so it is parsed, never edited in place.
Step 2: Pivot the Wide Form into the VS Grain
Melt the vitals columns into one row per test, carrying the subject, visit, and timing keys so each measurement becomes a self-contained VS record. Bind each source column to its VSTESTCD/VSTEST/VSORRESU triple through a static map so the grain is explicit and reviewable.
# ALCOA+ (Consistent): the wide-to-long pivot is table-driven, so every run
# produces the same VSTESTCD rows in the same deterministic order.
VS_MAP = {
"SYSBP": ("SYSBP", "Systolic Blood Pressure", "mmHg"),
"DIABP": ("DIABP", "Diastolic Blood Pressure", "mmHg"),
"PULSE": ("PULSE", "Pulse Rate", "beats/min"),
"RESP": ("RESP", "Respiratory Rate", "breaths/min"),
"TEMP": ("TEMP", "Temperature", "C"),
"HEIGHT": ("HEIGHT", "Height", "cm"),
"WEIGHT": ("WEIGHT", "Weight", "kg"),
}
KEYS = ["USUBJID", "VISITNUM", "VISIT", "VSDTC", "VSPOS", "VSTPT", "VSTPTNUM"]
def pivot_to_vs(df: pd.DataFrame) -> pd.DataFrame:
long = df.melt(id_vars=KEYS + ["source_hash"], value_vars=list(VS_MAP),
var_name="src_col", value_name="VSORRES")
long[["VSTESTCD", "VSTEST", "VSORRESU"]] = long["src_col"].map(VS_MAP).apply(pd.Series)
return long.sort_values(KEYS + ["VSTESTCD"], kind="mergesort").reset_index(drop=True)
Step 3: Standardize Units into VSSTRESN, Converting Temperature
Convert each as-collected VSORRES/VSORRESU into VSSTRESN/VSSTRESU. Most vitals are numeric pass-throughs, but temperature must be normalized to Celsius when a site recorded Fahrenheit, and the conversion must be logged so the arithmetic is reproducible.
# 21 CFR Part 11 (Accurate): temperature conversion is explicit and logged,
# so VSSTRESN is a reproducible, auditable derivation for every unit variant.
from decimal import Decimal, InvalidOperation
def standardize_vs(row: dict) -> dict:
raw = row["VSORRES"]
try:
value = Decimal(raw)
except (InvalidOperation, TypeError):
row["VSSTRESN"], row["VSSTRESU"], row["conv_note"] = None, None, "non-numeric"
return row
if row["VSTESTCD"] == "TEMP" and row["VSORRESU"] in ("F", "degF"):
value = (value - Decimal("32")) * Decimal("5") / Decimal("9") # F -> C
row["VSSTRESU"], row["conv_note"] = "C", "F->C applied"
else:
row["VSSTRESU"], row["conv_note"] = row["VSORRESU"], "pass-through"
row["VSSTRESN"] = float(round(value, 4))
return row
Step 4: Record Not-Done Status, Position, and Time Points
A missing reading is not a blank. When a measurement was not performed, set VSSTAT = NOT DONE, null the result, and require a VSREASND. Carry VSPOS (position, such as SITTING or SUPINE) and the VSTPT/VSTPTNUM time points that anchor repeated captures within a visit.
# ALCOA+ (Complete): a not-taken reading is explicitly NOT DONE with a reason,
# never a silent blank that reads as missing data downstream.
def apply_status(row: dict) -> dict:
result_present = bool(row.get("VSORRES"))
if not result_present:
row["VSSTAT"] = "NOT DONE"
row["VSORRES"], row["VSSTRESN"] = None, None
row["VSREASND"] = row.get("NOT_DONE_REASON") or "Reason not provided" # query if blank
else:
row["VSSTAT"] = None # per SDTM, VSSTAT is blank when a result exists
row["VSREASND"] = None
# VSPOS applies chiefly to blood pressure and pulse; leave blank where not collected
if row["VSTESTCD"] not in ("SYSBP", "DIABP", "PULSE"):
row["VSPOS"] = None
return row
A NOT DONE reading with an empty VSREASND is routed into the discrepancy workflow described in Automated Clinical Query Generation, so a missing reason becomes a query rather than an unexplained gap.
Step 5: Gate on Conformance Before Promotion
Assert the VS grain, controlled-terminology membership, and the status invariant with a schema contract, and archive the report as OQ evidence. The rule the gate enforces: a NOT DONE record has a null VSORRES and a populated VSREASND, and a completed record has the opposite.
# GxP test artifact: the pandera contract is the executable VS conformance
# spec; its pass/fail report is archived as OQ evidence for the run.
import pandera.pandas as pa
VS_CODES = ["SYSBP", "DIABP", "PULSE", "TEMP", "HEIGHT", "WEIGHT", "RESP"]
vs_schema = pa.DataFrameSchema({
"VSTESTCD": pa.Column(str, pa.Check.isin(VS_CODES)),
"VSSTRESN": pa.Column(float, nullable=True),
"VSSTAT": pa.Column(str, pa.Check.isin(["NOT DONE"]), nullable=True),
"VSPOS": pa.Column(str, pa.Check.isin(["SITTING", "STANDING", "SUPINE"]), nullable=True),
"VSDTC": pa.Column(str, pa.Check.str_matches(r"^\d{4}-\d{2}-\d{2}")), # ISO 8601
})
def gate(df):
return vs_schema.validate(df, lazy=True)
The same conformance-gate pattern anchors every SDTM domain in this guide and is generalized in Validation and CSV Frameworks; downstream, the averaged analysis values it feeds are built in ADaM Dataset Derivation for Analysis.
Verification and Audit Trail
Confirming a VS mapping is itself a regulated activity. Capture the following evidence on every run so the transformation can be reconstructed and defended at inspection:
| Evidence field | Purpose |
|---|---|
source_hash |
Anchors each VS row to the exact wide EDC form line (ALCOA+ Original). |
conv_note |
Records whether a Fahrenheit-to-Celsius conversion was applied, so temperature is reproducible. |
VSSTAT / VSREASND pair |
Proves every not-taken reading is explained, never a silent blank. |
replicate count per VSTPTNUM |
Confirms triplicate BP captures are preserved, not collapsed to one row. |
VSTESTCD row count |
Confirms the wide-to-long pivot produced the expected tests per visit. |
| pandera report | The executable conformance result, archived as OQ evidence with the run id. |
Run the pivot twice on identical input and assert byte-identical output with pandas.testing.assert_frame_equal; the stable mergesort in Step 2 makes that determinism proof reproducible for the audit file.
Edge Cases and Vendor-Specific Gotchas
Temperature units are region-dependent, so convert explicitly. A site in the United States may record 98.6 F while another records 37.0 C, and both must land as Celsius in VSSTRESU. Never infer the unit from the numeric magnitude; read VSORRESU, apply the logged Fahrenheit-to-Celsius formula only when the source unit says Fahrenheit, and store the conversion note so a reviewer can recompute the value.
Triplicate blood pressure stays as replicates in SDTM. Protocols often require three sequential BP readings, and the mean is what analysis wants — but averaging belongs in ADaM, not VS. Keep all three replicates as separate rows distinguished by VSTPT/VSTPTNUM, and let the analysis dataset derive the mean. Collapsing them in SDTM destroys the raw observations the reviewer is entitled to see.
Height is collected only at screening. Weight repeats at most visits, but height is typically measured once. Do not carry the screening height forward into later visits as if it were re-measured; leave it absent where it was not collected. Fabricating a repeated height inflates the record with measurements that never happened and is a data-integrity finding.
Frequently Asked Questions
Should I average triplicate blood-pressure readings in the VS domain?
No. SDTM VS holds the observations as collected, so all three replicates stay as separate rows distinguished by VSTPT and VSTPTNUM. The mean of a triplicate is a derived analysis value and belongs in the ADaM layer. Averaging in VS destroys the raw readings a reviewer is entitled to reconstruct.
How do I convert temperature recorded in Fahrenheit?
Read VSORRESU for the source unit and apply the standard formula, subtract 32 then multiply by 5/9, only when the unit is Fahrenheit, landing the result in Celsius in VSSTRESU. Log that the conversion was applied so the value is reproducible. Never guess the unit from the number itself, because 37 and 98.6 both describe a normal body temperature in different scales.
What goes in VSSTAT and VSREASND when a reading was not taken?
Set VSSTAT to NOT DONE, leave VSORRES and VSSTRESN null, and populate VSREASND with the documented reason. A blank cell is ambiguous — it could mean not measured or lost in transfer — so the explicit status plus reason preserves ALCOA+ Complete. If the reason is missing, raise a query rather than inventing one.
Why pivot the EDC vitals form instead of keeping it wide?
VS is a Findings-class domain with a one-measurement-per-row grain keyed by VSTESTCD. The EDC collects vitals wide because that suits data entry, but tabulation and downstream analysis expect the vertical structure. Pivoting through a table-driven map makes the grain explicit and the transform deterministic and reviewable.
Should screening height be carried forward to later visits?
No. Height is normally measured once at screening. Leave it absent at visits where it was not collected rather than copying the screening value forward. Populating a height that was never re-measured fabricates observations and is a data-integrity finding; weight, by contrast, is genuinely re-measured and repeats legitimately.
Related
- CDISC SDTM Transformation Pipelines — the parent guide covering the shared SDTM transformation architecture this VS build follows.
- Clinical Data Architecture & EDC Standards — the reference architecture this domain-mapping layer sits within.
- Deriving the SDTM LB Domain from Central Lab Feeds — a sibling Findings-class build with its own unit-conversion and reconciliation logic.
- Handling SUPPQUAL Supplemental Qualifiers in SDTM — where non-standard vitals fields that do not fit a core VS variable are parked.
- ADaM Dataset Derivation for Analysis — where triplicate replicates are averaged into analysis-ready values.