From 2bf2ddb1e768d61a7ffdac88fb97eb9cb1568c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerardo=20J=2E=20Rodr=C3=ADguez?= <68994823+Gero1999@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:16:40 +0100 Subject: [PATCH 1/9] Add ex_to_PKNCAdose function --- R/sdtm-input | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 R/sdtm-input diff --git a/R/sdtm-input b/R/sdtm-input new file mode 100644 index 00000000..62b19f1a --- /dev/null +++ b/R/sdtm-input @@ -0,0 +1,129 @@ +ex_to_PKNCAdose <- function( + ex, + USUBJID = "USUBJID", + EXTRT = "EXTRT", + + + # Time variables to determine dose + EXSTDTC = "EXSTDTC", + EXDUR = "EXDUR", + # In case EXDUR is not derived + EXENDTC = "EXENDTC", + + # Nominal time variables + EXELTM = "EXELTM", + # In case EXELTM is not derived + EXTPTNUM = "EXTPTNUM", + EXRFTDTC = "EXRFTDTC", + + EXDOSE = "EXDOSE", + EXDOSU = "EXDOSU", + EXROUTE = "EXROUTE" +) { + + std_dtc_to_rdate <- function(dtc) { + formats <- c( + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M", + "%Y-%m-%dT%H", + "%Y-%m-%d" + ) + dtc_to_dt <- list() + for (fmt in formats) { + dtc_to_dt[[fmt]] <- + as.POSIXct(dtc, format=fmt, tz="UTC") + } + # Combine the results, preferring the most complete formats first + dplyr::coalesce(dtc_to_dt[[formats[1]]], dtc_to_dt[[formats[2]]], + dtc_to_dt[[formats[3]]], dtc_to_dt[[formats[4]]]) + } + + route_cdisc_to_pknca <- function(route) { + intravascular_pattern <- paste0( + "(INFUS|DRIP|IV|INTRAVEN|IVADMIN|BOLUS|INTRAVASCULAR|INTRA-?ARTERIAL|", + "INTRACARDIAC|INTRACORONARY)" + ) + ifelse( + grepl(intravascular_pattern, gsub("[^[:alnum:]]", "", route)), + "intravascular", + "extravascular" + ) + } + + ex2 <- ex %>% + + # Standardise all dates to R date-time format + mutate( + !!sym(EXSTDTC) := if (!!EXSTDTC %in% names(ex)) { + std_dtc_to_rdate(!!sym(EXSTDTC)) + } else { + as.POSIXct(NA) + }, + !!sym(EXENDTC) := if (!!EXENDTC %in% names(ex)) { + std_dtc_to_rdate(!!sym(EXENDTC)) + } else { + as.POSIXct(NA) + }, + !!sym(EXRFTDTC) := if (!!EXRFTDTC %in% names(ex)) { + std_dtc_to_rdate(!!sym(EXRFTDTC)) + } else { + NULL + } + ) %>% + # Derive EXDUR if missing + mutate( + !!sym(EXDUR) := if (!!EXDUR %in% names(ex)) { + !!sym(EXDUR) + } else { + dur <- as.numeric(difftime( + !!sym(EXENDTC), + !!sym(EXSTDTC), + units="hours" + )) + # When EXENDTC is NA (e.g. oral/instantaneous doses), duration defaults to 0 + ifelse(is.na(dur), 0, dur) + } + ) %>% + # Derive EXELTM if missing + mutate( + !!sym(EXELTM) := if (!!EXELTM %in% names(ex)) { + !!sym(EXELTM) + } else if (!!EXRFTDTC %in% names(ex)) { + as.numeric(difftime( + !!sym(EXSTDTC), + !!sym(EXRFTDTC), + units="hours" + )) + } else { + NULL + } + ) %>% + # Determine for each subject the reference (first) dose date-time + group_by(!!sym(USUBJID)) %>% + mutate( + EX_reference = min(!!sym(EXSTDTC), na.rm=TRUE) + ) %>% + ungroup() %>% + # Determine dose time in hours from reference + mutate( + AFRLT = as.numeric(difftime( + EXSTDTC, + EX_reference, + units="hours" + )), + ) + PKNCAdose_args <- list( + data = ex2, + formula = as.formula( + paste(EXDOSE, "~", "AFRLT", "|", paste(c(EXTRT, USUBJID), collapse="+")) + ), + route = if(EXROUTE %in% names(ex)) route_cdisc_to_pknca(ex2[[EXROUTE]]) else NULL, + time.nominal = if (EXELTM %in% names(ex2)) EXELTM else NULL, + duration = if (EXDUR %in% names(ex2)) EXDUR else NULL, + doseu = if (EXDOSU %in% names(ex2)) EXDOSU else NULL + ) + # Remove NULL entries + PKNCAdose_args <- PKNCAdose_args[!sapply(PKNCAdose_args, is.null)] + do.call(PKNCA::PKNCAdose, PKNCAdose_args) +} +o_dose <- ex_to_PKNCAdose(ex) From 1e9e692d1ec73efa5ea3c36ca27c96d174babcde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerardo=20J=2E=20Rodr=C3=ADguez?= <68994823+Gero1999@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:21:28 +0100 Subject: [PATCH 2/9] Add example SDTM EX domain for multi-dose PK study This script simulates a Phase I dose-escalation study with various parameters for drug exposure. It creates a data frame for the SDTM EX domain, detailing subjects, treatments, doses, and timing. --- data-raw/sdtm/ex.R | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 data-raw/sdtm/ex.R diff --git a/data-raw/sdtm/ex.R b/data-raw/sdtm/ex.R new file mode 100644 index 00000000..51b98495 --- /dev/null +++ b/data-raw/sdtm/ex.R @@ -0,0 +1,82 @@ +# Example SDTM EX (Exposure) domain for a multi-dose PK study +# +# Simulates a Phase I dose-escalation study with: +# - 6 subjects across 2 treatment arms (Drug A oral, Drug B IV infusion) +# - 3 dose levels (100mg, 200mg, 400mg oral; 50mg, 100mg IV) +# - Multiple dosing occasions (Day 1 and Day 8) +# - Mixed date-time precision (full datetime, date-only) +# - IV infusions with start/end times (for duration derivation) +# - Reference date/time for elapsed time derivation + +ex_example <- data.frame( + STUDYID = "PKS-001", + DOMAIN = "EX", + USUBJID = c( + # Drug A oral: 3 subjects, 2 doses each + "PKS-001-001", "PKS-001-001", + "PKS-001-002", "PKS-001-002", + "PKS-001-003", "PKS-001-003", + # Drug B IV infusion: 3 subjects, 2 doses each + "PKS-001-004", "PKS-001-004", + "PKS-001-005", "PKS-001-005", + "PKS-001-006", "PKS-001-006" + ), + EXSEQ = rep(c(1L, 2L), 6), + EXTRT = c( + rep("DRUG A", 6), + rep("DRUG B", 6) + ), + EXDOSE = c( + # Drug A: escalating oral doses + 100, 100, # Subject 1: 100mg on Day 1 and Day 8 + 200, 200, # Subject 2: 200mg + 400, 400, # Subject 3: 400mg + # Drug B: IV doses + 50, 50, # Subject 4: 50mg IV + 100, 100, # Subject 5: 100mg IV + 100, 100 # Subject 6: 100mg IV + ), + EXDOSU = "mg", + EXDOSFRM = c( + rep("TABLET", 6), + rep("SOLUTION", 6) + ), + EXROUTE = c( + rep("ORAL", 6), + rep("INTRAVENOUS INFUSION", 6) + ), + EXSTDTC = c( + # Drug A oral (instantaneous dosing, date+time) + "2024-03-01T08:00", "2024-03-08T08:00", + "2024-03-01T08:15", "2024-03-08T08:10", + "2024-03-01T08:30", "2024-03-08T08:25", + # Drug B IV infusion (start of infusion) + "2024-03-01T09:00:00", "2024-03-08T09:00:00", + "2024-03-01T09:05:00", "2024-03-08T09:10:00", + "2024-03-01T09:15", "2024-03-08T09:20" + ), + EXENDTC = c( + # Drug A oral: no end time (instantaneous dosing) + NA, NA, + NA, NA, + NA, NA, + # Drug B IV: 1-hour infusions + "2024-03-01T10:00:00", "2024-03-08T10:00:00", + "2024-03-01T10:05:00", "2024-03-08T10:10:00", + "2024-03-01T10:15", "2024-03-08T10:20" + ), + EXRFTDTC = c( + # Reference date = first dose date for each subject + "2024-03-01T08:00", "2024-03-01T08:00", + "2024-03-01T08:15", "2024-03-01T08:15", + "2024-03-01T08:30", "2024-03-01T08:30", + "2024-03-01T09:00:00", "2024-03-01T09:00:00", + "2024-03-01T09:05:00", "2024-03-01T09:05:00", + "2024-03-01T09:15", "2024-03-01T09:15" + ), + VISITNUM = rep(c(1L, 2L), 6), + VISIT = rep(c("DAY 1", "DAY 8"), 6), + EPOCH = "TREATMENT", + stringsAsFactors = FALSE +) +usethis::use_data(ex_example, overwrite = TRUE) From 0334cf1397f5d5ac8b4747861771f7a631ff5372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerardo=20J=2E=20Rodr=C3=ADguez?= <68994823+Gero1999@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:22:20 +0100 Subject: [PATCH 3/9] Add unit tests for ex_to_PKNCAdose function Add tests for ex_to_PKNCAdose function covering various scenarios including handling of oral doses, IV infusions, duration calculations, and optional columns. --- tests/test-sdtm-input | 169 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 tests/test-sdtm-input diff --git a/tests/test-sdtm-input b/tests/test-sdtm-input new file mode 100644 index 00000000..a7ba978b --- /dev/null +++ b/tests/test-sdtm-input @@ -0,0 +1,169 @@ +test_that("ex_to_PKNCAdose returns a PKNCAdose object", { + ex <- data.frame( + USUBJID = c("SUBJ-001", "SUBJ-001"), + EXTRT = c("DRUG A", "DRUG A"), + EXDOSE = c(100, 100), + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00", "2024-01-08T08:00"), + EXENDTC = c(NA, NA), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + expect_s3_class(result, "PKNCAdose") +}) + +test_that("ex_to_PKNCAdose handles oral doses with NA EXENDTC", { + ex <- data.frame( + USUBJID = c("SUBJ-001", "SUBJ-001"), + EXTRT = c("DRUG A", "DRUG A"), + EXDOSE = c(100, 100), + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00", "2024-01-08T08:00"), + EXENDTC = c(NA, NA), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + # Duration should be 0 for oral doses with NA EXENDTC + expect_true(all(result$data$EXDUR == 0)) +}) + +test_that("ex_to_PKNCAdose derives duration from EXENDTC for IV infusions", { + ex <- data.frame( + USUBJID = "SUBJ-001", + EXTRT = "DRUG B", + EXDOSE = 50, + EXDOSU = "mg", + EXROUTE = "INTRAVENOUS INFUSION", + EXSTDTC = "2024-01-01T09:00:00", + EXENDTC = "2024-01-01T10:00:00", + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + expect_equal(result$data$EXDUR, 1) +}) + +test_that("ex_to_PKNCAdose maps routes correctly", { + ex <- data.frame( + USUBJID = c("SUBJ-001", "SUBJ-002", "SUBJ-003", "SUBJ-004"), + EXTRT = "DRUG A", + EXDOSE = 100, + EXDOSU = "mg", + EXROUTE = c("ORAL", "INTRAVENOUS INFUSION", "INTRAVENOUS BOLUS", "SUBCUTANEOUS"), + EXSTDTC = "2024-01-01T08:00", + EXENDTC = c(NA, "2024-01-01T09:00", "2024-01-01T08:00", NA), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + route_col <- result$columns$route + # Oral and subcutaneous are extravascular; IV infusion and bolus are intravascular + expect_equal( + result$data[[route_col]], + c("extravascular", "intravascular", "intravascular", "extravascular") + ) +}) + +test_that("ex_to_PKNCAdose computes relative time from first dose per subject", { + ex <- data.frame( + USUBJID = c("SUBJ-001", "SUBJ-001", "SUBJ-002", "SUBJ-002"), + EXTRT = "DRUG A", + EXDOSE = 100, + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00", "2024-01-02T08:00", + "2024-01-01T10:00", "2024-01-02T10:00"), + EXENDTC = c(NA, NA, NA, NA), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + # First dose for each subject should be time 0, second dose at 24h + expect_equal(result$data$AFRLT, c(0, 24, 0, 24)) +}) + +test_that("ex_to_PKNCAdose handles mixed datetime precision", { + ex <- data.frame( + USUBJID = c("SUBJ-001", "SUBJ-001", "SUBJ-001"), + EXTRT = "DRUG A", + EXDOSE = 100, + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00:00", "2024-01-01T10:30", "2024-01-02"), + EXENDTC = c(NA, NA, NA), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + expect_s3_class(result, "PKNCAdose") + # All dates should be parsed (no NAs in AFRLT) + expect_false(any(is.na(result$data$AFRLT))) +}) + +test_that("ex_to_PKNCAdose derives EXELTM from EXRFTDTC when available", { + ex <- data.frame( + USUBJID = c("SUBJ-001", "SUBJ-001"), + EXTRT = "DRUG A", + EXDOSE = 100, + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00", "2024-01-02T08:00"), + EXENDTC = c(NA, NA), + EXRFTDTC = c("2024-01-01T08:00", "2024-01-01T08:00"), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + # EXELTM should be 0 for first dose, 24 for second + expect_equal(result$data$EXELTM, c(0, 24)) +}) + + +test_that("ex_to_PKNCAdose uses pre-existing EXDUR without deriving", { + ex <- data.frame( + USUBJID = c("SUBJ-001", "SUBJ-001"), + EXTRT = "DRUG B", + EXDOSE = c(50, 50), + EXDOSU = "mg", + EXROUTE = "INTRAVENOUS INFUSION", + EXSTDTC = c("2024-01-01T09:00:00", "2024-01-08T09:00:00"), + EXENDTC = c("2024-01-01T10:00:00", "2024-01-08T10:00:00"), + # Pre-existing EXDUR that differs from EXENDTC - EXSTDTC (1h) + # e.g. actual infusion was 0.75h, recorded separately from collection times + EXDUR = c(0.75, 0.5), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + # Should use the provided EXDUR, not derive from EXENDTC - EXSTDTC + expect_equal(result$data$EXDUR, c(0.75, 0.5)) +}) + +test_that("ex_to_PKNCAdose uses pre-existing EXELTM without deriving", { + ex <- data.frame( + USUBJID = c("SUBJ-001", "SUBJ-001"), + EXTRT = "DRUG A", + EXDOSE = c(100, 100), + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00", "2024-01-02T08:00"), + EXENDTC = c(NA, NA), + EXRFTDTC = c("2024-01-01T08:00", "2024-01-01T08:00"), + # Pre-existing EXELTM that differs from EXSTDTC - EXRFTDTC (0h, 24h) + # e.g. protocol-defined nominal elapsed times + EXELTM = c(0, 168), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + # Should use the provided EXELTM, not derive from EXSTDTC - EXRFTDTC + expect_equal(result$data$EXELTM, c(0, 168)) +}) + +test_that("ex_to_PKNCAdose works without optional columns", { + # Minimal dataset: no EXENDTC, no EXRFTDTC, no EXROUTE, no EXDOSU + ex <- data.frame( + USUBJID = c("SUBJ-001", "SUBJ-001"), + EXTRT = "DRUG A", + EXDOSE = c(100, 100), + EXSTDTC = c("2024-01-01T08:00", "2024-01-08T08:00"), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + expect_s3_class(result, "PKNCAdose") +}) From a272b062b1e54aaa65dab96bc401d0addd53877d Mon Sep 17 00:00:00 2001 From: Gero1999 Date: Mon, 16 Mar 2026 14:27:07 +0100 Subject: [PATCH 4/9] rename ex.R > ex_example.R --- data-raw/sdtm/{ex.R => ex_example.R} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename data-raw/sdtm/{ex.R => ex_example.R} (100%) diff --git a/data-raw/sdtm/ex.R b/data-raw/sdtm/ex_example.R similarity index 100% rename from data-raw/sdtm/ex.R rename to data-raw/sdtm/ex_example.R From 72b46acd859c3be62503b78860b42f6ed8da4414 Mon Sep 17 00:00:00 2001 From: Gero1999 Date: Mon, 16 Mar 2026 14:40:27 +0100 Subject: [PATCH 5/9] rename files properly with .R --- R/{sdtm-input => sdtm-input.R} | 0 tests/{test-sdtm-input => testthat/test-sdtm-input.R} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename R/{sdtm-input => sdtm-input.R} (100%) rename tests/{test-sdtm-input => testthat/test-sdtm-input.R} (100%) diff --git a/R/sdtm-input b/R/sdtm-input.R similarity index 100% rename from R/sdtm-input rename to R/sdtm-input.R diff --git a/tests/test-sdtm-input b/tests/testthat/test-sdtm-input.R similarity index 100% rename from tests/test-sdtm-input rename to tests/testthat/test-sdtm-input.R From ec73b4280b84d4eccf9c80cff8309f377c3c319b Mon Sep 17 00:00:00 2001 From: Gero1999 Date: Mon, 16 Mar 2026 14:40:48 +0100 Subject: [PATCH 6/9] complete ex_example.R with use of ex_to_PKNCAdose() --- data-raw/sdtm/ex_example.R | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/data-raw/sdtm/ex_example.R b/data-raw/sdtm/ex_example.R index 51b98495..cb0a92d6 100644 --- a/data-raw/sdtm/ex_example.R +++ b/data-raw/sdtm/ex_example.R @@ -80,3 +80,7 @@ ex_example <- data.frame( stringsAsFactors = FALSE ) usethis::use_data(ex_example, overwrite = TRUE) + +# Process SDTM EX +library(PKNCA) +ex_to_PKNCAdose(ex) From dd3f70c5a51edd220d1afa709e882b1e1f28b4b8 Mon Sep 17 00:00:00 2001 From: Gero1999 Date: Mon, 16 Mar 2026 15:12:08 +0100 Subject: [PATCH 7/9] rm usethis::use_data & ex_to_PKNCAdose(ex) example in sdtm-input.R --- R/sdtm-input.R | 2 +- data-raw/sdtm/ex_example.R | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/R/sdtm-input.R b/R/sdtm-input.R index 62b19f1a..339fad2f 100644 --- a/R/sdtm-input.R +++ b/R/sdtm-input.R @@ -126,4 +126,4 @@ ex_to_PKNCAdose <- function( PKNCAdose_args <- PKNCAdose_args[!sapply(PKNCAdose_args, is.null)] do.call(PKNCA::PKNCAdose, PKNCAdose_args) } -o_dose <- ex_to_PKNCAdose(ex) + diff --git a/data-raw/sdtm/ex_example.R b/data-raw/sdtm/ex_example.R index cb0a92d6..1c7f84fa 100644 --- a/data-raw/sdtm/ex_example.R +++ b/data-raw/sdtm/ex_example.R @@ -79,7 +79,6 @@ ex_example <- data.frame( EPOCH = "TREATMENT", stringsAsFactors = FALSE ) -usethis::use_data(ex_example, overwrite = TRUE) # Process SDTM EX library(PKNCA) From 89e58e20eb7a28a750c265cb8837309a774fab28 Mon Sep 17 00:00:00 2001 From: Gero1999 Date: Mon, 16 Mar 2026 17:37:16 +0100 Subject: [PATCH 8/9] add docs & update man / NAMESPACE --- NAMESPACE | 3 +++ R/sdtm-input.R | 27 +++++++++++++++++++ man/ex_to_PKNCAdose.Rd | 59 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 man/ex_to_PKNCAdose.Rd diff --git a/NAMESPACE b/NAMESPACE index f4942744..39cdd084 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -133,6 +133,7 @@ export(choose.auc.intervals) export(clean.conc.blq) export(clean.conc.na) export(cov_holder) +export(ex_to_PKNCAdose) export(exclude) export(exclude_nca_by_param) export(exclude_nca_count_conc_measured) @@ -248,6 +249,7 @@ export(time_calc) export(ungroup) export(var_sparse_auc) importFrom(dplyr,"%>%") +importFrom(dplyr,coalesce) importFrom(dplyr,filter) importFrom(dplyr,full_join) importFrom(dplyr,group_by) @@ -260,5 +262,6 @@ importFrom(dplyr,ungroup) importFrom(lifecycle,deprecated) importFrom(nlme,getGroups) importFrom(rlang,.data) +importFrom(rlang,sym) importFrom(stats,formula) importFrom(stats,model.frame) diff --git a/R/sdtm-input.R b/R/sdtm-input.R index 339fad2f..4516630f 100644 --- a/R/sdtm-input.R +++ b/R/sdtm-input.R @@ -1,3 +1,30 @@ +#' Convert an EX (Exposure) SDTM domain to a PKNCAdose object +#' +#' Transforms a CDISC SDTM EX domain data frame into a \code{PKNCAdose} object +#' suitable for NCA analysis with PKNCA. Handles date-time parsing, duration +#' derivation, elapsed time derivation, route mapping, and relative time +#' computation. +#' +#' @param ex A data.frame containing the EX (Exposure) SDTM domain +#' @param USUBJID Column name for the unique subject identifier +#' @param EXTRT Column name for the treatment name +#' @param EXSTDTC Column name for the start date/time of treatment (ISO 8601) +#' @param EXDUR Column name for the duration of treatment. If the column is +#' absent, it is derived from \code{EXSTDTC} and \code{EXENDTC}. +#' @param EXENDTC Column name for the end date/time of treatment (ISO 8601). +#' Used to derive \code{EXDUR} when not available. +#' @param EXELTM Column name for the planned elapsed time since first dose. +#' If absent, derived from \code{EXSTDTC} and \code{EXRFTDTC}. +#' @param EXTPTNUM Column name for the planned time point number +#' @param EXRFTDTC Column name for the reference date/time (ISO 8601). +#' Used to derive \code{EXELTM} when not available. +#' @param EXDOSE Column name for the dose per administration +#' @param EXDOSU Column name for the dose units +#' @param EXROUTE Column name for the route of administration +#' @return A \code{PKNCAdose} object +#' @importFrom dplyr mutate group_by ungroup coalesce +#' @importFrom rlang sym +#' @export ex_to_PKNCAdose <- function( ex, USUBJID = "USUBJID", diff --git a/man/ex_to_PKNCAdose.Rd b/man/ex_to_PKNCAdose.Rd new file mode 100644 index 00000000..d377126a --- /dev/null +++ b/man/ex_to_PKNCAdose.Rd @@ -0,0 +1,59 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/sdtm-input.R +\name{ex_to_PKNCAdose} +\alias{ex_to_PKNCAdose} +\title{Convert an EX (Exposure) SDTM domain to a PKNCAdose object} +\usage{ +ex_to_PKNCAdose( + ex, + USUBJID = "USUBJID", + EXTRT = "EXTRT", + EXSTDTC = "EXSTDTC", + EXDUR = "EXDUR", + EXENDTC = "EXENDTC", + EXELTM = "EXELTM", + EXTPTNUM = "EXTPTNUM", + EXRFTDTC = "EXRFTDTC", + EXDOSE = "EXDOSE", + EXDOSU = "EXDOSU", + EXROUTE = "EXROUTE" +) +} +\arguments{ +\item{ex}{A data.frame containing the EX (Exposure) SDTM domain} + +\item{USUBJID}{Column name for the unique subject identifier} + +\item{EXTRT}{Column name for the treatment name} + +\item{EXSTDTC}{Column name for the start date/time of treatment (ISO 8601)} + +\item{EXDUR}{Column name for the duration of treatment. If the column is +absent, it is derived from \code{EXSTDTC} and \code{EXENDTC}.} + +\item{EXENDTC}{Column name for the end date/time of treatment (ISO 8601). +Used to derive \code{EXDUR} when not available.} + +\item{EXELTM}{Column name for the planned elapsed time since first dose. +If absent, derived from \code{EXSTDTC} and \code{EXRFTDTC}.} + +\item{EXTPTNUM}{Column name for the planned time point number} + +\item{EXRFTDTC}{Column name for the reference date/time (ISO 8601). +Used to derive \code{EXELTM} when not available.} + +\item{EXDOSE}{Column name for the dose per administration} + +\item{EXDOSU}{Column name for the dose units} + +\item{EXROUTE}{Column name for the route of administration} +} +\value{ +A \code{PKNCAdose} object +} +\description{ +Transforms a CDISC SDTM EX domain data frame into a \code{PKNCAdose} object +suitable for NCA analysis with PKNCA. Handles date-time parsing, duration +derivation, elapsed time derivation, route mapping, and relative time +computation. +} From 9e1879aec6cdc122579a44db48be693fdd7f9148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerardo=20J=2E=20Rodr=C3=ADguez?= <68994823+Gero1999@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:41:25 +0000 Subject: [PATCH 9/9] feat: derive NFRLT from EXRFTDTC + EXELTM as time.nominal Extract std_dtc_to_rdate, parse_iso8601_duration, and route_cdisc_to_pknca as top-level internal helpers. Parse EXELTM from ISO 8601 duration to numeric hours when character. Derive NFRLT = (EXRFTDTC + EXELTM) - min(EXRFTDTC) per dose grouping and use it as time.nominal in PKNCAdose instead of raw EXELTM. Co-authored-by: Ona --- R/sdtm-input.R | 158 +++++++++++++++++++++++-------- man/ex_to_PKNCAdose.Rd | 19 +++- tests/testthat/test-sdtm-input.R | 148 +++++++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 42 deletions(-) diff --git a/R/sdtm-input.R b/R/sdtm-input.R index 4516630f..e60b390e 100644 --- a/R/sdtm-input.R +++ b/R/sdtm-input.R @@ -1,3 +1,73 @@ +#' Parse ISO 8601 date-time strings with mixed precision +#' +#' Handles full datetime, datetime without seconds, datetime with hour only, +#' and date-only formats. Returns POSIXct in UTC. +#' +#' @param dtc Character vector of ISO 8601 date-time strings +#' @return POSIXct vector in UTC +#' @keywords internal +std_dtc_to_rdate <- function(dtc) { + formats <- c( + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M", + "%Y-%m-%dT%H", + "%Y-%m-%d" + ) + dtc_to_dt <- list() + for (fmt in formats) { + dtc_to_dt[[fmt]] <- as.POSIXct(dtc, format = fmt, tz = "UTC") + } + dplyr::coalesce( + dtc_to_dt[[formats[1]]], dtc_to_dt[[formats[2]]], + dtc_to_dt[[formats[3]]], dtc_to_dt[[formats[4]]] + ) +} + +#' Parse an ISO 8601 duration string to numeric hours +#' +#' Supports durations in the form \code{PTH}, \code{PTM}, +#' \code{PTS}, or combinations like \code{PT1H30M}. Also handles +#' negative durations (e.g. \code{PT-0.5H}). Returns the total duration in +#' hours as a numeric value. +#' +#' @param x Character vector of ISO 8601 duration strings (e.g. \code{"PT2H"}, +#' \code{"PT1H30M"}, \code{"PT90M"}) +#' @return Numeric vector of durations in hours +#' @keywords internal +parse_iso8601_duration <- function(x) { + vapply(x, function(val) { + if (is.na(val) || !grepl("^PT", val)) return(NA_real_) + hours <- 0 + h_match <- regmatches(val, regexpr("-?[0-9.]+(?=H)", val, perl = TRUE)) + if (length(h_match) == 1) hours <- hours + as.numeric(h_match) + m_match <- regmatches(val, regexpr("-?[0-9.]+(?=M)", val, perl = TRUE)) + if (length(m_match) == 1) hours <- hours + as.numeric(m_match) / 60 + s_match <- regmatches(val, regexpr("-?[0-9.]+(?=S)", val, perl = TRUE)) + if (length(s_match) == 1) hours <- hours + as.numeric(s_match) / 3600 + hours + }, numeric(1), USE.NAMES = FALSE) +} + +#' Map CDISC route of administration to PKNCA route +#' +#' @param route Character vector of CDISC route values +#' @return Character vector of \code{"intravascular"} or +#' \code{"extravascular"} +#' @keywords internal +route_cdisc_to_pknca <- function(route) { + intravascular_pattern <- paste0( + "(INFUS|DRIP|IV|INTRAVEN|IVADMIN|BOLUS|INTRAVASCULAR|INTRA-?ARTERIAL|", + "INTRACARDIAC|INTRACORONARY)" + ) + ifelse( + grepl(intravascular_pattern, gsub("[^[:alnum:]]", "", toupper(route))), + "intravascular", + "extravascular" + ) +} + +# --- EX to PKNCAdose --------------------------------------------------------- + #' Convert an EX (Exposure) SDTM domain to a PKNCAdose object #' #' Transforms a CDISC SDTM EX domain data frame into a \code{PKNCAdose} object @@ -5,6 +75,20 @@ #' derivation, elapsed time derivation, route mapping, and relative time #' computation. #' +#' @section NFRLT derivation: +#' When \code{EXRFTDTC} and \code{EXELTM} are available, the function derives +#' \code{NFRLT} (nominal time from reference) for each dose record: +#' \enumerate{ +#' \item \code{EXELTM} is parsed from ISO 8601 duration to numeric hours +#' (if character), or used as-is (if already numeric). +#' \item Per dose grouping (e.g. \code{EXTRT + USUBJID}): +#' \code{nominal_ref = min(EXRFTDTC)} +#' \item \code{NFRLT = (EXRFTDTC + EXELTM) - nominal_ref} (in hours) +#' } +#' \code{NFRLT} is used as \code{time.nominal} in the PKNCAdose object. +#' If \code{EXRFTDTC} or \code{EXELTM} are not available, \code{NFRLT} is +#' not derived and \code{time.nominal} is omitted. +#' #' @param ex A data.frame containing the EX (Exposure) SDTM domain #' @param USUBJID Column name for the unique subject identifier #' @param EXTRT Column name for the treatment name @@ -14,10 +98,12 @@ #' @param EXENDTC Column name for the end date/time of treatment (ISO 8601). #' Used to derive \code{EXDUR} when not available. #' @param EXELTM Column name for the planned elapsed time since first dose. +#' Can be numeric (hours) or ISO 8601 duration (e.g. \code{"PT2H"}). #' If absent, derived from \code{EXSTDTC} and \code{EXRFTDTC}. #' @param EXTPTNUM Column name for the planned time point number #' @param EXRFTDTC Column name for the reference date/time (ISO 8601). -#' Used to derive \code{EXELTM} when not available. +#' Used to derive \code{EXELTM} when not available, and to compute +#' \code{NFRLT}. #' @param EXDOSE Column name for the dose per administration #' @param EXDOSU Column name for the dose units #' @param EXROUTE Column name for the route of administration @@ -30,7 +116,6 @@ ex_to_PKNCAdose <- function( USUBJID = "USUBJID", EXTRT = "EXTRT", - # Time variables to determine dose EXSTDTC = "EXSTDTC", EXDUR = "EXDUR", @@ -48,34 +133,8 @@ ex_to_PKNCAdose <- function( EXROUTE = "EXROUTE" ) { - std_dtc_to_rdate <- function(dtc) { - formats <- c( - "%Y-%m-%dT%H:%M:%S", - "%Y-%m-%dT%H:%M", - "%Y-%m-%dT%H", - "%Y-%m-%d" - ) - dtc_to_dt <- list() - for (fmt in formats) { - dtc_to_dt[[fmt]] <- - as.POSIXct(dtc, format=fmt, tz="UTC") - } - # Combine the results, preferring the most complete formats first - dplyr::coalesce(dtc_to_dt[[formats[1]]], dtc_to_dt[[formats[2]]], - dtc_to_dt[[formats[3]]], dtc_to_dt[[formats[4]]]) - } - - route_cdisc_to_pknca <- function(route) { - intravascular_pattern <- paste0( - "(INFUS|DRIP|IV|INTRAVEN|IVADMIN|BOLUS|INTRAVASCULAR|INTRA-?ARTERIAL|", - "INTRACARDIAC|INTRACORONARY)" - ) - ifelse( - grepl(intravascular_pattern, gsub("[^[:alnum:]]", "", route)), - "intravascular", - "extravascular" - ) - } + # Grouping variables for the dose formula + group_vars <- c(EXTRT, USUBJID) ex2 <- ex %>% @@ -105,21 +164,22 @@ ex_to_PKNCAdose <- function( dur <- as.numeric(difftime( !!sym(EXENDTC), !!sym(EXSTDTC), - units="hours" + units = "hours" )) # When EXENDTC is NA (e.g. oral/instantaneous doses), duration defaults to 0 ifelse(is.na(dur), 0, dur) } ) %>% - # Derive EXELTM if missing + # Derive EXELTM if missing; parse ISO 8601 duration if character mutate( !!sym(EXELTM) := if (!!EXELTM %in% names(ex)) { - !!sym(EXELTM) + eltm <- !!sym(EXELTM) + if (is.character(eltm)) parse_iso8601_duration(eltm) else eltm } else if (!!EXRFTDTC %in% names(ex)) { as.numeric(difftime( !!sym(EXSTDTC), !!sym(EXRFTDTC), - units="hours" + units = "hours" )) } else { NULL @@ -128,24 +188,40 @@ ex_to_PKNCAdose <- function( # Determine for each subject the reference (first) dose date-time group_by(!!sym(USUBJID)) %>% mutate( - EX_reference = min(!!sym(EXSTDTC), na.rm=TRUE) + EX_reference = min(!!sym(EXSTDTC), na.rm = TRUE) ) %>% ungroup() %>% # Determine dose time in hours from reference mutate( AFRLT = as.numeric(difftime( - EXSTDTC, + !!sym(EXSTDTC), EX_reference, - units="hours" - )), + units = "hours" + )) ) + + # Derive NFRLT from EXRFTDTC + EXELTM when both are available + has_nfrlt <- EXRFTDTC %in% names(ex2) && EXELTM %in% names(ex2) + if (has_nfrlt) { + ex2 <- ex2 %>% + group_by(!!!syms(group_vars)) %>% + mutate( + NFRLT = as.numeric(difftime( + !!sym(EXRFTDTC) + !!sym(EXELTM) * 3600, + min(!!sym(EXRFTDTC), na.rm = TRUE), + units = "hours" + )) + ) %>% + ungroup() + } + PKNCAdose_args <- list( data = ex2, formula = as.formula( - paste(EXDOSE, "~", "AFRLT", "|", paste(c(EXTRT, USUBJID), collapse="+")) + paste(EXDOSE, "~", "AFRLT", "|", paste(group_vars, collapse = "+")) ), - route = if(EXROUTE %in% names(ex)) route_cdisc_to_pknca(ex2[[EXROUTE]]) else NULL, - time.nominal = if (EXELTM %in% names(ex2)) EXELTM else NULL, + route = if (EXROUTE %in% names(ex)) route_cdisc_to_pknca(ex2[[EXROUTE]]) else NULL, + time.nominal = if (has_nfrlt) "NFRLT" else NULL, duration = if (EXDUR %in% names(ex2)) EXDUR else NULL, doseu = if (EXDOSU %in% names(ex2)) EXDOSU else NULL ) diff --git a/man/ex_to_PKNCAdose.Rd b/man/ex_to_PKNCAdose.Rd index d377126a..1ff497b7 100644 --- a/man/ex_to_PKNCAdose.Rd +++ b/man/ex_to_PKNCAdose.Rd @@ -35,12 +35,14 @@ absent, it is derived from \code{EXSTDTC} and \code{EXENDTC}.} Used to derive \code{EXDUR} when not available.} \item{EXELTM}{Column name for the planned elapsed time since first dose. +Can be numeric (hours) or ISO 8601 duration (e.g. \code{"PT2H"}). If absent, derived from \code{EXSTDTC} and \code{EXRFTDTC}.} \item{EXTPTNUM}{Column name for the planned time point number} \item{EXRFTDTC}{Column name for the reference date/time (ISO 8601). -Used to derive \code{EXELTM} when not available.} +Used to derive \code{EXELTM} when not available, and to compute +\code{NFRLT}.} \item{EXDOSE}{Column name for the dose per administration} @@ -57,3 +59,18 @@ suitable for NCA analysis with PKNCA. Handles date-time parsing, duration derivation, elapsed time derivation, route mapping, and relative time computation. } +\section{NFRLT derivation}{ + +When \code{EXRFTDTC} and \code{EXELTM} are available, the function derives +\code{NFRLT} (nominal time from reference) for each dose record: +\enumerate{ + \item \code{EXELTM} is parsed from ISO 8601 duration to numeric hours + (if character), or used as-is (if already numeric). + \item Per dose grouping (e.g. \code{EXTRT + USUBJID}): + \code{nominal_ref = min(EXRFTDTC)} + \item \code{NFRLT = (EXRFTDTC + EXELTM) - nominal_ref} (in hours) +} +\code{NFRLT} is used as \code{time.nominal} in the PKNCAdose object. +If \code{EXRFTDTC} or \code{EXELTM} are not available, \code{NFRLT} is +not derived and \code{time.nominal} is omitted. +} diff --git a/tests/testthat/test-sdtm-input.R b/tests/testthat/test-sdtm-input.R index a7ba978b..d4ef9174 100644 --- a/tests/testthat/test-sdtm-input.R +++ b/tests/testthat/test-sdtm-input.R @@ -1,3 +1,51 @@ +# --- Tests for shared helpers ------------------------------------------------ + +test_that("std_dtc_to_rdate parses mixed precision datetimes", { + result <- std_dtc_to_rdate(c( + "2024-01-01T08:00:00", + "2024-01-01T10:30", + "2024-01-02T14", + "2024-01-03" + )) + expect_s3_class(result, "POSIXct") + expect_equal(length(result), 4) + expect_false(any(is.na(result))) + # Full precision + expect_equal(format(result[1], "%H:%M:%S"), "08:00:00") + # Minute precision + expect_equal(format(result[2], "%H:%M"), "10:30") +}) + +test_that("parse_iso8601_duration handles standard durations", { + expect_equal(parse_iso8601_duration("PT1H"), 1) + expect_equal(parse_iso8601_duration("PT2H"), 2) + expect_equal(parse_iso8601_duration("PT30M"), 0.5) + expect_equal(parse_iso8601_duration("PT1H30M"), 1.5) + expect_equal(parse_iso8601_duration("PT3600S"), 1) + expect_true(is.na(parse_iso8601_duration(NA))) + expect_true(is.na(parse_iso8601_duration("not_a_duration"))) +}) + +test_that("parse_iso8601_duration handles negative elapsed times", { + # EXELTM can encode pre-dose times as PT-0.083H + expect_equal(parse_iso8601_duration("PT-0.083H"), -0.083) +}) + +test_that("parse_iso8601_duration handles vectors", { + result <- parse_iso8601_duration(c("PT1H", "PT2H", NA, "PT30M")) + expect_equal(result, c(1, 2, NA, 0.5)) +}) + +test_that("route_cdisc_to_pknca maps routes correctly", { + expect_equal(route_cdisc_to_pknca("ORAL"), "extravascular") + expect_equal(route_cdisc_to_pknca("INTRAVENOUS INFUSION"), "intravascular") + expect_equal(route_cdisc_to_pknca("INTRAVENOUS BOLUS"), "intravascular") + expect_equal(route_cdisc_to_pknca("SUBCUTANEOUS"), "extravascular") + expect_equal(route_cdisc_to_pknca("INTRAMUSCULAR"), "extravascular") +}) + +# --- Tests for ex_to_PKNCAdose ----------------------------------------------- + test_that("ex_to_PKNCAdose returns a PKNCAdose object", { ex <- data.frame( USUBJID = c("SUBJ-001", "SUBJ-001"), @@ -167,3 +215,103 @@ test_that("ex_to_PKNCAdose works without optional columns", { result <- ex_to_PKNCAdose(ex) expect_s3_class(result, "PKNCAdose") }) + +test_that("ex_to_PKNCAdose derives NFRLT from EXRFTDTC and numeric EXELTM", { + ex <- data.frame( + USUBJID = c("S1", "S1"), + EXTRT = "DRUG A", + EXDOSE = 100, + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00:00", "2024-01-02T08:00:00"), + EXENDTC = NA, + EXRFTDTC = c("2024-01-01T08:00:00", "2024-01-02T08:00:00"), + EXELTM = c(0, 24), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + expect_true("NFRLT" %in% names(result$data)) + # Dose 1: (EXRFTDTC + 0h) - min(EXRFTDTC) = 0 + # Dose 2: (EXRFTDTC + 24h) - min(EXRFTDTC) = 48 + # because EXRFTDTC[2] is already 24h after EXRFTDTC[1], plus EXELTM=24 + expect_equal(result$data$NFRLT, c(0, 48)) +}) + +test_that("ex_to_PKNCAdose derives NFRLT from EXRFTDTC and ISO 8601 EXELTM", { + ex <- data.frame( + USUBJID = c("S1", "S1"), + EXTRT = "DRUG A", + EXDOSE = 100, + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00:00", "2024-01-02T08:00:00"), + EXENDTC = NA, + EXRFTDTC = c("2024-01-01T08:00:00", "2024-01-01T08:00:00"), + EXELTM = c("PT0H", "PT24H"), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + expect_true("NFRLT" %in% names(result$data)) + # Same EXRFTDTC for both, so: + # Dose 1: (EXRFTDTC + 0h) - EXRFTDTC = 0 + # Dose 2: (EXRFTDTC + 24h) - EXRFTDTC = 24 + expect_equal(result$data$NFRLT, c(0, 24)) + # EXELTM should be parsed to numeric + expect_equal(result$data$EXELTM, c(0, 24)) +}) + +test_that("ex_to_PKNCAdose uses NFRLT as time.nominal", { + ex <- data.frame( + USUBJID = c("S1", "S1"), + EXTRT = "DRUG A", + EXDOSE = 100, + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00:00", "2024-01-02T08:00:00"), + EXENDTC = NA, + EXRFTDTC = c("2024-01-01T08:00:00", "2024-01-01T08:00:00"), + EXELTM = c(0, 24), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + # time.nominal should point to NFRLT + expect_equal(result$columns$time.nominal, "NFRLT") +}) + +test_that("ex_to_PKNCAdose skips NFRLT when EXRFTDTC is absent", { + ex <- data.frame( + USUBJID = c("S1", "S1"), + EXTRT = "DRUG A", + EXDOSE = 100, + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00", "2024-01-02T08:00"), + EXENDTC = NA, + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + expect_false("NFRLT" %in% names(result$data)) +}) + +test_that("ex_to_PKNCAdose derives NFRLT per treatment group", { + ex <- data.frame( + USUBJID = c("S1", "S1", "S1", "S1"), + EXTRT = c("DRUG A", "DRUG A", "DRUG B", "DRUG B"), + EXDOSE = 100, + EXDOSU = "mg", + EXROUTE = "ORAL", + EXSTDTC = c("2024-01-01T08:00", "2024-01-02T08:00", + "2024-01-01T10:00", "2024-01-02T10:00"), + EXENDTC = NA, + EXRFTDTC = c("2024-01-01T08:00", "2024-01-01T08:00", + "2024-01-01T10:00", "2024-01-01T10:00"), + EXELTM = c(0, 24, 0, 24), + stringsAsFactors = FALSE + ) + result <- ex_to_PKNCAdose(ex) + expect_true("NFRLT" %in% names(result$data)) + # Each treatment group has its own nominal_ref = min(EXRFTDTC) + # DRUG A: NFRLT = (EXRFTDTC + EXELTM) - min(EXRFTDTC_A) = 0, 24 + # DRUG B: NFRLT = (EXRFTDTC + EXELTM) - min(EXRFTDTC_B) = 0, 24 + expect_equal(result$data$NFRLT, c(0, 24, 0, 24)) +})