Skip to content

Case study: from the SAP to a study macro

The library macro of chapter 19 works in every study. Most macros you write at work are smaller: they do one rule of one study’s statistical analysis plan (SAP). Chapter 10 used one of them, %m_baseline, for vital signs and for a lab parameter. This case study shows how such a macro is made, in five moves from production work:

  1. Copy the SAP text into the program, next to the code that does it.
  2. Look at the data before you write the rule, and make one call per rule.
  3. Write one study macro: the mechanism goes in the macro, the rule goes in the call.
  4. Give the macro a full header and parameter checks.
  5. Prove the result against an independent program.

The data is the CDISC pilot study. The SAP text is an example definition written for this book.

A reviewer reads your program next to the SAP. Make that easy: paste the SAP definition as a comment block right above the code that does it, as 10_2_advs_bp.sas does.

/*****************************************************************************/
/* Example SAP definition: Baseline */
/* Baseline is the last non-missing value on or before the day of the first */
/* dose. Blood pressure and pulse are measured in three positions: each */
/* position has its own baseline. */
/*****************************************************************************/

Start each step with a comment that says what the step does, /* Step 020: the baseline record, as the SAP says */. A reviewer then finds every rule of the SAP by reading the comments alone.

2. Look at the data before you write the rule

Section titled “2. Look at the data before you write the rule”

The SAP says “the last value on or before the first dose”. Three facts of the pilot data decide how the code looks:

  • The BASELINE visit of the vital signs is on the day of the first dose, for every record. So the condition must be adt <= trtsdt. With adt < trtsdt the BASELINE visit would never count.
  • Blood pressure and pulse have three positions per visit (ATPTN 815, 816, 817). Weight has one value per visit. That gives two rules: one baseline per subject and parameter, and one per subject, parameter and position. Each rule is one call: by=usubjid paramcd and by=usubjid paramcd atptn.
  • The chemistry labs (ALT and the others) have no BASELINE visit at all, and some subjects have an unscheduled retest before the first dose. A rule based on the name of a visit would fail there; a rule based on dates works for every BDS data set.
10_2_0_bds_macros.sas (the first macro)
/* Chapter 10: the study macros for BDS data sets, %m_baseline and %m_chg. Run this file once, then 10_2 and later programs use them. */
/*******************************************************************************
Macro : %m_baseline (study macro)
Version : 2.0
Purpose : Mark the baseline record of each group, as the SAP
defines it: the last record that meets the condition.
SAP text : example SAP definition written for this book
"Baseline is the last non-missing value on or before the
day of the first dose. When that value comes from a
screening visit, the screening value is the baseline.
Blood pressure and pulse are measured in three positions:
each position has its own baseline."
Parameters :
data= a BDS data set (required)
out= the output data set, sorted by &sortby. (required)
by= one baseline per group (default usubjid paramcd)
where= which records can be baseline, a WHERE condition
(default adt <= trtsdt AND NOT MISSING(aval))
sortby= the by= variables, then the time order: the last record
of a group is its baseline (default &by. adt). Add a
key such as the SDTM sequence number when two records
of a group can have the same date.
baseline_visit= AVISITN of the new records when copy=Y (default 0)
copy= N: set ABLFL = "Y" on the collected baseline record
(default; the reference ADVS of the pilot does this)
Y: also add a copy with AVISIT "Baseline", AVISITN =
&baseline_visit., ABLFL "Y". Only for records without an
analysis visit, else a group has two Baseline rows
Preconditions : &data. has the variables of by=, sortby= and where=;
sortby= starts with the variables of by=
Postconditions : &out. has all records of &data. and the variable ABLFL,
"Y" on exactly one record per group that has a record
meeting where=; with copy=Y one more record per such
group; work data sets _mbl_: are deleted
Validation level : independent double programming of the baseline values
Author : your name, date
Change history :
2026-11-01 1.0 first version, case study of Part V
2026-11-01 1.1 parameters renamed to the book's interface: data= (was
input=), where= (was baseline_cond=), by= (was groupby=),
out= (was output=)
2026-11-01 2.0 moved to chapter 10 for every BDS data set: defaults for
by=, where= and sortby=; copy=N flags the record, copy=Y
adds a baseline record; out= has all records
*******************************************************************************/
%MACRO m_baseline(data=, out=, by=usubjid paramcd, where=adt <= trtsdt AND NOT MISSING(aval),
sortby=&by. adt, baseline_visit=0, copy=N)
/ DES = 'Mark the baseline record of each group';
%LOCAL macro mversion i p n last;
%LET macro = %UPCASE(&SYSMACRONAME.);
%LET mversion = 2.0;
/* Check the parameters: required ones, the input, the sort order, copy= */
%DO i = 1 %TO 5;
%LET p = %SCAN(data out by where sortby, &i.);
%IF %LENGTH(%SUPERQ(&p.)) = 0 %THEN %DO;
%PUT %STR(ERR)OR: [&macro.] Parameter %UPCASE(&p.)= is required.;
%RETURN;
%END;
%END;
%IF NOT %SYSFUNC(EXIST(&data.)) %THEN %DO;
%PUT %STR(ERR)OR: [&macro.] Data set &data. does not exist.;
%RETURN;
%END;
%LET n = %SYSFUNC(COUNTW(&by., %STR( )));
%DO i = 1 %TO &n.;
%IF %UPCASE(%SCAN(&by., &i., %STR( ))) NE %UPCASE(%SCAN(&sortby., &i., %STR( ))) %THEN %DO;
%PUT %STR(ERR)OR: [&macro.] SORTBY= must start with the BY= variables.;
%RETURN;
%END;
%END;
%LET copy = %UPCASE(&copy.);
%IF &copy. NE N AND &copy. NE Y %THEN %DO;
%PUT %STR(ERR)OR: [&macro.] COPY= must be N or Y, not &copy..;
%RETURN;
%END;
%LET last = %SCAN(&by., -1, %STR( ));
%PUT NOTE: [&macro.] Version &mversion. started %SYSFUNC(DATE(), DATE9.) %SYSFUNC(TIME(), HHMM.);
/* Step 010: the records in time order within each group, numbered */
PROC SORT DATA=&data. OUT=_mbl_010;
BY &sortby.;
RUN;
DATA _mbl_010;
SET _mbl_010;
_mbl_n = _N_;
RUN;
/* Step 020: the number of the last record of each group that can be baseline */
DATA _mbl_020(KEEP=_mbl_n);
SET _mbl_010(WHERE=(&where.));
BY &by.;
IF LAST.&last.;
RUN;
/* Step 030: flag that record, or add a copy of it */
DATA &out.(DROP=_mbl_n);
LENGTH ablfl $1;
MERGE _mbl_010 _mbl_020(IN=_mbl_bl);
BY _mbl_n;
%IF &copy. = N %THEN %DO;
IF _mbl_bl THEN ablfl = "Y";
ELSE ablfl = "";
%END;
%ELSE %DO;
ablfl = "";
OUTPUT;
IF _mbl_bl THEN DO;
avisit = "Baseline";
avisitn = &baseline_visit.;
ablfl = "Y";
OUTPUT;
END;
%END;
RUN;
/* Remove the work data sets of this macro */
PROC DATASETS LIBRARY=work NOLIST NOWARN;
DELETE _mbl_:;
RUN; QUIT;
%MEND m_baseline;

