diff --git a/.lintr b/.lintr index 2d1f9f985..9738f4601 100644 --- a/.lintr +++ b/.lintr @@ -11,4 +11,4 @@ linters: seq_linter = seq_linter() ) encoding: "UTF-8" -exclusions: list("R/PKNCA_extra_parameters.R", "tests/testthat/test-PKNCA_extra_parameters.R", "inst/www/templates/script_template.R") +exclusions: list("R/PKNCA_extra_parameters.R", "tests/testthat/test-PKNCA_extra_parameters.R", "inst/www/templates/script_template.R", "inst/www/templates/script_template_sdtm.R") diff --git a/R/get_session_code.R b/R/get_session_code.R index 13eaf9b45..1df3a6ebf 100644 --- a/R/get_session_code.R +++ b/R/get_session_code.R @@ -213,10 +213,21 @@ get_settings_code <- function( settings_file_path, data_path, output_path = "settings_code.R", - template_path = system.file("www/templates/script_template.R", package = "aNCA") + template_path = NULL ) { settings <- read_settings(settings_file_path) + # Auto-select template based on input_mode if not explicitly provided + if (is.null(template_path)) { + mode <- settings[["input_mode"]] %||% "adnca" + template_file <- if (identical(mode, "sdtm")) { + "www/templates/script_template_sdtm.R" + } else { + "www/templates/script_template.R" + } + template_path <- system.file(template_file, package = "aNCA") + } + session <- list( settings = settings[["settings"]], slope_rules = settings[["slope_rules"]], @@ -228,7 +239,8 @@ get_settings_code <- function( extra_vars_to_keep = c( settings[["mapping"]][["Grouping_Variables"]], "DOSEA", "ATPTREF", "ROUTE" ), - time_duplicate_rows = settings[["time_duplicate_keys"]] + time_duplicate_rows = settings[["time_duplicate_keys"]], + sdtm_metabolites = settings[["sdtm_metabolites"]] ) get_code( diff --git a/inst/shiny/functions/zip-utils.R b/inst/shiny/functions/zip-utils.R index b4101c35e..d1e504b28 100644 --- a/inst/shiny/functions/zip-utils.R +++ b/inst/shiny/functions/zip-utils.R @@ -435,7 +435,19 @@ prepare_export_files <- function(target_dir, if ("r_script" %in% input$res_tree) { progress$set(message = "Creating exports...", detail = "Saving R script...") - saveRDS(session$userData$raw_data, file.path(target_dir, "input_data.rds")) + mode <- session$userData$input_mode %||% "adnca" + if (mode == "sdtm") { + sdtm <- session$userData$sdtm_raw + if (!is.null(sdtm)) { + saveRDS(sdtm$pc, file.path(target_dir, "input_pc.rds")) + saveRDS(sdtm$ex, file.path(target_dir, "input_ex.rds")) + if (!is.null(sdtm$dm)) { + saveRDS(sdtm$dm, file.path(target_dir, "input_dm.rds")) + } + } + } else { + saveRDS(session$userData$raw_data, file.path(target_dir, "input_data.rds")) + } .export_script(target_dir, session) } } else { @@ -569,9 +581,15 @@ prepare_export_files <- function(target_dir, slope_rules = session$userData$slope_rules(), filters = session$userData$applied_filters, time_duplicate_keys = session$userData$time_duplicate_keys, - nca_ran = isTRUE(session$userData$nca_ran) + nca_ran = isTRUE(session$userData$nca_ran), + input_mode = session$userData$input_mode %||% "adnca" ) + # Store SDTM-specific settings + if (identical(payload$input_mode, "sdtm")) { + payload$sdtm_metabolites <- session$userData$sdtm_metabolites + } + dataset_name <- session$userData$dataset_filename %||% "" active_tab <- tryCatch( @@ -631,9 +649,14 @@ prepare_export_files <- function(target_dir, #' @keywords internal #' @noRd .export_script <- function(target_dir, session) { - template_path <- "www/templates/script_template.R" + mode <- session$userData$input_mode %||% "adnca" + template_file <- if (mode == "sdtm") { + "www/templates/script_template_sdtm.R" + } else { + "www/templates/script_template.R" + } get_session_code( - template_path = system.file(template_path, package = "aNCA"), + template_path = system.file(template_file, package = "aNCA"), session, file.path(target_dir, "session_code.R") ) diff --git a/inst/shiny/modules/tab_data.R b/inst/shiny/modules/tab_data.R index 311068e43..a1c085378 100644 --- a/inst/shiny/modules/tab_data.R +++ b/inst/shiny/modules/tab_data.R @@ -350,7 +350,9 @@ tab_data_server <- function(id) { id = "column_mapping", adnca_data = uploaded_data$adnca_raw, imported_mapping = imported_mapping, - trigger = trigger_mapping_submit + trigger = trigger_mapping_submit, + input_mode = uploaded_data$input_mode, + sdtm_raw = uploaded_data$sdtm_raw ) #' Reactive value for the processed dataset adnca_mapped <- column_mapping$processed_data @@ -419,12 +421,29 @@ tab_data_server <- function(id) { log_trace("Creating PKNCA::data object.") tryCatch({ - pknca_object <- PKNCA_create_data_object( - adnca_data = uploaded_data$adnca_raw(), - mapping = column_mapping$mapping(), - applied_filters = filtering_result$applied_filters(), - time_duplicate_rows = column_mapping$time_duplicate_rows() - ) + mode <- uploaded_data$input_mode() + + if (mode == "sdtm") { + # SDTM: PKNCAdata was already created in the mapping step; + # update concentration data with filtered rows + log_trace("SDTM mode: retrieving PKNCAdata from mapping step...") + pknca_object <- column_mapping$sdtm_pknca_data() + req(pknca_object) + pknca_object$conc$data <- processed_data() + log_trace( + "SDTM: PKNCAdata updated with filtered data (", + nrow(processed_data()), " rows)." + ) + } else { + # ADNCA: create PKNCAdata from mapped data + pknca_object <- PKNCA_create_data_object( + adnca_data = uploaded_data$adnca_raw(), + mapping = column_mapping$mapping(), + applied_filters = filtering_result$applied_filters(), + time_duplicate_rows = column_mapping$time_duplicate_rows() + ) + } + pknca_object$units <- .simplify_volume_units(pknca_object$units) log_success("PKNCA data object created.") @@ -453,7 +472,8 @@ tab_data_server <- function(id) { adnca_raw = uploaded_data$adnca_raw, extra_group_vars = extra_group_vars, settings_override = uploaded_data$settings_override, - auto_replay_ready = auto_replay_ready + auto_replay_ready = auto_replay_ready, + input_mode = uploaded_data$input_mode ) }) } diff --git a/inst/shiny/modules/tab_data/data_mapping.R b/inst/shiny/modules/tab_data/data_mapping.R index 14bd5d1f0..307d2d2c0 100644 --- a/inst/shiny/modules/tab_data/data_mapping.R +++ b/inst/shiny/modules/tab_data/data_mapping.R @@ -1,3 +1,5 @@ +# --- ADNCA mapping constants -------------------------------------------------- + # Add information for non-official CDISC mapping columns NON_STD_MAPPING_INFO <- data.frame( Variable = c("Grouping_Variables", "Metabolites"), @@ -45,6 +47,57 @@ sections_order <- c( ) MAPPING_BY_SECTION <- MAPPING_BY_SECTION[sections_order] +# --- SDTM mapping constants -------------------------------------------------- + +SDTM_NON_STD_MAPPING_INFO <- data.frame( + Variable = c("Metabolites", "Grouping_Variables"), + Label = c( + "PCTEST values to flag as metabolites", + "Variables to group and summarise results" + ), + Values = c("", ""), + mapping_tooltip = c( + paste0( + "Choose the PCTEST values to flag as metabolites of the parent drug (METABFL = 'Y'). ", + "If empty, all PCTEST values are treated as parent drug (METABFL = '')" + ), + "Additional column(s) to use to group the data in the outputs (e.g. AGE, SEX, ARM)" + ), + mapping_section = c("PC Concentrations", "Supplemental Variables"), + mapping_alternatives = c("", paste0( + "ARM, ACTARM, AGE, SEX, RACE, GROUP, COHORT, PART, PERIOD, FEDSTATE" + )), + mapping_order = c(7.5, 21), + allow_create_numeric = c(FALSE, FALSE), + is_multiple_choice = c(TRUE, TRUE), + sdtm_domain = c("PC", "ALL"), + stringsAsFactors = FALSE +) + +SDTM_MAPPING_INFO <- metadata_nca_variables %>% + filter(.data$is.mapped, .data$Dataset %in% c("SDTM", "PC", "EX", "DM")) %>% + select( + Variable, Label, Values, mapping_tooltip, + mapping_section, mapping_alternatives, mapping_order, + allow_create_numeric + ) %>% + mutate( + is_multiple_choice = FALSE, + sdtm_domain = metadata_nca_variables$Dataset[ + metadata_nca_variables$is.mapped & + metadata_nca_variables$Dataset %in% c("SDTM", "PC", "EX", "DM") + ] + ) %>% + bind_rows(SDTM_NON_STD_MAPPING_INFO) %>% + arrange(.data$mapping_order) + +SDTM_MAPPING_BY_SECTION <- split( + SDTM_MAPPING_INFO, SDTM_MAPPING_INFO$mapping_section +) +SDTM_MAPPING_BY_SECTION <- SDTM_MAPPING_BY_SECTION[c( + "SDTM General", "PC Concentrations", "EX Dosing", "Supplemental Variables" +)] + # Column order is the default in apply_mapping() #' Column Mapping Widget @@ -64,7 +117,11 @@ MAPPING_BY_SECTION <- MAPPING_BY_SECTION[sections_order] #' tooltip_text = "Select the study identifier column." #' ) .column_mapping_widget <- function(ns, id, tooltip_text, multiple = FALSE, - allow_create_numeric = FALSE) { + allow_create_numeric = FALSE, + id_prefix = "") { + input_id <- paste0("select_", id_prefix, id) + label_id <- paste0("label_", id_prefix, id) + selectize_options <- if (allow_create_numeric) { list( create = TRUE, @@ -78,7 +135,7 @@ MAPPING_BY_SECTION <- MAPPING_BY_SECTION[sections_order] class = "column-mapping-row", tooltip( selectizeInput( - ns(paste0("select_", id)), + ns(input_id), "", choices = NULL, multiple = multiple, @@ -94,12 +151,12 @@ MAPPING_BY_SECTION <- MAPPING_BY_SECTION[sections_order] ), div( class = "column-mapping-label", - span(textOutput(ns(paste0("label_", id)))) + span(textOutput(ns(label_id))) ) ) } -.column_mapping_section <- function(ns, mapping_df) { +.column_mapping_section <- function(ns, mapping_df, id_prefix = "") { section_title <- unique(mapping_df$mapping_section) if (length(section_title) != 1) { stop("mapping_df must contain exactly one unique mapping_section value.") @@ -110,7 +167,8 @@ MAPPING_BY_SECTION <- MAPPING_BY_SECTION[sections_order] row <- mapping_df[i, ] .column_mapping_widget( ns, row$Variable, row$mapping_tooltip, row$is_multiple_choice, - allow_create_numeric = isTRUE(row$allow_create_numeric) + allow_create_numeric = isTRUE(row$allow_create_numeric), + id_prefix = id_prefix ) }) ) @@ -175,8 +233,9 @@ MAPPING_BY_SECTION <- MAPPING_BY_SECTION[sections_order] #' @returns Error string if the mapping was skipped, or NULL on success. #' @keywords internal #' @noRd -.apply_single_mapping <- function(var, val, column_names, session) { - var_info <- MAPPING_INFO[MAPPING_INFO$Variable == var, ] +.apply_single_mapping <- function(var, val, column_names, session, + mapping_info = MAPPING_INFO) { + var_info <- mapping_info[mapping_info$Variable == var, ] predefined <- strsplit(var_info$Values, ", ")[[1]] valid_values <- c(column_names, predefined) @@ -215,16 +274,17 @@ MAPPING_BY_SECTION <- MAPPING_BY_SECTION[sections_order] NULL } -.process_imported_mapping <- function(mapping, adnca_data, session) { +.process_imported_mapping <- function(mapping, adnca_data, session, + mapping_info = MAPPING_INFO) { if (is.null(mapping)) return(character(0)) column_names <- names(adnca_data) skipped <- character(0) - for (var in MAPPING_INFO$Variable) { + for (var in mapping_info$Variable) { if (!var %in% names(mapping) || var == "Metabolites") next result <- .apply_single_mapping( - var, mapping[[var]], column_names, session + var, mapping[[var]], column_names, session, mapping_info ) if (!is.null(result)) skipped <- c(skipped, result) } @@ -293,105 +353,229 @@ data_mapping_ui <- function(id) { card( div( class = "data-mapping-container", - h3("Data Mapping"), - p( - "The following columns are required for data analysis.", - " Please ensure each of these columns", - " has been assigned a corresponding column from your dataset" + # ADNCA mapping UI (visible by default) + div( + id = ns("adnca_mapping_panel"), + h3("Data Mapping"), + p( + "The following columns are required for data analysis.", + " Please ensure each of these columns", + " has been assigned a corresponding column from your dataset" + ), + lapply(MAPPING_BY_SECTION, function(mapping_section) { + .column_mapping_section(ns, mapping_section) + }) ), - # Define the input widgets for each variable to map - lapply(MAPPING_BY_SECTION, function(mapping_section) { - .column_mapping_section(ns, mapping_section) - }) + # SDTM mapping UI (hidden by default, prefixed IDs) + shinyjs::hidden(div( + id = ns("sdtm_mapping_panel"), + h3("SDTM Data Mapping"), + p( + "Map columns from your PC, EX, and subject-level datasets.", + " Standard SDTM column names are auto-detected when present." + ), + lapply(SDTM_MAPPING_BY_SECTION, function(mapping_section) { + .column_mapping_section(ns, mapping_section, id_prefix = "sdtm_") + }) + )) ) ) ) } -data_mapping_server <- function(id, adnca_data, imported_mapping, trigger) { +#' Populate ADNCA mapping inputs from uploaded data and imported settings. +#' @noRd +.populate_adnca_inputs <- function(session, input_ids, adnca_data, + imported_mapping) { + column_names <- names(adnca_data) + update_selectize_inputs(session, input_ids, column_names, MAPPING_INFO) + + if (!"VOLUME" %in% column_names) { + updateSelectizeInput(session, "select_VOLUMEU", selected = "") + } + if (!"WTBL" %in% column_names) { + updateSelectizeInput(session, "select_WTBLU", selected = "") + } + + mapping <- imported_mapping$mapping + if (!is.null(mapping)) { + skipped <- .process_imported_mapping(mapping, adnca_data, session) + session$userData$mapping_skipped <- skipped + } +} + +#' Populate SDTM mapping inputs from uploaded domain data. +#' @noRd +.populate_sdtm_inputs <- function(session, sdtm_input_ids, sdtm, + imported_mapping) { + all_cols <- unique(c( + names(sdtm$pc), names(sdtm$ex), + if (!is.null(sdtm$dm)) names(sdtm$dm) else character(0) + )) + + .update_sdtm_selectize_inputs( + session, sdtm_input_ids, all_cols, SDTM_MAPPING_INFO + ) + + if (!"VOLUME" %in% names(sdtm$pc)) { + updateSelectizeInput(session, "select_sdtm_VOLUME", selected = "") + updateSelectizeInput(session, "select_sdtm_VOLUMEU", selected = "") + } + if (is.null(sdtm$dm) || !"WTBL" %in% names(sdtm$dm)) { + updateSelectizeInput(session, "select_sdtm_WTBL", selected = "") + updateSelectizeInput(session, "select_sdtm_WTBLU", selected = "") + } + + mapping <- imported_mapping$mapping + if (!is.null(mapping)) { + sdtm_info_for_import <- SDTM_MAPPING_INFO + sdtm_info_for_import$Variable <- paste0( + "sdtm_", sdtm_info_for_import$Variable + ) + skipped <- .process_imported_mapping( + mapping, sdtm$pc, session, sdtm_info_for_import + ) + session$userData$mapping_skipped <- skipped + } +} + +#' Determine the default metabolite selection for ADNCA mode. +#' @noRd +.get_adnca_metabolite_selection <- function(imported_mapping, adnca_data, + param_col) { + if (!is.null(imported_mapping$mapping$Metabolites)) { + imported_mapping$mapping$Metabolites + } else if ("METABFL" %in% names(adnca_data)) { + unique(adnca_data[adnca_data$METABFL == "Y", ][[param_col]]) + } else { + NULL + } +} + +data_mapping_server <- function(id, adnca_data, imported_mapping, trigger, + input_mode = reactive("adnca"), + sdtm_raw = reactive(NULL)) { moduleServer(id, function(input, output, session) { ns <- session$ns duplicates <- reactiveVal(NULL) - # Derive input IDs from column_groups + sdtm_pknca_data <- reactiveVal(NULL) + + # --- Panel visibility ----------------------------------------------------- + observe({ + mode <- input_mode() + shinyjs::toggle("adnca_mapping_panel", condition = (mode == "adnca")) + shinyjs::toggle("sdtm_mapping_panel", condition = (mode == "sdtm")) + }) + + # --- Input ID constants --------------------------------------------------- input_ids <- paste0("select_", MAPPING_INFO[["Variable"]]) + sdtm_input_ids <- paste0("select_sdtm_", SDTM_MAPPING_INFO[["Variable"]]) - # Loop through each label and create the renderText outputs + # --- Render labels for both panels ---------------------------------------- purrr::walk(MAPPING_INFO$Variable, function(var) { output[[paste0("label_", var)]] <- renderText( - MAPPING_INFO$Label[MAPPING_INFO$Variable == var] + MAPPING_INFO$Label[MAPPING_INFO$Variable == var][1] + ) + }) + purrr::walk(SDTM_MAPPING_INFO$Variable, function(var) { + output[[paste0("label_sdtm_", var)]] <- renderText( + SDTM_MAPPING_INFO$Label[SDTM_MAPPING_INFO$Variable == var][1] ) }) - # Populate the static inputs with column names + # --- ADNCA: Populate the static inputs with column names ------------------ observeEvent(c(adnca_data(), imported_mapping()), { - column_names <- names(adnca_data()) - update_selectize_inputs(session, input_ids, column_names, MAPPING_INFO) - - # Exceptions: - # If by default VOLUME is not mapped, then neither is VOLUMEU - if (!"VOLUME" %in% column_names) { - updateSelectizeInput(session, "select_VOLUMEU", selected = "") - } - # If by default WTBL is not mapped, then neither is WTBLU - if (!"WTBL" %in% column_names) { - updateSelectizeInput(session, "select_WTBLU", selected = "") - } + req(input_mode() == "adnca") + .populate_adnca_inputs( + session, input_ids, adnca_data(), imported_mapping() + ) + }) - mapping <- imported_mapping()$mapping - if (!is.null(mapping)) { - # process mapping using settings to override default selections - skipped <- .process_imported_mapping(mapping, adnca_data(), session) - session$userData$mapping_skipped <- skipped - } + # --- SDTM: Populate inputs from domain data ------------------------------- + observeEvent(c(sdtm_raw(), imported_mapping()), { + req(input_mode() == "sdtm") + sdtm <- sdtm_raw() + req(sdtm) + .populate_sdtm_inputs( + session, sdtm_input_ids, sdtm, imported_mapping() + ) }) - # Populate the dynamic input Metabolites + + # Populate the dynamic input Metabolites for ADNCA observe({ + req(input_mode() == "adnca") req(input$select_PARAM != "") param_col <- input$select_PARAM choices_metab <- unique(adnca_data()[[param_col]]) - # Use pending import if available, otherwise fall back to METABFL default - selected_metab <- if (!is.null(imported_mapping()$mapping$Metabolites)) { - imported <- imported_mapping()$mapping$Metabolites - imported - } else if ("METABFL" %in% names(adnca_data())) { - unique(adnca_data()[adnca_data()$METABFL == "Y", ][[param_col]]) - } else { - NULL - } + selected_metab <- .get_adnca_metabolite_selection( + imported_mapping(), adnca_data(), param_col + ) updateSelectizeInput( session, "select_Metabolites", choices = choices_metab, selected = selected_metab ) }) + # Populate the dynamic input Metabolites for SDTM + observe({ + req(input_mode() == "sdtm") + req(input$select_sdtm_PCTEST != "") + sdtm <- sdtm_raw() + req(sdtm) + pctest_col <- input$select_sdtm_PCTEST + choices_metab <- unique(sdtm$pc[[pctest_col]]) + selected_metab <- imported_mapping()$mapping$Metabolites + updateSelectizeInput( + session, "select_sdtm_Metabolites", + choices = choices_metab, selected = selected_metab + ) + }) + # Validate numeric inputs for variables with allow_create_numeric = TRUE .observe_numeric_inputs(input, session, adnca_data, MAPPING_INFO) - # Observe submit button click and update processed_data + # --- Collect current mapping values --------------------------------------- mapping <- reactive({ - mapping_list <- setNames(lapply(input_ids, function(id) input[[id]]), input_ids) - supplemental_ids <- paste0("select_", MAPPING_BY_SECTION$`Supplemental Variables`$Variable) + if (input_mode() == "adnca") { + ids <- input_ids + suppl_ids <- paste0( + "select_", MAPPING_BY_SECTION$`Supplemental Variables`$Variable + ) + } else { + ids <- sdtm_input_ids + suppl_ids <- paste0( + "select_sdtm_", + SDTM_MAPPING_BY_SECTION$`Supplemental Variables`$Variable + ) + } + + mapping_list <- setNames(lapply(ids, function(id) input[[id]]), ids) # Get the names to keep names_to_keep <- names(mapping_list) %>% keep(\(name) { - # The logical condition with the any() fix - !(name %in% supplemental_ids) || any(mapping_list[[name]] != "") + !(name %in% suppl_ids) || any(mapping_list[[name]] != "") }) - # Subset the list with the final names mapping_list[names_to_keep] }) observe({ m <- mapping() - names(m) <- gsub("^select_", "", names(m)) + # Strip both "select_" and "select_sdtm_" prefixes + names(m) <- gsub("^select_(sdtm_)?", "", names(m)) session$userData$mapping <- m + # Store metabolites for SDTM script template + if (input_mode() == "sdtm") { + session$userData$sdtm_metabolites <- input$select_sdtm_Metabolites + } }) + # --- ADNCA: apply mapping and produce mapped data.frame ------------------- mapped_data <- reactive({ + req(input_mode() == "adnca") req(adnca_data()) - log_info("Processing data mapping...") + log_info("Processing ADNCA data mapping...") mapping_ <- mapping() names(mapping_) <- gsub("^select_", "", names(mapping_)) @@ -422,6 +606,76 @@ data_mapping_server <- function(id, adnca_data, imported_mapping, trigger) { }) %>% bindEvent(trigger(), ignoreInit = TRUE) + # --- SDTM: apply mapping and produce PKNCAdata ---------------------------- + sdtm_mapped_data <- reactive({ + req(input_mode() == "sdtm") + sdtm <- sdtm_raw() + req(sdtm) + log_info("Processing SDTM data mapping...") + + mapping_ <- mapping() + # Strip both "select_" and "select_sdtm_" prefixes + names(mapping_) <- gsub("^select_(sdtm_)?", "", names(mapping_)) + + tryCatch( + withCallingHandlers( + { + # Rename columns per user mapping + log_trace("SDTM: renaming PC columns...") + pc <- .apply_sdtm_column_rename(sdtm$pc, mapping_, "PC") + log_trace("SDTM: renaming EX columns...") + ex <- .apply_sdtm_column_rename(sdtm$ex, mapping_, "EX") + dm <- if (!is.null(sdtm$dm)) { + log_trace("SDTM: renaming DM columns...") + .apply_sdtm_column_rename(sdtm$dm, mapping_, "DM") + } + + metabolites <- input$select_sdtm_Metabolites + log_info( + "SDTM: calling sdtm_to_PKNCAdata ", + "(PC: ", nrow(pc), " rows, EX: ", nrow(ex), " rows", + if (!is.null(dm)) paste0(", DM: ", nrow(dm), " rows") else "", + if (length(metabolites) > 0) + paste0(", metabolites: ", paste(metabolites, collapse = ", ")) + else "", + ")" + ) + pknca_obj <- sdtm_to_PKNCAdata( + pc = pc, ex = ex, dm = dm, metabolites = metabolites + ) + log_success( + "SDTM: PKNCAdata created. ", + "Conc: ", nrow(pknca_obj$conc$data), " rows, ", + "Dose: ", nrow(pknca_obj$dose$data), " rows." + ) + sdtm_pknca_data(pknca_obj) + + # Return the concentration data for filtering/preview + pknca_obj$conc$data + }, + warning = function(w) { + log_warn(conditionMessage(w)) + showNotification(conditionMessage(w), type = "warning", duration = 10) + invokeRestart("muffleWarning") + } + ), + error = function(e) { + log_error("SDTM mapping failed: ", conditionMessage(e)) + showNotification( + paste("SDTM conversion error:", conditionMessage(e)), + type = "error", duration = NULL + ) + NULL + } + ) + }) %>% + bindEvent(trigger(), ignoreInit = TRUE) + + # --- Unified mapped data output ------------------------------------------- + unified_mapped_data <- reactive({ + if (input_mode() == "sdtm") sdtm_mapped_data() else mapped_data() + }) + # Check for blocking duplicates using annotate_duplicates() df_duplicates <- reactiveVal(NULL) resolved_time_duplicate_rows <- reactiveVal(NULL) @@ -429,17 +683,17 @@ data_mapping_server <- function(id, adnca_data, imported_mapping, trigger) { session$userData$time_duplicate_rows <- resolved_time_duplicate_rows() # Store key-based representation for settings export session$userData$time_duplicate_keys <- extract_time_dup_keys( - mapped_data(), resolved_time_duplicate_rows() + unified_mapped_data(), resolved_time_duplicate_rows() ) }) processed_data <- reactive({ - req(mapped_data()) + req(unified_mapped_data()) dup_rows <- resolved_time_duplicate_rows() if (is.null(dup_rows)) { restored <- .restore_duplicate_exclusions( - mapped_data(), imported_mapping()$time_duplicate_keys + unified_mapped_data(), imported_mapping()$time_duplicate_keys ) if (!is.null(restored)) { resolved_time_duplicate_rows(restored) @@ -449,8 +703,8 @@ data_mapping_server <- function(id, adnca_data, imported_mapping, trigger) { tryCatch( { - result <- annotate_duplicates(mapped_data(), dup_rows) - select(result, any_of(c(names(mapped_data()), "DTYPE"))) + result <- annotate_duplicates(unified_mapped_data(), dup_rows) + select(result, any_of(c(names(unified_mapped_data()), "DTYPE"))) }, time_duplicate_error = function(e) { df_duplicates(e$duplicate_data) @@ -459,7 +713,7 @@ data_mapping_server <- function(id, adnca_data, imported_mapping, trigger) { ) }) %>% bindEvent( - list(mapped_data(), resolved_time_duplicate_rows()), + list(unified_mapped_data(), resolved_time_duplicate_rows()), ignoreInit = FALSE ) @@ -480,7 +734,7 @@ data_mapping_server <- function(id, adnca_data, imported_mapping, trigger) { # Validate: check if the selection resolves all time duplicates tryCatch( { - annotate_duplicates(mapped_data(), new_exclusions) + annotate_duplicates(unified_mapped_data(), new_exclusions) # Selection resolves all duplicates — proceed resolved_time_duplicate_rows(new_exclusions) removeModal() @@ -553,15 +807,99 @@ data_mapping_server <- function(id, adnca_data, imported_mapping, trigger) { # Cleaned mapping with select_ prefix removed cleaned_mapping <- reactive({ m <- mapping() - names(m) <- gsub("^select_", "", names(m)) + names(m) <- gsub("^select_(sdtm_)?", "", names(m)) m }) list( processed_data = processed_data, mapping = cleaned_mapping, - grouping_variables = reactive(input$select_Grouping_Variables), - time_duplicate_rows = resolved_time_duplicate_rows + grouping_variables = reactive({ + if (input_mode() == "sdtm") { + input$select_sdtm_Grouping_Variables + } else { + input$select_Grouping_Variables + } + }), + time_duplicate_rows = resolved_time_duplicate_rows, + sdtm_pknca_data = sdtm_pknca_data ) }) } + +#' Update selectize inputs for SDTM mapping. +#' +#' Handles the sdtm_ prefix on input IDs: strips it before looking up +#' the variable in the mapping info, then updates the prefixed input. +#' +#' @param session Shiny session. +#' @param input_ids Prefixed input IDs (e.g. "select_sdtm_PCTEST"). +#' @param data_colnames Column names from the uploaded data. +#' @param mapping_info SDTM_MAPPING_INFO (unprefixed Variable names). +#' @noRd +.update_sdtm_selectize_inputs <- function(session, input_ids, data_colnames, + mapping_info) { + info_list <- split(mapping_info, mapping_info$Variable) + + for (input_id in input_ids) { + # Strip "select_sdtm_" to get the original variable name + var_name <- sub("^select_sdtm_", "", input_id) + input_info <- info_list[[var_name]] + if (is.null(input_info)) next + + alternatives <- strsplit(input_info$mapping_alternatives, ", ")[[1]] + value_choices <- strsplit(input_info$Values, ", ")[[1]] + + potential_mappings <- c( + intersect(c(var_name, alternatives), data_colnames), + value_choices + ) + + selected_vals <- if (length(potential_mappings) == 0) { + NULL + } else if (input_info$is_multiple_choice) { + potential_mappings + } else { + potential_mappings[[1]] + } + + updateSelectizeInput( + session, input_id, + choices = list( + "Select Column" = "", + "Mapping Columns" = data_colnames, + "Mapping Values" = value_choices + ), + selected = selected_vals + ) + } +} + +#' Rename columns in an SDTM domain data.frame based on user mapping. +#' +#' For each SDTM variable in the mapping, if the user selected a +#' non-standard column name, rename it to the expected SDTM name. +#' Only renames columns belonging to the specified domain. +#' +#' @param df Data.frame to rename columns in. +#' @param mapping Named list of mapping values (variable = selected column). +#' @param domain Character, one of "PC", "EX", "DM". +#' @returns The data.frame with renamed columns. +#' @noRd +.apply_sdtm_column_rename <- function(df, mapping, domain) { + # Include domain-specific vars and SDTM General vars (shared across domains) + domain_vars <- SDTM_MAPPING_INFO$Variable[ + SDTM_MAPPING_INFO$sdtm_domain %in% c(domain, "SDTM", "ALL") + ] + + for (var in domain_vars) { + selected <- mapping[[var]] + if (is.null(selected) || length(selected) != 1) next + if (selected == "" || selected == var) next + if (selected %in% names(df)) { + names(df)[names(df) == selected] <- var + } + } + + df +} diff --git a/inst/shiny/modules/tab_data/data_upload.R b/inst/shiny/modules/tab_data/data_upload.R index a10a0a69d..8eb1a09c7 100644 --- a/inst/shiny/modules/tab_data/data_upload.R +++ b/inst/shiny/modules/tab_data/data_upload.R @@ -1,28 +1,79 @@ -#' Module responsible for loading and validating raw ADNCA data. +#' Module responsible for loading and validating raw PK data. #' #' @details +#' Supports two input modes: +#' - **ADNCA**: Upload a single ADNCA dataset (one or more files rbind-ed). +#' - **SDTM**: Upload separate PC, EX, and optional subject-level files. +#' #' Upon startup, when no data is provided by the user, the module will return dummy data #' available with the application. Upon upload, user data will be loaded from .csv or .rds files. #' #' @param id ID of the module. #' -#' @returns A reactive with raw adnca data as provided by the user (or dummy dataset). +#' @returns A list with reactives: `adnca_raw`, `settings_override`, `input_mode`, `sdtm_raw`. data_upload_ui <- function(id) { + ns <- NS(id) + file_formats <- paste(names(aNCA:::readers), collapse = ", ") + div( div( class = "upload-container", id = ns("upload_container"), - p("Upload your PK dataset and Settings file (optional)."), - fileInput( - ns("data_upload"), - width = "50%", - label = NULL, - multiple = TRUE, - placeholder = paste(names(aNCA:::readers), collapse = ", "), - buttonLabel = list(icon("folder"), "Upload File...") + radioButtons( + ns("input_mode"), "Input Format", + choices = c("ADNCA" = "adnca", "SDTM" = "sdtm"), + selected = "adnca", + inline = TRUE + ), + # ADNCA upload (default) + conditionalPanel( + condition = sprintf("input['%s'] == 'adnca'", ns("input_mode")), + p("Upload your PK dataset and Settings file (optional)."), + fileInput( + ns("data_upload"), + width = "50%", + label = NULL, + multiple = TRUE, + placeholder = file_formats, + buttonLabel = list(icon("folder"), "Upload File...") + ) + ), + # SDTM upload + conditionalPanel( + condition = sprintf("input['%s'] == 'sdtm'", ns("input_mode")), + p("Upload SDTM domain files and Settings file (optional)."), + fileInput( + ns("sdtm_pc_upload"), + label = "PC domain (required)", + multiple = FALSE, + placeholder = file_formats, + buttonLabel = list(icon("folder"), "Browse...") + ), + fileInput( + ns("sdtm_ex_upload"), + label = "EX domain (required)", + multiple = FALSE, + placeholder = file_formats, + buttonLabel = list(icon("folder"), "Browse...") + ), + fileInput( + ns("sdtm_subj_upload"), + label = "Subject-level data (DM, LB, VS, ... optional)", + multiple = TRUE, + placeholder = file_formats, + buttonLabel = list(icon("folder"), "Browse...") + ), + fileInput( + ns("sdtm_settings_upload"), + label = "Settings file (optional)", + multiple = FALSE, + accept = c(".yml", ".yaml"), + placeholder = "yml, yaml", + buttonLabel = list(icon("folder"), "Browse...") + ) ), uiOutput(ns("file_loading_message")) ), @@ -31,16 +82,19 @@ data_upload_ui <- function(id) { } data_upload_server <- function(id) { + moduleServer(id, function(input, output, session) { ns <- session$ns #' Dummy data is automatically loaded on startup if no data path is provided DUMMY_DATA <- adnca_example + SDTM_DUMMY <- list(pc = pc_example, ex = ex_example, dm = dm_example) #' Display file loading error if any issues arise file_loading_error <- reactiveVal(NULL) settings_override <- reactiveVal(NULL) # Store loaded settings pending_versioned <- reactiveVal(NULL) # Versioned settings awaiting selection + sdtm_data <- reactiveVal(NULL) # SDTM domain data (list of pc/ex/dm) output$file_loading_message <- renderUI({ if (is.null(file_loading_error())) { @@ -85,8 +139,10 @@ data_upload_server <- function(id) { }) } - raw_data <- ( + # --- ADNCA mode data loading (existing behavior) -------------------------- + adnca_raw_data <- ( reactive({ + req(input$input_mode == "adnca") file_loading_error(NULL) upload_paths <- .resolve_upload_paths( @@ -103,13 +159,65 @@ data_upload_server <- function(id) { file_loading_error, session, ns ) }) %>% - bindEvent(input$data_upload, ignoreNULL = FALSE) + bindEvent(input$data_upload, input$input_mode, ignoreNULL = FALSE) + ) + + # --- SDTM mode data loading ----------------------------------------------- + sdtm_trigger <- reactive({ + list(input$sdtm_pc_upload, input$sdtm_ex_upload, + input$sdtm_subj_upload, input$sdtm_settings_upload) + }) + + sdtm_raw_data <- ( + reactive({ + req(input$input_mode == "sdtm") + file_loading_error(NULL) + + # If no files uploaded, use example data + if (is.null(input$sdtm_pc_upload) && is.null(input$sdtm_ex_upload)) { + sdtm_data(SDTM_DUMMY) + session$userData$dataset_filename <- "sdtm_example" + return(SDTM_DUMMY$pc) + } + + result <- .process_sdtm_uploads( + input$sdtm_pc_upload, input$sdtm_ex_upload, + input$sdtm_subj_upload, input$sdtm_settings_upload, + settings_override, pending_versioned, + file_loading_error, session, ns + ) + + sdtm_data(result$sdtm) + result$preview + }) %>% + bindEvent(sdtm_trigger(), input$input_mode, ignoreNULL = FALSE) ) + # --- Unified display data ------------------------------------------------- + raw_data <- reactive({ + if (input$input_mode == "sdtm") sdtm_raw_data() else adnca_raw_data() + }) + observeEvent(raw_data(), { session$userData$raw_data <- raw_data() }) + # Store input mode and SDTM data in session for downstream modules + observe({ + session$userData$input_mode <- input$input_mode + }) + observe({ + session$userData$sdtm_raw <- sdtm_data() + }) + + # Switch input mode when settings specify it + observeEvent(settings_override(), { + override <- settings_override() + if (!is.null(override$input_mode) && override$input_mode == "sdtm") { + updateRadioButtons(session, "input_mode", selected = "sdtm") + } + }) + reactable_server( "data_display", raw_data, @@ -140,7 +248,9 @@ data_upload_server <- function(id) { list( adnca_raw = raw_data, - settings_override = settings_override + settings_override = settings_override, + input_mode = reactive(input$input_mode), + sdtm_raw = sdtm_data ) }) } @@ -446,3 +556,176 @@ data_upload_server <- function(id) { size = "l" )) } + +#' Process SDTM file uploads (PC, EX, subject-level, settings). +#' +#' Reads each uploaded file, validates that PC and EX are present, +#' merges subject-level files by STUDYID + USUBJID, and handles +#' settings YAML. +#' +#' @param pc_upload fileInput value for PC domain. +#' @param ex_upload fileInput value for EX domain. +#' @param subj_upload fileInput value for subject-level files (multi). +#' @param settings_upload fileInput value for settings YAML. +#' @param settings_override reactiveVal for settings. +#' @param pending_versioned reactiveVal for versioned settings. +#' @param file_loading_error reactiveVal for error display. +#' @param session Shiny session. +#' @param ns Namespace function. +#' Read a single SDTM domain file, appending errors on failure. +#' @returns A list with `data` (data.frame or NULL) and `errors`. +#' @noRd +.read_sdtm_domain <- function(upload, domain_label, errors) { + if (is.null(upload$datapath)) { + return(list(data = NULL, errors = errors)) + } + tryCatch({ + df <- read_pk(upload$datapath) + log_success(domain_label, " domain loaded: ", upload$name) + list(data = df, errors = errors) + }, error = function(e) { + errors[[length(errors) + 1]] <- paste0(domain_label, ": ", e$message) + list(data = NULL, errors = errors) + }) +} + +#' Read and merge multiple subject-level upload files. +#' @returns A list with `data` (merged data.frame or NULL) and `errors`. +#' @noRd +.read_subject_level_files <- function(subj_upload, errors) { + if (is.null(subj_upload$datapath)) { + return(list(data = NULL, errors = errors)) + } + subj_dfs <- list() + for (i in seq_along(subj_upload$datapath)) { + tryCatch({ + df <- read_pk(subj_upload$datapath[i]) + subj_dfs[[length(subj_dfs) + 1]] <- df + log_success("Subject-level file loaded: ", subj_upload$name[i]) + }, error = function(e) { + errors[[length(errors) + 1]] <<- paste0( + subj_upload$name[i], ": ", e$message + ) + }) + } + dm <- if (length(subj_dfs) > 0) .merge_subject_level_data(subj_dfs) + list(data = dm, errors = errors) +} + +#' Build the SDTM result list from parsed domain data. +#' @returns A list with `sdtm`, `preview`, and `errors`. +#' @noRd +.build_sdtm_result <- function(pc, ex, dm, pc_upload, ex_upload, + subj_upload, errors, session) { + sdtm <- NULL + preview <- adnca_example + + if (!is.null(pc) && !is.null(ex)) { + sdtm <- list(pc = pc, ex = ex, dm = dm) + preview <- pc + filenames <- c(pc_upload$name, ex_upload$name) + if (!is.null(subj_upload$name)) { + filenames <- c(filenames, subj_upload$name) + } + session$userData$dataset_filename <- paste(filenames, collapse = ", ") + log_success( + "SDTM data loaded: PC (", nrow(pc), " rows), EX (", nrow(ex), " rows)", + if (!is.null(dm)) paste0(", DM (", nrow(dm), " rows)") else "" + ) + } else if (!is.null(pc) || !is.null(ex)) { + missing <- if (is.null(pc)) "PC" else "EX" + errors[[length(errors) + 1]] <- paste0( + "Both PC and EX domains are required. Missing: ", missing + ) + if (!is.null(pc)) preview <- pc + if (!is.null(ex)) preview <- ex + } + + list(sdtm = sdtm, preview = preview, errors = errors) +} + +#' @returns A list with `sdtm` (list of pc/ex/dm data.frames or NULL) +#' and `preview` (data.frame for the reactable display). +#' @noRd +.process_sdtm_uploads <- function(pc_upload, ex_upload, subj_upload, + settings_upload, settings_override, + pending_versioned, file_loading_error, + session, ns) { + errors <- list() + + pc_result <- .read_sdtm_domain(pc_upload, "PC", errors) + errors <- pc_result$errors + + ex_result <- .read_sdtm_domain(ex_upload, "EX", errors) + errors <- ex_result$errors + + subj_result <- .read_subject_level_files(subj_upload, errors) + errors <- subj_result$errors + + # --- Handle settings YAML --------------------------------------------------- + if (!is.null(settings_upload$datapath)) { + settings_results <- list(list( + status = "success", type = "settings", + content = tryCatch( + read_settings(settings_upload$datapath), + error = function(e) { + errors[[length(errors) + 1]] <<- paste0( + "Settings: ", e$message + ) + NULL + } + ), + name = settings_upload$name + )) + if (!is.null(settings_results[[1]]$content)) { + errors <- .apply_uploaded_settings( + settings_results, errors, settings_override, + pending_versioned, session, ns + ) + } + } + + result <- .build_sdtm_result( + pc_result$data, ex_result$data, subj_result$data, + pc_upload, ex_upload, subj_upload, errors, session + ) + errors <- result$errors + + if (length(errors) > 0) { + file_loading_error(paste(errors, collapse = "
")) + log_error("SDTM upload errors: ", paste(errors, collapse = "; ")) + } + + list(sdtm = result$sdtm, preview = result$preview) +} + +#' Merge multiple subject-level data.frames by STUDYID + USUBJID. +#' +#' Performs sequential left joins. The first data.frame is the base; +#' subsequent data.frames add columns. Duplicate columns (other than +#' the join keys) from later files are suffixed. +#' +#' @param dfs List of data.frames to merge. +#' @returns A single merged data.frame, or the first data.frame if +#' only one is provided. +#' @noRd +.merge_subject_level_data <- function(dfs) { + if (length(dfs) == 1) return(dfs[[1]]) + + join_keys <- c("STUDYID", "USUBJID") + merged <- dfs[[1]] + + for (i in seq(2, length(dfs))) { + df <- dfs[[i]] + # Use available join keys + available_keys <- intersect(join_keys, intersect(names(merged), names(df))) + if (length(available_keys) == 0) { + log_warn("Cannot merge subject-level file: no common join keys found.") + next + } + merged <- merge(merged, df, by = available_keys, all.x = TRUE, + suffixes = c("", paste0(".", i))) + } + + merged +} diff --git a/inst/shiny/modules/tab_nca/nca_setup.R b/inst/shiny/modules/tab_nca/nca_setup.R index 7ec06ed93..2558eb79b 100644 --- a/inst/shiny/modules/tab_nca/nca_setup.R +++ b/inst/shiny/modules/tab_nca/nca_setup.R @@ -244,8 +244,12 @@ nca_setup_server <- function(id, data, adnca_data, extra_group_vars, settings_ov slope_rules = slope_rules(), filters = session$userData$applied_filters, time_duplicate_keys = session$userData$time_duplicate_keys, - nca_ran = isTRUE(session$userData$nca_ran) + nca_ran = isTRUE(session$userData$nca_ran), + input_mode = session$userData$input_mode %||% "adnca" ) + if (identical(payload$input_mode, "sdtm")) { + payload$sdtm_metabolites <- session$userData$sdtm_metabolites + } dataset_name <- session$userData$dataset_filename %||% "" diff --git a/inst/www/templates/script_template_sdtm.R b/inst/www/templates/script_template_sdtm.R new file mode 100644 index 000000000..c852f58a2 --- /dev/null +++ b/inst/www/templates/script_template_sdtm.R @@ -0,0 +1,120 @@ +# Load the package (https://github.com/pharmaverse/aNCA) # +########################################################### +## Please, set your working directory to this file: +# setwd("path/to/this/file/script_template_sdtm.R") + +if (!require("aNCA")) install.packages("aNCA") +library(aNCA) +library(dplyr) + +# Load SDTM domain data # +pc_data <- read_pk("../input_pc.rds") +ex_data <- read_pk("../input_ex.rds") +dm_data <- read_pk("../input_dm.rds") + +## Apply column mapping from session settings ################## +# If the user mapped non-standard column names in the app, +# rename them back to expected SDTM names before conversion. +sdtm_mapping <- settings_list$mapping +sdtm_mapping <- sdtm_mapping[ + !names(sdtm_mapping) %in% c("Metabolites", "Grouping_Variables") +] + +rename_cols <- function(df, mapping) { + for (var in names(mapping)) { + selected <- mapping[[var]] + if (is.null(selected) || length(selected) != 1 || selected == "") next + if (selected != var && selected %in% names(df)) { + names(df)[names(df) == selected] <- var + } + } + df +} + +pc_data <- rename_cols(pc_data, sdtm_mapping) +ex_data <- rename_cols(ex_data, sdtm_mapping) +dm_data <- rename_cols(dm_data, sdtm_mapping) + +## Create PKNCA object from SDTM domains ###################### +metabolites <- settings_list$sdtm_metabolites + +pknca_obj <- sdtm_to_PKNCAdata( + pc = pc_data, + ex = ex_data, + dm = dm_data, + metabolites = metabolites +) + +## Setup NCA settings, intervals, parameter selections, and units +int_parameters <- settings_list$settings$int_parameters +units_table <- settings_list$units_table +parameters_selected_per_study <- settings_list$settings$parameters$selections +extra_vars_to_keep <- settings_list$extra_vars_to_keep +slope_rules <- settings_list$slope_rules + +pknca_obj <- pknca_obj %>% + PKNCA_update_data_object( + method = settings_list$settings$method, + selected_analytes = settings_list$settings$analyte, + selected_profile = settings_list$settings$profile, + selected_pcspec = settings_list$settings$pcspec, + start_impute = settings_list$settings$data_imputation$impute_c0, + exclusion_list = settings_list$settings$general_exclusions, + hl_adj_rules = slope_rules, + keep_interval_cols = setdiff(extra_vars_to_keep, c("DOSEA", "ATPTREF", "ROUTE")), + min_hl_points = settings_list$settings$min_hl_points %||% 3, + parameter_selections = parameters_selected_per_study, + int_parameters = int_parameters, + custom_units_table = units_table + ) + +## Run NCA calculations ######################################## +flag_rules <- settings_list$settings$flags +ratio_table <- settings_list$ratio_table +blq_rule <- settings_list$settings$data_imputation$blq_imputation_rule + +pknca_res <- pknca_obj %>% + + # Run pk.nca and join subject and dose information to the results + # Consider the BLQ imputation rule before calculations (if any) + PKNCA_calculate_nca( + blq_rule = blq_rule + ) %>% + + # Apply standard CDISC names + mutate(PPTESTCD = translate_terms(PPTESTCD, "PKNCA", "PPTESTCD")) %>% + + # Flag relevant parameters based on AUCPEO, AUCPEP & lambda span + PKNCA_hl_rules_exclusion( + rules = flag_rules %>% + purrr::keep(\(x) x$is.checked) %>% + purrr::map(\(x) x$threshold) + ) %>% + + # Derive secondary parameters (ratio parameters) + calculate_table_ratios(ratio_table) %>% + + # Filter only parameters that have been requested + remove_pp_not_requested() + +## Obtain PP, ADPP, ADNCA & Pivoted results ######################### + +# Build flag rule exclusion messages for ADPP CRITy/CRITyFL/PPSUMFL columns +flag_operators <- c(R2ADJ = " < ", R2 = " < ", AUCPEO = " > ", AUCPEP = " > ", LAMZSPN = " < ") +checked_flags <- purrr::keep(flag_rules, function(x) x$is.checked) +flag_rule_msgs <- if (length(checked_flags) > 0) { + vapply(names(checked_flags), function(nm) { + paste0(nm, flag_operators[nm], checked_flags[[nm]]$threshold) + }, character(1), USE.NAMES = FALSE) +} else { + NULL +} + +cdisc_datasets <- pknca_res %>% + export_cdisc(grouping_vars = extra_vars_to_keep, flag_rules = flag_rule_msgs) + +pivoted_results <- pivot_wider_pknca_results( + myres = pknca_res, + flag_rules = flag_rules, + extra_vars_to_keep = extra_vars_to_keep +) diff --git a/man/get_settings_code.Rd b/man/get_settings_code.Rd index 7a50cd0f0..76c786c36 100644 --- a/man/get_settings_code.Rd +++ b/man/get_settings_code.Rd @@ -8,7 +8,7 @@ get_settings_code( settings_file_path, data_path, output_path = "settings_code.R", - template_path = system.file("www/templates/script_template.R", package = "aNCA") + template_path = NULL ) } \arguments{