One call per domain and display, for programmers moving from SAS.
A SAS programmer is used to calling one macro and getting a finished table. Open-source R gives you a correct but multi-step rtables/tern pipeline instead, and that gap is the single most-cited reason migrations stall. sasparity is the opinionated front door: one function per standard display, sensible defaults for the common case, and ... as the escape hatch back to the full rtables/tern API the moment you need something study-specific. It never reimplements rtables/tern — every function is a thin, documented call into them.
Six displays. That is the whole surface, deliberately:
| Function | Display | Built on |
|---|---|---|
tab_demographics() |
Table 14.1.x demographics and baseline characteristics | tern::analyze_vars() |
tab_disposition() |
Table 14.1.x subject disposition | tern::analyze_vars() |
tab_ae_summary() |
Table 14.3.1 AE overview | tern::count_patients_with_event() |
tab_ae_soc() |
Table 14.3.2 AEs by SOC and preferred term |
tern::summarize_num_patients() + count_occurrences()
|
tab_ae_severity() |
Table 14.3.x AEs by SOC, PT and maximum severity | tern::count_occurrences_by_grade() |
lst_ae() |
Listing 16.2.x adverse events |
dplyr::arrange() on a tibble |
Plus parity_note() / sas_r_map (the SAS↔︎R equivalence table) and sasparity_example() (bundled fixture data).
tern/rtables cannot express in one layout call is out of scope. No efficacy tables, no labs/vitals, no exposure, no shift tables, no figures, no multi-page or multi-section outputs, no RTF/PDF rendering. Those are not “not yet” — they are a different package. Take $table and build them with rtables/tern/r2rtf directly.tab_disposition() has no overall (“All Patients”) column; the other tables do.tern defaults. Where a study needs something else, ... forwards to the underlying layout call — except denom, which is deliberately fixed (see below).adsl must already be filtered to the analysis population and adae must already carry TRTEMFL/AESER/ASEV as you want them counted. Derivation belongs in admiral.sas_r_map documents idioms whose R side lives in packages this one does not depend on (tidyr, haven, xportr, survival, mmrm, r2rtf, …). Those rows are documentation, not a runtime contract — nothing here calls them, and the difference column tells you where to look, it does not prove numerical equivalence for your study.
# from this local package tree
install.packages("/root/dev/rpkgs/sasparity", repos = NULL, type = "source")
library(sasparity)
# Small, self-contained fixture bundled with the package -- no network,
# no {pharmaverseadam} required. Filter to your analysis population first;
# the denominator shown in every table is exactly what you pass as `adsl`.
adsl <- sasparity_example("adsl")
adsl <- adsl[adsl$SAFFL == "Y", ]
adae <- sasparity_example("adae")
tab_demographics(adsl)
#> ── Demographics and Baseline Characteristics ──
#> ℹ SAS equivalent: %demog macro / PROC MEANS + PROC FREQ by treatment group (Table 14.1.x)
#> ℹ Denominator: n per column = subjects in `adsl` (10 total)
#> A B All Patients
#> (N=5) (N=5) (N=10)
#> AGE
#> Mean (SD) 66.2 (6.5) 64.8 (8.5) 65.5 (7.2)
#> ...
tab_disposition(adsl)
tab_ae_summary(adae, adsl)
tab_ae_soc(adae, adsl)
tab_ae_severity(adae, adsl)
lst_ae(adae)
# Which SAS macro/PROC each call replaces:
parity_note()Every tab_ae_*() function takes the population count from adsl, never from adae — and the difference is not cosmetic. adae is record-level and usually only contains subjects who had an event; naively dividing by “subjects present in adae” makes “at least one adverse event” read as 100% every time. tab_ae_summary() and tab_ae_soc() build the table with alt_counts_df = adsl and (where {tern}’s default would otherwise use the wrong base) explicitly force denom = "N_col", so the percentage is always subjects-with-the-event ÷ subjects-in-adsl. tests/testthat/test-ae-summary.R asserts this directly against a fixture where the two denominators would give different answers (3/5 = 60% vs. the wrong 3/3 = 100%).
tab_ae_soc()’s whole reason to exist is getting this right: a subject with three recorded episodes of the same preferred term must contribute 1 to that row, not 3. Built on {tern}’s summarize_num_patients() (SOC rows) and count_occurrences() (PT rows), both of which de-duplicate by subject id before counting. tests/testthat/test-ae-soc.R includes a fixture subject with two “NAUSEA” records and asserts the table shows 2 patients, not 3.
Every function forwards ... to the underlying {tern}/{rtables} layout call, so a study-specific statistic, label, or sort order is one argument away — you are never stuck rewriting the whole table by hand:
tab_demographics(adsl, vars = "AGE", .stats = "mean_sd")The SAS equivalent is a deliverable here, not a comment. sas_r_map is shipped package data — 51 rows, queryable with parity_note():
parity_note(sas = "PROC FREQ")[c("sas_idiom", "r_function")]
parity_note("dplyr::") # everything replaced by a dplyr verb
parity_note("tab_ae_soc") # one of this package's own displays
parity_note(sas = "nodupkey")$difference
#> "NODUPKEY sorts first, keeps the first record per key and can write the rest
#> to DUPOUT= for review; distinct() keeps the first row in the current row
#> order and discards the duplicates with no record of what was dropped."Columns: topic, sas_idiom, sas_equivalent, r_function, difference, sasparity_fn, phuse_files, phuse_example. difference is the column that matters — a mapping that claims equivalence where behaviour diverges is worse than no mapping, so every row has one and the test suite fails if any row loses it.
Source and licence. Every shipped row is evidenced by public, MIT-licensed PhUSE code: phuse-org/phuse-scripts (lang/SAS/, contributed/, licence in that repository’s LICENSE.md). phuse-org/TestDataFactory is scanned during curation but carries no licence file, so nothing sourced from it is shipped — all 51 rows cite phuse-scripts paths, and that is asserted in the tests. data-raw/sas_r_map.R rescans that corpus, records how many files each idiom appears in (phuse_files) and one example path (phuse_example), and refuses to write the dataset if any idiom cannot be found — so no row is invented. No PHUSE source code is redistributed here; only paths and counts. The %macro names in the six display rows (%demog, %aesumm, …) are the conventional sponsor-side names and are illustrative; it is the PROC idiom beside them that the corpus evidences.
See vignette("getting-started", package = "sasparity") for the full walkthrough.