Read it from top to bottom:

  1. The header quotes the SAP. It lists the purpose, the parameters, the preconditions (true before the call), the postconditions (true after it), the validation level and the change history. A reviewer can check the macro against the SAP without reading the code.
  2. The change history is the story of the macro. Version 1.0 worked on one data set. Version 1.1 got the book’s interface (data=, out=, by=, where=). Version 2.0 works on every BDS data set: the SAP rule became the default values, and copy= chooses between flagging the record and adding a baseline record. When a table changes, the version in the log tells you which macro made it.
  3. The parameter checks are part of the macro. One loop checks that five parameters are not empty. p holds a parameter name, such as data, and %SUPERQ(&p.) gives the value of that parameter. Then the data set must exist, and copy= must be N or Y. sortby= must start with the by= variables, or LAST. would not mean “last in the group”. Every failed check writes ERROR: [M_BASELINE] ... and stops with %RETURN before any step runs.
  4. A version message starts the log of every call: NOTE: [M_BASELINE] Version 2.0 started ....
  5. Numbered work data sets with a private prefix, _mbl_010 and _mbl_020, are deleted at the end with DELETE _mbl_:;.
  6. The mechanism is small. Sort and number the records. Find the last record of each group that meets where=. Flag that record, or add a copy of it. LAST.&last. uses the last word of by=: for usubjid paramcd atptn it is LAST.atptn.

The macro does not know what “baseline” means in this study. The default of where= is this study’s rule, and a call can give another one. When the SAP changes, you change the call or the default, and the mechanism stays tested.

Chapter 10 calls the macro once per rule, with the rule in the call:

/* blood pressure and pulse: one baseline per position */
%m_baseline(data=advs_bp_010, out=advs_bp_020, by=usubjid paramcd atptn,
sortby=usubjid paramcd atptn adt vsseq)
/* weight: one baseline per parameter, the defaults */
%m_baseline(data=advs_020, out=advs_wt_sap, sortby=usubjid paramcd adt vsseq)
/* ALT: a lab parameter, the same call */
%m_baseline(data=adlb_010, out=adlb_020, sortby=usubjid paramcd adt lbseq)

sortby= adds the SDTM sequence number, so that two records of a group on the same day always come in the same order.

The production design that this macro comes from added a baseline record, a copy of the last pre-dose record with AVISIT = "Baseline". The reference ADVS flags the record itself; the ADaM implementation guide allows both designs. copy= keeps both designs in one macro: copy=N (the default) flags, copy=Y adds. Exercise 10-5 shows that both give the same BASE.

