diff --git a/NAMESPACE b/NAMESPACE index 5826c4d0..2d9be067 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -135,6 +135,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) @@ -254,6 +255,7 @@ export(ungroup) export(var_sparse_auc) export(var_sparse_aumc) importFrom(dplyr,"%>%") +importFrom(dplyr,coalesce) importFrom(dplyr,filter) importFrom(dplyr,full_join) importFrom(dplyr,group_by) @@ -266,5 +268,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 new file mode 100644 index 00000000..e60b390e --- /dev/null +++ b/R/sdtm-input.R @@ -0,0 +1,232 @@ +#' 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 +#' 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. +#' +#' @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. +#' 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, 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 +#' @return A \code{PKNCAdose} object +#' @importFrom dplyr mutate group_by ungroup coalesce +#' @importFrom rlang sym +#' @export +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" +) { + + # Grouping variables for the dose formula + group_vars <- c(EXTRT, USUBJID) + + 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; parse ISO 8601 duration if character + mutate( + !!sym(EXELTM) := if (!!EXELTM %in% names(ex)) { + 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" + )) + } 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( + !!sym(EXSTDTC), + EX_reference, + 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(group_vars, collapse = "+")) + ), + 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 + ) + # Remove NULL entries + PKNCAdose_args <- PKNCAdose_args[!sapply(PKNCAdose_args, is.null)] + do.call(PKNCA::PKNCAdose, PKNCAdose_args) +} + diff --git a/data-raw/sdtm/ex_example.R b/data-raw/sdtm/ex_example.R new file mode 100644 index 00000000..1c7f84fa --- /dev/null +++ b/data-raw/sdtm/ex_example.R @@ -0,0 +1,85 @@ +# 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 +) + +# Process SDTM EX +library(PKNCA) +ex_to_PKNCAdose(ex) diff --git a/man/ex_to_PKNCAdose.Rd b/man/ex_to_PKNCAdose.Rd new file mode 100644 index 00000000..1ff497b7 --- /dev/null +++ b/man/ex_to_PKNCAdose.Rd @@ -0,0 +1,76 @@ +% 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. +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, and to compute +\code{NFRLT}.} + +\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. +} +\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 new file mode 100644 index 00000000..d4ef9174 --- /dev/null +++ b/tests/testthat/test-sdtm-input.R @@ -0,0 +1,317 @@ +# --- 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"), + 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") +}) + +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)) +})