diff --git a/.gitignore b/.gitignore index ba2ec833b..9e1aaeed3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ inst/shiny/log/* .DS_Store # {shinytest2}: Ignore new debug snapshots for `$expect_values()` *_.new.png -desktop.ini \ No newline at end of file +desktop.ini diff --git a/DESCRIPTION b/DESCRIPTION index 93220e9b3..3ff5bdc60 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: aNCA Title: (Pre-)Clinical NCA in a Dynamic Shiny App -Version: 0.1.0.9175 +Version: 0.1.0.9176 Authors@R: c( person("Ercan", "Suekuer", email = "ercan.suekuer@roche.com", role = "aut", comment = c(ORCID = "0009-0001-1626-1526")), diff --git a/NEWS.md b/NEWS.md index 9ac7adfd1..a5aec6171 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,6 +18,9 @@ * Partial interval parameters section supports calculations beyond `AUCINT`: `RCAMINT`, `AUCINTD`, `CAVGINT`, and others. Table starts empty by default with a Remove Row button (#524, #1249) * "Min. Points for Half-life" setting added (range 2–10, default 3) (#1155) * BLQ imputation rules via `NCA Setup > Data Imputation` (#139) + +### Bug Fixes +* Half-life (LAMZHL) and lambda.z (LAMZ) now correctly retain BLQ imputation when half-life-dependent parameters (AUCIFO, AUCIFP) are requested. Previously `rm_impute_obs_params()` removed imputation from `half.life` because its dependency check was limited to one level — missing the transitive chain `half.life -> lambda.z -> aucinf.obs`. The dependency resolution now recursively traverses upstream dependencies to include all parameters in the AUC calculation chain (#1057). * General Exclusions section for in-app NCA exclusions, with "Excl. TLG" checkbox per entry (#851, #1018) * Parameter Exclusions tab: exclude individual PK parameter rows from descriptive statistics and ADPP export via PPSUMFL/PPSUMRSN flags (#1040) * NCA flag rules (NCAwXRS) from ADNCA standards — flagged records are excluded from NCA (#752) diff --git a/R/intervals.R b/R/intervals.R index 0db736fa0..ed1e3b669 100644 --- a/R/intervals.R +++ b/R/intervals.R @@ -295,6 +295,12 @@ update_main_intervals <- function( # and apply it only for non-observational parameters if (!is.null(blq_imputation_rule)) { + # Ensure impute column exists so dplyr mutate below references the + # column rather than the function parameter (which could be FALSE + # from YAML settings, causing "PKNCA_impute_method_FALSE" error). + if (!"impute" %in% names(data$intervals)) { + data$intervals$impute <- NA_character_ + } data$intervals <- data$intervals %>% mutate( impute = ifelse( @@ -324,16 +330,20 @@ update_main_intervals <- function( #' @import dplyr #' rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_parameters) { - # Don't impute parameters that are not AUC dependent + # Don't impute parameters that are not AUC dependent. + # Parameters in the AUC calculation chain (half.life feeds lambda.z, + # which feeds aucinf.obs) must all use the same BLQ-imputed data (#1057). params_auc_dep <- metadata_nca_parameters %>% filter(grepl("auc|aumc", PKNCA) | grepl("auc", Depends)) %>% pull(PKNCA) + # Build reverse dependency table and walk forward along the data-flow + # direction to find all params whose consumers eventually reach an AUC param. + rev_deps <- .build_rev_deps(metadata_nca_parameters) + needs_impute <- .walk_forward_deps(params_auc_dep, rev_deps) + params_not_to_impute <- metadata_nca_parameters %>% - filter( - !grepl("auc|aumc", PKNCA), - !grepl(paste0(params_auc_dep, collapse = "|"), Depends) - ) %>% + filter(!PKNCA %in% needs_impute) %>% pull(PKNCA) %>% intersect(names(PKNCA::get.interval.cols())) @@ -367,3 +377,44 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa data } + +#' Build a reverse dependency table from the Depends column. +#' For each parameter A, returns which parameters list A as a dependency. +#' e.g., lambda.z lists half.life in Depends, so rev_deps$half.life includes lambda.z. +#' @noRd +.build_rev_deps <- function(metadata) { + rev <- list() + for (i in seq_len(nrow(metadata))) { + pkg_name <- metadata$PKNCA[i] + dep_str <- metadata$Depends[i] + if (is.na(dep_str) || dep_str == "") next + dep_list <- trimws(strsplit(dep_str, ",")[[1]]) + for (d in dep_list) { + rev[[d]] <- unique(c(rev[[d]], pkg_name)) + } + } + rev +} + +#' Walk forward through the reverse dependency table from `start_set`. +#' At each iteration, finds params whose consumers include any param +#' already in the set — i.e., params that feed into the current chain. +#' Stops when reaching purely observational leaf params (cmax, tmax, tlast) +#' to avoid including them in the imputation set. +#' @noRd +.walk_forward_deps <- function(start_set, rev_deps, + obs_params = c("cmax", "tmax", "tlast")) { + needs <- start_set + repeat { + newly_found <- character() + for (pkg in names(rev_deps)) { + if (!pkg %in% needs && !pkg %in% obs_params && + any(rev_deps[[pkg]] %in% needs)) { + newly_found <- c(newly_found, pkg) + } + } + if (length(newly_found) == 0) break + needs <- c(needs, newly_found) + } + needs +} diff --git a/tests/testthat/test-intervals.R b/tests/testthat/test-intervals.R index 82a73b7e0..9d0c0517f 100644 --- a/tests/testthat/test-intervals.R +++ b/tests/testthat/test-intervals.R @@ -449,3 +449,82 @@ describe("rm_impute_obs_params", { expect_equal(result$intervals, intervals_before) }) }) + +describe(".build_rev_deps", { + it("builds reverse dependency map from Depends column", { + metadata <- data.frame( + PKNCA = c("half.life", "lambda.z", "aucinf.obs", "cmax"), + Depends = c("tmax, tlast", "half.life", "lambda.z", NA), + stringsAsFactors = FALSE + ) + result <- .build_rev_deps(metadata) + expect_type(result, "list") + expect_true("lambda.z" %in% result[["half.life"]]) + expect_true("aucinf.obs" %in% result[["lambda.z"]]) + expect_null(result[["cmax"]]) + }) + + it("handles empty Depends strings", { + metadata <- data.frame( + PKNCA = c("param1", "param2"), + Depends = c("", NA), + stringsAsFactors = FALSE + ) + result <- .build_rev_deps(metadata) + expect_type(result, "list") + }) +}) + +describe(".walk_forward_deps", { + it("finds half.life via transitive chain: half.life -> lambda.z -> aucinf.obs", { + metadata <- data.frame( + PKNCA = c("half.life", "lambda.z", "aucinf.obs", "cmax", "tmax"), + Depends = c("tmax, tlast", "half.life", "lambda.z", NA, NA), + stringsAsFactors = FALSE + ) + rev_deps <- .build_rev_deps(metadata) + start_set <- c("aucinf.obs") + result <- .walk_forward_deps(start_set, rev_deps) + expect_true("aucinf.obs" %in% result) + expect_true("lambda.z" %in% result) + expect_true("half.life" %in% result) + expect_false("cmax" %in% result) + expect_false("tmax" %in% result) + }) + + it("excludes observational leaf params (cmax, tmax, tlast) regardless of depth", { + metadata <- data.frame( + PKNCA = c("half.life", "lambda.z", "aucinf.obs"), + Depends = c("tmax", "half.life", "lambda.z"), + stringsAsFactors = FALSE + ) + rev_deps <- .build_rev_deps(metadata) + result <- .walk_forward_deps(c("aucinf.obs"), rev_deps) + expect_true("lambda.z" %in% result) + expect_true("half.life" %in% result) + # tmax is in default obs_params → excluded regardless of depth + expect_false("tmax" %in% result) + }) +}) + +describe("rm_impute_obs_params integration", { + it("retains imputation for half.life when aucinf.obs is requested (#1057)", { + data <- FIXTURE_PKNCA_DATA + # Mark all intervals as having imputation + if (!"impute" %in% names(data$intervals)) { + data$intervals$impute <- "blq" + } else { + data$intervals$impute[is.na(data$intervals$impute)] <- "blq" + } + # Request only aucinf.obs and half.life + for (col in names(data$intervals)) { + if (col %in% c("start", "end", "impute", "type_interval")) next + data$intervals[[col]] <- col %in% c("aucinf.obs", "half.life") + } + result <- rm_impute_obs_params(data, metadata_nca_parameters) + # half.life should NOT have imputation removed (it feeds aucinf.obs) + expect_false( + any(grepl("half.life", result$intervals$impute_not_for_params %||% character())) + ) + }) +})