5. Prove it: compare with an independent program

Section titled “5. Prove it: compare with an independent program”

The pilot comes with a reference ADVS and ADLBC, made by another team. Chapter 10 compares with them, and every difference has a reason:

DataDifferenceWhy
Blood pressure and pulse01-718-1150, 9 baseline recordsno BASELINE visit for blood pressure: the SAP rule takes SCREENING 1
Weight01-702-1082, 1 baseline recordno weight at the BASELINE visit: the SAP rule takes SCREENING 1
ALT24 subjects, BASE changes for 22an unscheduled test before the first dose is the last value

The reference takes the baseline from a named visit; the SAP takes the last value before the first dose.

19_8_m_baseline_errors.sas
/* EXPECT: ERROR [M_BASELINE] */
/* Case study: wrong calls of %m_baseline stop before any step runs (needs 10_1 and the macros of 10_2_0) */
/* 1: a required parameter is empty */
%m_baseline(data=advs_020, out=x1, by=)
/* 2: the input does not exist */
%m_baseline(data=advs_typo, out=x2)
/* 3: the sort order does not start with the groups */
%m_baseline(data=advs_020, out=x3, sortby=usubjid adt paramcd)
/* 4: copy= has a value the macro does not know */
%m_baseline(data=advs_020, out=x4, copy=yes)
%PUT NOTE: x1 exists=%SYSFUNC(EXIST(x1)) x2 exists=%SYSFUNC(EXIST(x2)) x3 exists=%SYSFUNC(EXIST(x3)) x4 exists=%SYSFUNC(EXIST(x4));

What you should see:

ERROR: [M_BASELINE] Parameter BY= is required.
ERROR: [M_BASELINE] Data set advs_typo does not exist.
ERROR: [M_BASELINE] SORTBY= must start with the BY= variables.
ERROR: [M_BASELINE] COPY= must be N or Y, not YES.
NOTE: x1 exists=0 x2 exists=0 x3 exists=0 x4 exists=0

Each message names the macro and the problem, and no output data set is made. A half-made data set is worse than none: the next step would use it without a word.

  1. Run 10_2_0_bds_macros.sas once with F4. Put the cursor on the first %m_baseline( call in 10_2_advs_bp.sas and click Debug Macro. DataStep Studio finds the definition in 10_2_0_bds_macros.sas in the same folder and puts your call in the call pane. Click Start Debug. See Debug a macro.

    What you should see: the debugger stops at the first statement of %m_baseline. The Variables view shows the parameters of this call: by = usubjid paramcd atptn, data = advs_bp_010, out = advs_bp_020, and the defaults that the call did not change, such as where. This is the fastest way to answer “what did this call get?”.

    Debug Macro from the first %m_baseline( call in 10_2_advs_bp.sas

    Debug Macro from the first %m_baseline( call in 10_2_advs_bp.sas: the debugger stops at the first statement of %m_baseline 2.0 in 10_2_0_bds_macros.sas. Variables: by = usubjid paramcd atptn, copy = N, data = advs_bp_010, out = advs_bp_020.

  2. To look at the data inside the macro, write %pry(when=%SCAN(&by., -1) = atptn) in 10_2_0_bds_macros.sas, on a line before /* Remove the work data sets of this macro */. Run that file, then run 10_2_advs_bp.sas with F4. The run stops in the blood pressure call only.

  3. In the SAS Pry terminal, type vt advs_bp_020, then where usubjid = "01-718-1150" AND ablfl = "Y", then show.

    What you should see: the 9 baseline records of this subject, all from SCREENING 1. Type c to let the run go on. See %pry, the complete guide.

    %pry inside %m_baseline, in the call by position only. vt advs_bp_020 and where usubjid = '01-718-1150' AND ablfl = 'Y'

    %pry inside %m_baseline, in the call by position only. vt advs_bp_020 and where usubjid = “01-718-1150” AND ablfl = “Y”: rows 1 to 9 of 9, every baseline record of this subject comes from SCREENING 1.

Study macroLibrary macro
Doesone rule of one SAPa task every study has
Example%m_baseline, chapter 10%count_subj, chapter 19
Livesin the study folder, next to the programsin the team’s autocall library
Tested bythe double programming of the studyits own test harness, before every release
Headerthe SAP text it doesan example call

Promote a study macro to the library when the third study needs the same rule. Then the condition and the group stay parameters, the SAP text in the header becomes an example, and the macro gets its own tests.

  1. Your SAP says: “If a time point has more than one measurement, the baseline is the average of the last set of measurements before the first dose.” The pilot data has no such repeats. Write the program for a study that has them: which step comes before the %m_baseline call, and which BY variables does it use?
  2. Add a debug= parameter to %m_baseline that keeps the _mbl_ data sets, as %count_subj does.
  3. Call %m_baseline with copy=Y on the blood pressure records of 10_2_advs_bp.sas, then %m_chg. How many records are added, and is BASE on the collected records the same as in ADVS_BP?

Answers: Answers, Parts IV and V.