From 5dbfeffa7898ec336abdc39118cb9a7bcb217c28 Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Fri, 29 May 2026 19:58:29 +0800 Subject: [PATCH 01/12] fix: resolve transitive BLQ imputation dependencies for half.life chain (#1057) rm_impute_obs_params() previously used a single-level dependency check: parameters whose Depends did not directly reference an AUC param had their BLQ imputation removed. This missed the transitive chain half.life -> lambda.z -> aucinf.obs, causing standalone half.life to be calculated on raw data while AUCIFO internally used a different (imputed) half.life value. New .resolve_upstream_deps() helper recursively traces upstream dependencies (2 levels) from AUC parameters to identify all params in the calculation chain. half.life and lambda.z now correctly retain BLQ imputation when any AUC-dependent parameter is requested. Also adds the impute column guard from #1266 to prevent the "PKNCA_impute_method_FALSE" error when start_impute is FALSE. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 ++- DESCRIPTION | 2 +- NEWS.md | 3 +++ R/intervals.R | 42 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index ba2ec833b..eb776aafc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ 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 +CLAUDE.md \ No newline at end of file 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..9486459b2 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,30 @@ 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. + # Start with directly AUC-related params, then resolve transitive + # dependencies (e.g. aucinf.obs -> lambda.z -> half.life) so that + # the half.life used within AUCINF reflects the same BLQ-imputed data + # as the AUC calculation itself (#1057). params_auc_dep <- metadata_nca_parameters %>% filter(grepl("auc|aumc", PKNCA) | grepl("auc", Depends)) %>% pull(PKNCA) + # Resolve transitive dependencies from AUC parameters. + # Two levels cover the full chain without reaching purely + # observational leaf parameters (cmax, tmax, tlast): + # Level 1: lambda.z, clast.obs (direct deps of aucinf.obs/pred) + # Level 2: half.life (dep of lambda.z, clast.pred) + needs_impute <- params_auc_dep + for (depth in 1:2) { + upstream <- .resolve_upstream_deps(metadata_nca_parameters, needs_impute) + upstream <- setdiff(upstream, needs_impute) + if (length(upstream) == 0) break + needs_impute <- c(needs_impute, upstream) + } + 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 +387,15 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa data } + +#' Resolve the direct upstream dependencies of a set of PKNCA parameters. +#' Returns all parameter names listed in the Depends column for the given params. +#' @noRd +.resolve_upstream_deps <- function(metadata, params) { + dep_str <- metadata %>% + filter(PKNCA %in% params) %>% + pull(Depends) + dep_str <- dep_str[!is.na(dep_str) & dep_str != ""] + if (length(dep_str) == 0) return(character()) + unique(trimws(unlist(strsplit(dep_str, ",")))) +} From e1a9fa3db07cd8764de7bd7b0c0ed6c945f38960 Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Fri, 29 May 2026 20:19:59 +0800 Subject: [PATCH 02/12] refactor: use forward dependency walk in rm_impute_obs_params (#1057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the reverse traversal (.resolve_upstream_deps) with a forward approach using a reverse dependency table (.build_rev_deps + .walk_forward_deps). Now follows the natural data-flow direction: for each parameter, checks whether any of its consumers (params that list it in Depends) are in the AUC chain. This makes the logic more intuitive — "half.life feeds lambda.z, which feeds aucinf.obs, therefore half.life keeps imputation." Extracted .walk_forward_deps() as a separate helper to reduce cyclomatic complexity. Co-Authored-By: Claude Opus 4.7 --- R/intervals.R | 66 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/R/intervals.R b/R/intervals.R index 9486459b2..f9e026113 100644 --- a/R/intervals.R +++ b/R/intervals.R @@ -331,26 +331,16 @@ update_main_intervals <- function( #' rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_parameters) { # Don't impute parameters that are not AUC dependent. - # Start with directly AUC-related params, then resolve transitive - # dependencies (e.g. aucinf.obs -> lambda.z -> half.life) so that - # the half.life used within AUCINF reflects the same BLQ-imputed data - # as the AUC calculation itself (#1057). + # 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) - # Resolve transitive dependencies from AUC parameters. - # Two levels cover the full chain without reaching purely - # observational leaf parameters (cmax, tmax, tlast): - # Level 1: lambda.z, clast.obs (direct deps of aucinf.obs/pred) - # Level 2: half.life (dep of lambda.z, clast.pred) - needs_impute <- params_auc_dep - for (depth in 1:2) { - upstream <- .resolve_upstream_deps(metadata_nca_parameters, needs_impute) - upstream <- setdiff(upstream, needs_impute) - if (length(upstream) == 0) break - needs_impute <- c(needs_impute, upstream) - } + # 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(!PKNCA %in% needs_impute) %>% @@ -388,14 +378,40 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa data } -#' Resolve the direct upstream dependencies of a set of PKNCA parameters. -#' Returns all parameter names listed in the Depends column for the given params. +#' 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 -.resolve_upstream_deps <- function(metadata, params) { - dep_str <- metadata %>% - filter(PKNCA %in% params) %>% - pull(Depends) - dep_str <- dep_str[!is.na(dep_str) & dep_str != ""] - if (length(dep_str) == 0) return(character()) - unique(trimws(unlist(strsplit(dep_str, ",")))) +.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. +#' Limited to 2 steps to avoid reaching purely observational leaf params. +#' @noRd +.walk_forward_deps <- function(start_set, rev_deps, max_depth = 2) { + needs <- start_set + for (depth in seq_len(max_depth)) { + newly_found <- character() + for (pkg in names(rev_deps)) { + if (!pkg %in% needs && any(rev_deps[[pkg]] %in% needs)) { + newly_found <- c(newly_found, pkg) + } + } + if (length(newly_found) == 0) break + needs <- c(needs, newly_found) + } + needs } From b99b58d40358ea67e9a4cf122f9ca7e66d84993b Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Thu, 4 Jun 2026 09:30:24 +0800 Subject: [PATCH 03/12] chore: remove CLAUDE.md from .gitignore; add tests for transitive dep helpers (#1057) - Remove CLAUDE.md from project .gitignore (per reviewer feedback) - Add 4 unit tests for .build_rev_deps() and .walk_forward_deps() covering reverse dep map construction, empty Depends handling, transitive chain resolution (half.life -> lambda.z -> aucinf.obs), and max_depth boundary enforcement Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 +- tests/testthat/test-intervals.R | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index eb776aafc..ba2ec833b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,4 @@ inst/shiny/log/* .DS_Store # {shinytest2}: Ignore new debug snapshots for `$expect_values()` *_.new.png -desktop.ini -CLAUDE.md \ No newline at end of file +desktop.ini \ No newline at end of file diff --git a/tests/testthat/test-intervals.R b/tests/testthat/test-intervals.R index 82a73b7e0..41eecc5d3 100644 --- a/tests/testthat/test-intervals.R +++ b/tests/testthat/test-intervals.R @@ -449,3 +449,59 @@ 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("stops at max_depth=2 and does not reach leaf params at depth 3", { + 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) + expect_false("tmax" %in% result) + }) +}) From 737a8f3e828136448f30647a84b3a36671c48741 Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Thu, 4 Jun 2026 13:32:16 +0800 Subject: [PATCH 04/12] refactor: use explicit exclusion set instead of max_depth in walk_forward_deps (#1057) - Replace hardcoded max_depth=2 with explicit obs_params exclusion set (cmax, tmax, tlast). More robust - won't silently break if future parameters introduce deeper dependency chains. - Add rm_impute_obs_params integration test verifying half.life retains imputation when aucinf.obs is requested. - Ensure .gitignore has trailing newline. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 2 +- R/intervals.R | 11 +++++++---- tests/testthat/test-intervals.R | 25 ++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 6 deletions(-) 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/R/intervals.R b/R/intervals.R index f9e026113..ed1e3b669 100644 --- a/R/intervals.R +++ b/R/intervals.R @@ -399,14 +399,17 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa #' 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. -#' Limited to 2 steps to avoid reaching purely observational leaf params. +#' 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, max_depth = 2) { +.walk_forward_deps <- function(start_set, rev_deps, + obs_params = c("cmax", "tmax", "tlast")) { needs <- start_set - for (depth in seq_len(max_depth)) { + repeat { newly_found <- character() for (pkg in names(rev_deps)) { - if (!pkg %in% needs && any(rev_deps[[pkg]] %in% needs)) { + if (!pkg %in% needs && !pkg %in% obs_params && + any(rev_deps[[pkg]] %in% needs)) { newly_found <- c(newly_found, pkg) } } diff --git a/tests/testthat/test-intervals.R b/tests/testthat/test-intervals.R index 41eecc5d3..9d0c0517f 100644 --- a/tests/testthat/test-intervals.R +++ b/tests/testthat/test-intervals.R @@ -492,7 +492,7 @@ describe(".walk_forward_deps", { expect_false("tmax" %in% result) }) - it("stops at max_depth=2 and does not reach leaf params at depth 3", { + 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"), @@ -502,6 +502,29 @@ describe(".walk_forward_deps", { 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())) + ) + }) +}) From 73bcfb8f07bb640bff3a461df2f448f6a0d813b6 Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Tue, 14 Jul 2026 11:19:13 +0800 Subject: [PATCH 05/12] refactor: rename dep helpers, add cycle guard, clarify docs (#1057) - Rename .build_rev_deps -> .build_consumer_map (builds "who consumes X") - Rename .walk_forward_deps -> .find_upstream_deps (walks upstream from AUC params) - Add max_iter=50L cycle guard to .find_upstream_deps - Document obs_params as maintenance point for new leaf params - Update test names to match new semantics Co-Authored-By: Claude Opus 4.7 --- R/intervals.R | 43 +++++++++++++++++++-------------- tests/testthat/test-intervals.R | 24 +++++++++--------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/R/intervals.R b/R/intervals.R index ed1e3b669..e8b795a9e 100644 --- a/R/intervals.R +++ b/R/intervals.R @@ -337,10 +337,10 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa 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) + # Build consumer map ("who consumes X") and walk upstream from AUC params + # to find all parameters in their dependency chain (#1057). + consumer_map <- .build_consumer_map(metadata_nca_parameters) + needs_impute <- .find_upstream_deps(params_auc_dep, consumer_map) params_not_to_impute <- metadata_nca_parameters %>% filter(!PKNCA %in% needs_impute) %>% @@ -378,11 +378,11 @@ 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. +#' Build a consumer map from the Depends column. +#' For each parameter A, returns which parameters consume A (list A as a dependency). +#' e.g., lambda.z lists half.life in Depends, so consumer_map$half.life = "lambda.z". #' @noRd -.build_rev_deps <- function(metadata) { +.build_consumer_map <- function(metadata) { rev <- list() for (i in seq_len(nrow(metadata))) { pkg_name <- metadata$PKNCA[i] @@ -396,20 +396,27 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa 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. +#' Find all upstream dependencies transitively from `start_set`. +#' Walks the consumer map to collect params that feed into the current chain, +#' stopping at purely observational leaf params (cmax, tmax, tlast). +#' +#' @param start_set Character vector of starting PKNCA parameter names. +#' @param consumer_map Named list from `.build_consumer_map()`. +#' @param obs_params Character vector of observational params to exclude. +#' Must be kept in sync with metadata_nca_parameters when new leaf params +#' are added. Default: cmax, tmax, tlast. +#' @param max_iter Maximum iterations to guard against infinite loops from +#' circular dependencies. 50 is generous (~40 params in real metadata). #' @noRd -.walk_forward_deps <- function(start_set, rev_deps, - obs_params = c("cmax", "tmax", "tlast")) { +.find_upstream_deps <- function(start_set, consumer_map, + obs_params = c("cmax", "tmax", "tlast"), + max_iter = 50L) { needs <- start_set - repeat { + for (iter in seq_len(max_iter)) { newly_found <- character() - for (pkg in names(rev_deps)) { + for (pkg in names(consumer_map)) { if (!pkg %in% needs && !pkg %in% obs_params && - any(rev_deps[[pkg]] %in% needs)) { + any(consumer_map[[pkg]] %in% needs)) { newly_found <- c(newly_found, pkg) } } diff --git a/tests/testthat/test-intervals.R b/tests/testthat/test-intervals.R index 9d0c0517f..618914470 100644 --- a/tests/testthat/test-intervals.R +++ b/tests/testthat/test-intervals.R @@ -450,14 +450,14 @@ describe("rm_impute_obs_params", { }) }) -describe(".build_rev_deps", { - it("builds reverse dependency map from Depends column", { +describe(".build_consumer_map", { + it("builds consumer 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) + result <- .build_consumer_map(metadata) expect_type(result, "list") expect_true("lambda.z" %in% result[["half.life"]]) expect_true("aucinf.obs" %in% result[["lambda.z"]]) @@ -470,21 +470,21 @@ describe(".build_rev_deps", { Depends = c("", NA), stringsAsFactors = FALSE ) - result <- .build_rev_deps(metadata) + result <- .build_consumer_map(metadata) expect_type(result, "list") }) }) -describe(".walk_forward_deps", { +describe(".find_upstream_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) + consumer_map <- .build_consumer_map(metadata) start_set <- c("aucinf.obs") - result <- .walk_forward_deps(start_set, rev_deps) + result <- .find_upstream_deps(start_set, consumer_map) expect_true("aucinf.obs" %in% result) expect_true("lambda.z" %in% result) expect_true("half.life" %in% result) @@ -498,8 +498,8 @@ describe(".walk_forward_deps", { Depends = c("tmax", "half.life", "lambda.z"), stringsAsFactors = FALSE ) - rev_deps <- .build_rev_deps(metadata) - result <- .walk_forward_deps(c("aucinf.obs"), rev_deps) + consumer_map <- .build_consumer_map(metadata) + result <- .find_upstream_deps(c("aucinf.obs"), consumer_map) expect_true("lambda.z" %in% result) expect_true("half.life" %in% result) # tmax is in default obs_params → excluded regardless of depth @@ -522,9 +522,7 @@ describe("rm_impute_obs_params integration", { 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())) - ) + # half.life should retain imputation (it feeds aucinf.obs via lambda.z) + expect_true("blq" %in% result$intervals$impute) }) }) From 030c7bdd20fab411af8fa3c8ca186b509d28b645 Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Tue, 14 Jul 2026 11:26:09 +0800 Subject: [PATCH 06/12] style: fix indentation lint in .find_upstream_deps (#1057) --- R/intervals.R | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/R/intervals.R b/R/intervals.R index e8b795a9e..a654ce5fc 100644 --- a/R/intervals.R +++ b/R/intervals.R @@ -409,14 +409,14 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa #' circular dependencies. 50 is generous (~40 params in real metadata). #' @noRd .find_upstream_deps <- function(start_set, consumer_map, - obs_params = c("cmax", "tmax", "tlast"), - max_iter = 50L) { + obs_params = c("cmax", "tmax", "tlast"), + max_iter = 50L) { needs <- start_set for (iter in seq_len(max_iter)) { newly_found <- character() for (pkg in names(consumer_map)) { if (!pkg %in% needs && !pkg %in% obs_params && - any(consumer_map[[pkg]] %in% needs)) { + any(consumer_map[[pkg]] %in% needs)) { newly_found <- c(newly_found, pkg) } } From aca2aaba7407dbee6fc2e6eb79f33cf25186390d Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Mon, 20 Jul 2026 09:54:33 +0800 Subject: [PATCH 07/12] test: add bidirectional assertions for rm_impute_obs_params (#1057) Address reviewer feedback on PR #1338: - Enhance integration test to assert both directions: AUC-chain params (half.life, lambda.z, aucinf.obs) retain blq imputation, observational params (cmax, tmax, tlast) lose blq imputation. - Extract .is_new_upstream_consumer() helper to reduce cyclomatic complexity of .find_upstream_deps() to pass lintr. --- R/intervals.R | 9 +++++++-- tests/testthat/test-intervals.R | 28 +++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/R/intervals.R b/R/intervals.R index a654ce5fc..24ebf02d1 100644 --- a/R/intervals.R +++ b/R/intervals.R @@ -396,6 +396,12 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa rev } +#' Check if a parameter is a new upstream consumer of the current chain. +#' @noRd +.is_new_upstream_consumer <- function(pkg, consumer_map, needs, obs_params) { + !pkg %in% needs && !pkg %in% obs_params && any(consumer_map[[pkg]] %in% needs) +} + #' Find all upstream dependencies transitively from `start_set`. #' Walks the consumer map to collect params that feed into the current chain, #' stopping at purely observational leaf params (cmax, tmax, tlast). @@ -415,8 +421,7 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa for (iter in seq_len(max_iter)) { newly_found <- character() for (pkg in names(consumer_map)) { - if (!pkg %in% needs && !pkg %in% obs_params && - any(consumer_map[[pkg]] %in% needs)) { + if (.is_new_upstream_consumer(pkg, consumer_map, needs, obs_params)) { newly_found <- c(newly_found, pkg) } } diff --git a/tests/testthat/test-intervals.R b/tests/testthat/test-intervals.R index 618914470..fb89cd06f 100644 --- a/tests/testthat/test-intervals.R +++ b/tests/testthat/test-intervals.R @@ -508,7 +508,7 @@ describe(".find_upstream_deps", { }) describe("rm_impute_obs_params integration", { - it("retains imputation for half.life when aucinf.obs is requested (#1057)", { + it("retains imputation for half-life chain and removes it for observational params (#1057)", { data <- FIXTURE_PKNCA_DATA # Mark all intervals as having imputation if (!"impute" %in% names(data$intervals)) { @@ -516,13 +516,31 @@ describe("rm_impute_obs_params integration", { } else { data$intervals$impute[is.na(data$intervals$impute)] <- "blq" } - # Request only aucinf.obs and half.life + + # Request AUC-chain params and observational params + requested <- c("aucinf.obs", "half.life", "cmax", "tmax", "tlast") 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") + data$intervals[[col]] <- col %in% requested } + result <- rm_impute_obs_params(data, metadata_nca_parameters) - # half.life should retain imputation (it feeds aucinf.obs via lambda.z) - expect_true("blq" %in% result$intervals$impute) + + # AUC-chain params (half.life, lambda.z, aucinf.obs) should RETAIN imputation + auc_chain_rows <- result$intervals[ + result$intervals[["aucinf.obs"]] | + result$intervals[["half.life"]] | + result$intervals[["lambda.z"]], + ] + expect_true("blq" %in% auc_chain_rows$impute) + + # Observational params (cmax, tmax, tlast) should LOSE imputation + obs_rows <- result$intervals[ + result$intervals[["cmax"]] | + result$intervals[["tmax"]] | + result$intervals[["tlast"]], + ] + expect_false("blq" %in% obs_rows$impute) + expect_true(all(is.na(obs_rows$impute) | obs_rows$impute == "")) }) }) From 30e4313da658a9172693a2cc87b336793b5b38fc Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Mon, 20 Jul 2026 09:54:43 +0800 Subject: [PATCH 08/12] style: fix pre-existing indentation lint in function signatures Fix 2-space indentation to 4-space in multi-line function definitions so that lintr::lint_package() passes with 0 violations. These are pre-existing issues in files not otherwise touched by #1057. --- R/PKNCA.R | 36 ++++++++++++------------ R/exploration_plots.R | 32 ++++++++++----------- R/ratio_calculations.R | 64 +++++++++++++++++++++--------------------- 3 files changed, 66 insertions(+), 66 deletions(-) diff --git a/R/PKNCA.R b/R/PKNCA.R index f1410f0fe..ed3c0ef2a 100644 --- a/R/PKNCA.R +++ b/R/PKNCA.R @@ -89,10 +89,10 @@ #' #' @export PKNCA_create_data_object <- function( # nolint: object_name_linter - adnca_data, - mapping = NULL, - applied_filters = NULL, - time_duplicate_rows = NULL) { + adnca_data, + mapping = NULL, + applied_filters = NULL, + time_duplicate_rows = NULL) { # Derive nca_exclude_reason_columns from mapping nca_exclude_reason_columns <- NULL if (!is.null(mapping)) { @@ -295,20 +295,20 @@ PKNCA_create_data_object <- function( # nolint: object_name_linter #' #' @export PKNCA_update_data_object <- function( # nolint: object_name_linter - adnca_data, - method, - selected_analytes, - selected_profile, - selected_pcspec, - start_impute = TRUE, - hl_adj_rules = NULL, - exclusion_list = NULL, - keep_interval_cols = NULL, - min_hl_points = 3, - parameter_selections = NULL, - int_parameters = NULL, - blq_imputation_rule = NULL, - custom_units_table = NULL) { + adnca_data, + method, + selected_analytes, + selected_profile, + selected_pcspec, + start_impute = TRUE, + hl_adj_rules = NULL, + exclusion_list = NULL, + keep_interval_cols = NULL, + min_hl_points = 3, + parameter_selections = NULL, + int_parameters = NULL, + blq_imputation_rule = NULL, + custom_units_table = NULL) { data <- adnca_data analyte_column <- data$conc$columns$groups$group_analyte diff --git a/R/exploration_plots.R b/R/exploration_plots.R index a0b669106..c9557f964 100644 --- a/R/exploration_plots.R +++ b/R/exploration_plots.R @@ -33,22 +33,22 @@ #' @return A `ggplot` object representing the individual PK line plot. #' @export exploration_individualplot <- function( - pknca_data, - color_by, - facet_by = NULL, - show_facet_n = FALSE, - ylog_scale = FALSE, - threshold_value = NULL, - x_limits = NULL, - y_limits = NULL, - show_dose = FALSE, - palette = "default", - tooltip_vars = NULL, - labels_df = NULL, - filtering_list = NULL, - use_time_since_last_dose = FALSE, - show_legend = TRUE, - line_type = "default") { + pknca_data, + color_by, + facet_by = NULL, + show_facet_n = FALSE, + ylog_scale = FALSE, + threshold_value = NULL, + x_limits = NULL, + y_limits = NULL, + show_dose = FALSE, + palette = "default", + tooltip_vars = NULL, + labels_df = NULL, + filtering_list = NULL, + use_time_since_last_dose = FALSE, + show_legend = TRUE, + line_type = "default") { result <- .prepare_line_type_data( line_type = line_type, diff --git a/R/ratio_calculations.R b/R/ratio_calculations.R index efee4a847..d40378ca5 100644 --- a/R/ratio_calculations.R +++ b/R/ratio_calculations.R @@ -84,27 +84,27 @@ multiple_matrix_ratios <- function(data, matrix_col, conc_col, units_col, #' @returns A data.frame result object with the calculated ratios. #' @export calculate_ratios <- function( - data, - test_parameter, - ref_parameter = test_parameter, - match_cols, - ref_groups, - test_groups = NULL, - adjusting_factor = 1, - custom_pptestcd = NULL) { + data, + test_parameter, + ref_parameter = test_parameter, + match_cols, + ref_groups, + test_groups = NULL, + adjusting_factor = 1, + custom_pptestcd = NULL) { UseMethod("calculate_ratios", data) } #' @export calculate_ratios.data.frame <- function( - data, - test_parameter, - ref_parameter = test_parameter, - match_cols, - ref_groups, - test_groups = NULL, - adjusting_factor = 1, - custom_pptestcd = NULL) { + data, + test_parameter, + ref_parameter = test_parameter, + match_cols, + ref_groups, + test_groups = NULL, + adjusting_factor = 1, + custom_pptestcd = NULL) { if (!any(data$PPTESTCD == test_parameter)) { warning( paste0( @@ -232,14 +232,14 @@ calculate_ratios.data.frame <- function( #' @export calculate_ratios.PKNCAresults <- function( - data, - test_parameter, - ref_parameter = test_parameter, - match_cols, - ref_groups, - test_groups = NULL, - adjusting_factor = 1, - custom_pptestcd = NULL) { + data, + test_parameter, + ref_parameter = test_parameter, + match_cols, + ref_groups, + test_groups = NULL, + adjusting_factor = 1, + custom_pptestcd = NULL) { # Check if match_cols and ref_groups are valid group columns # Make checks on the input formats cols_used_for_ratios <- c(match_cols, names(ref_groups), names(test_groups)) @@ -351,14 +351,14 @@ parse_interval_parameter <- function(param) { #' @param custom_pptestcd Optional character. If provided, will be used as the PPTESTCD value. #' @returns A data.frame with the calculated ratios for the specified settings. calculate_ratio_app <- function( - res, - test_parameter, - ref_parameter = test_parameter, - test_group = "(all other levels)", - ref_group = "PARAM: Analyte01", - aggregate_subject = "no", - adjusting_factor = 1, - custom_pptestcd = NULL) { + res, + test_parameter, + ref_parameter = test_parameter, + test_group = "(all other levels)", + ref_group = "PARAM: Analyte01", + aggregate_subject = "no", + adjusting_factor = 1, + custom_pptestcd = NULL) { # Parse interval parameters (e.g. AUCINT_0-20 -> base=AUCINT, start=0, end=20) test_parsed <- parse_interval_parameter(test_parameter) ref_parsed <- parse_interval_parameter(ref_parameter) From 3616b41e499f01c393c2d47325770eb412b8100e Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Mon, 3 Aug 2026 10:37:05 +0800 Subject: [PATCH 09/12] Revert "style: fix pre-existing indentation lint in function signatures" This reverts commit 30e4313. lintr requires 2-space indentation, but that commit changed function-signature args to 4 spaces, introducing "Indentation should be 2 spaces but is 4 spaces" failures in the Lint CI check (R/ratio_calculations.R, R/PKNCA.R, R/exploration_plots.R). --- R/PKNCA.R | 36 ++++++++++++------------ R/exploration_plots.R | 32 ++++++++++----------- R/ratio_calculations.R | 64 +++++++++++++++++++++--------------------- 3 files changed, 66 insertions(+), 66 deletions(-) diff --git a/R/PKNCA.R b/R/PKNCA.R index ed3c0ef2a..f1410f0fe 100644 --- a/R/PKNCA.R +++ b/R/PKNCA.R @@ -89,10 +89,10 @@ #' #' @export PKNCA_create_data_object <- function( # nolint: object_name_linter - adnca_data, - mapping = NULL, - applied_filters = NULL, - time_duplicate_rows = NULL) { + adnca_data, + mapping = NULL, + applied_filters = NULL, + time_duplicate_rows = NULL) { # Derive nca_exclude_reason_columns from mapping nca_exclude_reason_columns <- NULL if (!is.null(mapping)) { @@ -295,20 +295,20 @@ PKNCA_create_data_object <- function( # nolint: object_name_linter #' #' @export PKNCA_update_data_object <- function( # nolint: object_name_linter - adnca_data, - method, - selected_analytes, - selected_profile, - selected_pcspec, - start_impute = TRUE, - hl_adj_rules = NULL, - exclusion_list = NULL, - keep_interval_cols = NULL, - min_hl_points = 3, - parameter_selections = NULL, - int_parameters = NULL, - blq_imputation_rule = NULL, - custom_units_table = NULL) { + adnca_data, + method, + selected_analytes, + selected_profile, + selected_pcspec, + start_impute = TRUE, + hl_adj_rules = NULL, + exclusion_list = NULL, + keep_interval_cols = NULL, + min_hl_points = 3, + parameter_selections = NULL, + int_parameters = NULL, + blq_imputation_rule = NULL, + custom_units_table = NULL) { data <- adnca_data analyte_column <- data$conc$columns$groups$group_analyte diff --git a/R/exploration_plots.R b/R/exploration_plots.R index c9557f964..a0b669106 100644 --- a/R/exploration_plots.R +++ b/R/exploration_plots.R @@ -33,22 +33,22 @@ #' @return A `ggplot` object representing the individual PK line plot. #' @export exploration_individualplot <- function( - pknca_data, - color_by, - facet_by = NULL, - show_facet_n = FALSE, - ylog_scale = FALSE, - threshold_value = NULL, - x_limits = NULL, - y_limits = NULL, - show_dose = FALSE, - palette = "default", - tooltip_vars = NULL, - labels_df = NULL, - filtering_list = NULL, - use_time_since_last_dose = FALSE, - show_legend = TRUE, - line_type = "default") { + pknca_data, + color_by, + facet_by = NULL, + show_facet_n = FALSE, + ylog_scale = FALSE, + threshold_value = NULL, + x_limits = NULL, + y_limits = NULL, + show_dose = FALSE, + palette = "default", + tooltip_vars = NULL, + labels_df = NULL, + filtering_list = NULL, + use_time_since_last_dose = FALSE, + show_legend = TRUE, + line_type = "default") { result <- .prepare_line_type_data( line_type = line_type, diff --git a/R/ratio_calculations.R b/R/ratio_calculations.R index d40378ca5..efee4a847 100644 --- a/R/ratio_calculations.R +++ b/R/ratio_calculations.R @@ -84,27 +84,27 @@ multiple_matrix_ratios <- function(data, matrix_col, conc_col, units_col, #' @returns A data.frame result object with the calculated ratios. #' @export calculate_ratios <- function( - data, - test_parameter, - ref_parameter = test_parameter, - match_cols, - ref_groups, - test_groups = NULL, - adjusting_factor = 1, - custom_pptestcd = NULL) { + data, + test_parameter, + ref_parameter = test_parameter, + match_cols, + ref_groups, + test_groups = NULL, + adjusting_factor = 1, + custom_pptestcd = NULL) { UseMethod("calculate_ratios", data) } #' @export calculate_ratios.data.frame <- function( - data, - test_parameter, - ref_parameter = test_parameter, - match_cols, - ref_groups, - test_groups = NULL, - adjusting_factor = 1, - custom_pptestcd = NULL) { + data, + test_parameter, + ref_parameter = test_parameter, + match_cols, + ref_groups, + test_groups = NULL, + adjusting_factor = 1, + custom_pptestcd = NULL) { if (!any(data$PPTESTCD == test_parameter)) { warning( paste0( @@ -232,14 +232,14 @@ calculate_ratios.data.frame <- function( #' @export calculate_ratios.PKNCAresults <- function( - data, - test_parameter, - ref_parameter = test_parameter, - match_cols, - ref_groups, - test_groups = NULL, - adjusting_factor = 1, - custom_pptestcd = NULL) { + data, + test_parameter, + ref_parameter = test_parameter, + match_cols, + ref_groups, + test_groups = NULL, + adjusting_factor = 1, + custom_pptestcd = NULL) { # Check if match_cols and ref_groups are valid group columns # Make checks on the input formats cols_used_for_ratios <- c(match_cols, names(ref_groups), names(test_groups)) @@ -351,14 +351,14 @@ parse_interval_parameter <- function(param) { #' @param custom_pptestcd Optional character. If provided, will be used as the PPTESTCD value. #' @returns A data.frame with the calculated ratios for the specified settings. calculate_ratio_app <- function( - res, - test_parameter, - ref_parameter = test_parameter, - test_group = "(all other levels)", - ref_group = "PARAM: Analyte01", - aggregate_subject = "no", - adjusting_factor = 1, - custom_pptestcd = NULL) { + res, + test_parameter, + ref_parameter = test_parameter, + test_group = "(all other levels)", + ref_group = "PARAM: Analyte01", + aggregate_subject = "no", + adjusting_factor = 1, + custom_pptestcd = NULL) { # Parse interval parameters (e.g. AUCINT_0-20 -> base=AUCINT, start=0, end=20) test_parsed <- parse_interval_parameter(test_parameter) ref_parsed <- parse_interval_parameter(ref_parameter) From c4b6cbc9a40ce1e785278f757925c0b5068b58dc Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Mon, 3 Aug 2026 10:37:19 +0800 Subject: [PATCH 10/12] test: add end-to-end BLQ imputation consistency test (#1057) Address remaining reviewer feedback on PR #1338: - Add tests/testthat/data/test-blq-ADNCA.csv (dataset attached to issue #1057: IV bolus profile with trailing BLQ concentrations) and an end-to-end test running the full NCA pipeline with a BLQ imputation rule, asserting the internal consistency identity AUCIFO = AUCLST + CLST/LAMZ for both the obs and pred branches, plus the parameter values obtained in the app for this dataset. - Add AUCIFO, AUCIFP and LAMZHL to inst/WORDLIST to fix the spellcheck CI failure on the new NEWS.md entry. - Bump version to 0.1.0.9186. --- DESCRIPTION | 2 +- inst/WORDLIST | 3 ++ tests/testthat/data/test-blq-ADNCA.csv | 13 ++++++ tests/testthat/test-intervals.R | 61 ++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/testthat/data/test-blq-ADNCA.csv diff --git a/DESCRIPTION b/DESCRIPTION index 07db95297..74bc19ca5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: aNCA Title: (Pre-)Clinical NCA in a Dynamic Shiny App -Version: 0.1.0.9185 +Version: 0.1.0.9186 Authors@R: c( person("Ercan", "Suekuer", email = "ercan.suekuer@roche.com", role = "aut", comment = c(ORCID = "0009-0001-1626-1526")), diff --git a/inst/WORDLIST b/inst/WORDLIST index 4d990a028..deb0c4880 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -7,7 +7,9 @@ AEFRLT AFRLT ARRLT ATPTREF +AUCIFO AUCIFOD +AUCIFP AUCINT AUCLSTD AUCPEO @@ -58,6 +60,7 @@ INTRAVASCULAR Kezia Kobana LAMZ +LAMZHL LAMZLL LAMZNPT LAMZSPN diff --git a/tests/testthat/data/test-blq-ADNCA.csv b/tests/testthat/data/test-blq-ADNCA.csv new file mode 100644 index 000000000..0c71b0f85 --- /dev/null +++ b/tests/testthat/data/test-blq-ADNCA.csv @@ -0,0 +1,13 @@ +"STUDYID","USUBJID","PCSPEC","PARAM","DOSETRT","DOSEA","DOSEU","ROUTE","ADOSEDUR","AVAL","AVALU","AFRLT","NFRLT","ARRLT","NRRLT","RRLTU","ATPTREF","TRT01A","METABFL" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,100,"ng/mL",0.5,0.5,0.5,0.5,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,80,"ng/mL",1,1,1,1,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,65,"ng/mL",1.5,1.5,1.5,1.5,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,52,"ng/mL",2,2,2,2,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,35,"ng/mL",3,3,3,3,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,25,"ng/mL",4,4,4,4,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,12,"ng/mL",6,6,6,6,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,6,"ng/mL",8,8,8,8,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,2.5,"ng/mL",12,12,12,12,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,0,"ng/mL",24,24,24,24,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,0,"ng/mL",36,36,36,36,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,0,"ng/mL",48,48,48,48,"hr","DOSE 1","DrugA 10 mg",NA diff --git a/tests/testthat/test-intervals.R b/tests/testthat/test-intervals.R index fb89cd06f..3c37a3297 100644 --- a/tests/testthat/test-intervals.R +++ b/tests/testthat/test-intervals.R @@ -544,3 +544,64 @@ describe("rm_impute_obs_params integration", { expect_true(all(is.na(obs_rows$impute) | obs_rows$impute == "")) }) }) + +describe("BLQ imputation end-to-end on real bug dataset (#1057)", { + # Dataset attached to issue #1057: IV bolus profile with trailing BLQ (0) + # concentrations at 24/36/48 hr. Before the fix, rm_impute_obs_params() + # removed BLQ imputation from half.life (dependency check was one level + # deep), so lambda.z was calculated on non-imputed data while aucinf.obs + # used imputed data, breaking the AUCIFO = AUCLST + CLST/LAMZ identity. + blq_adnca <- read.csv(testthat::test_path("data", "test-blq-ADNCA.csv"), + na.strings = c("", "NA")) + blq_rule <- list(first = "keep", middle = "drop", last = 0) + auc_chain_params <- c("auclast", "aucinf.obs", "aucinf.pred", + "half.life", "lambda.z", "clast.obs", "clast.pred", + "cmax", "tmax", "tlast") + + pknca_data <- PKNCA_create_data_object(blq_adnca) + study_types <- unique(.derive_study_types(pknca_data)$type) + parameter_selections <- setNames( + lapply(study_types, function(x) auc_chain_params), + study_types + ) + + updated_data <- PKNCA_update_data_object( + adnca_data = pknca_data, + method = "lin up/log down", + selected_analytes = "DrugA", + selected_profile = "DOSE 1", + selected_pcspec = "SERUM", + start_impute = FALSE, + parameter_selections = parameter_selections, + blq_imputation_rule = blq_rule + ) + nca_results <- PKNCA_calculate_nca(updated_data, blq_rule = blq_rule) + results_df <- as.data.frame(nca_results$result) + get_pp <- function(pp) results_df$PPORRES[results_df$PPTESTCD == pp] + + it("keeps BLQ imputation on the half-life chain and removes it from observational params", { + auc_chain_rows <- updated_data$intervals[updated_data$intervals$aucinf.obs, ] + expect_true("blq" %in% auc_chain_rows$impute) + + obs_rows <- updated_data$intervals[updated_data$intervals$cmax, ] + expect_true(all(is.na(obs_rows$impute) | obs_rows$impute == "")) + }) + + it("produces internally consistent AUCIFO = AUCLST + CLST/LAMZ (obs and pred)", { + auclst <- get_pp("auclast") + lambda_z <- get_pp("lambda.z") + + expect_false(any(is.na(c(auclst, lambda_z, get_pp("aucinf.obs"))))) + expect_equal(get_pp("aucinf.obs"), auclst + get_pp("clast.obs") / lambda_z, + tolerance = 1e-6) + expect_equal(get_pp("aucinf.pred"), auclst + get_pp("clast.pred") / lambda_z, + tolerance = 1e-6) + }) + + it("matches the parameter values obtained in the app for the issue dataset", { + expect_equal(get_pp("auclast"), 251.4521, tolerance = 1e-4) + expect_equal(get_pp("aucinf.obs"), 259.2045, tolerance = 1e-4) + expect_equal(get_pp("lambda.z"), 0.32248, tolerance = 1e-4) + expect_equal(get_pp("half.life"), 2.1494, tolerance = 1e-4) + }) +}) From 241d73b4c803bb1f7c41882c1b6fa64f5549d9f5 Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Mon, 3 Aug 2026 11:05:59 +0800 Subject: [PATCH 11/12] test: fill METABFL in BLQ fixture dataset (#1057) The fixture had an empty METABFL column, which makes create_start_impute() silently skip C0 imputation (NA == "Y" poisons the case_when branches), producing PKNCA warnings about AUC ranges starting before the first measurement. Fill METABFL = "N" (parent drug) so the dataset is well-formed. --- tests/testthat/data/test-blq-ADNCA.csv | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/testthat/data/test-blq-ADNCA.csv b/tests/testthat/data/test-blq-ADNCA.csv index 0c71b0f85..f49390beb 100644 --- a/tests/testthat/data/test-blq-ADNCA.csv +++ b/tests/testthat/data/test-blq-ADNCA.csv @@ -1,13 +1,13 @@ "STUDYID","USUBJID","PCSPEC","PARAM","DOSETRT","DOSEA","DOSEU","ROUTE","ADOSEDUR","AVAL","AVALU","AFRLT","NFRLT","ARRLT","NRRLT","RRLTU","ATPTREF","TRT01A","METABFL" -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,100,"ng/mL",0.5,0.5,0.5,0.5,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,80,"ng/mL",1,1,1,1,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,65,"ng/mL",1.5,1.5,1.5,1.5,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,52,"ng/mL",2,2,2,2,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,35,"ng/mL",3,3,3,3,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,25,"ng/mL",4,4,4,4,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,12,"ng/mL",6,6,6,6,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,6,"ng/mL",8,8,8,8,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,2.5,"ng/mL",12,12,12,12,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,0,"ng/mL",24,24,24,24,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,0,"ng/mL",36,36,36,36,"hr","DOSE 1","DrugA 10 mg",NA -"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,0,"ng/mL",48,48,48,48,"hr","DOSE 1","DrugA 10 mg",NA +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,100,"ng/mL",0.5,0.5,0.5,0.5,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,80,"ng/mL",1,1,1,1,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,65,"ng/mL",1.5,1.5,1.5,1.5,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,52,"ng/mL",2,2,2,2,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,35,"ng/mL",3,3,3,3,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,25,"ng/mL",4,4,4,4,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,12,"ng/mL",6,6,6,6,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,6,"ng/mL",8,8,8,8,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,2.5,"ng/mL",12,12,12,12,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,0,"ng/mL",24,24,24,24,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,0,"ng/mL",36,36,36,36,"hr","DOSE 1","DrugA 10 mg","N" +"S1","BLQ-TEST-01","SERUM","DrugA","DrugA",10,"mg","INTRAVENOUS BOLUS",0,0,"ng/mL",48,48,48,48,"hr","DOSE 1","DrugA 10 mg","N" From 35be7eb23585d5d2999a45ad3e288c9ab39f219b Mon Sep 17 00:00:00 2001 From: Wang Zheng Date: Mon, 3 Aug 2026 11:43:46 +0800 Subject: [PATCH 12/12] fix: keep BLQ imputation for downstream AUC consumers (#1057) The upstream dependency walk in rm_impute_obs_params() missed downstream consumers of the AUC chain: params like vss.obs (Depends cl.obs, mrt.obs) and vz.obs (Depends cl.obs, lambda.z) have no "auc" in name or Depends, so their imputation was stripped. PKNCA then computed AUC internally on non-imputed data, producing "AUC range starting before the first measurement" warnings and values inconsistent with the imputed cl.obs. - Add .build_dependency_map() (mirror of .build_consumer_map()) and .find_downstream_consumers(), reusing the same fixpoint walk engine with the reverse graph; observational leaves stay excluded - Extend the end-to-end BLQ test with the app default parameter set: vss.obs/vz.obs stay imputed, no warnings, and vz.obs = cl.obs/lambda.z, vss.obs = cl.obs*mrt.obs hold exactly - Bump version to 0.1.0.9187 --- DESCRIPTION | 2 +- NEWS.md | 2 +- R/intervals.R | 45 ++++++++++++++++ tests/testthat/test-intervals.R | 95 +++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 74bc19ca5..a457258b1 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: aNCA Title: (Pre-)Clinical NCA in a Dynamic Shiny App -Version: 0.1.0.9186 +Version: 0.1.0.9187 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 9a0bf848e..bf1f273ed 100644 --- a/NEWS.md +++ b/NEWS.md @@ -27,7 +27,7 @@ * 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). +* 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, and also walks downstream so AUC consumers (e.g., `vss.obs`, `vz.obs`) keep the same imputed data (#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 24ebf02d1..20422f1ac 100644 --- a/R/intervals.R +++ b/R/intervals.R @@ -342,6 +342,12 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa consumer_map <- .build_consumer_map(metadata_nca_parameters) needs_impute <- .find_upstream_deps(params_auc_dep, consumer_map) + # Walk downstream too: AUC consumers (e.g., vss.obs = f(cl.obs, mrt.obs), + # vz.obs = f(cl.obs, lambda.z)) compute AUC-derived quantities internally, + # so they must use the same imputed data for consistency (#1057). + dependency_map <- .build_dependency_map(metadata_nca_parameters) + needs_impute <- .find_downstream_consumers(needs_impute, dependency_map) + params_not_to_impute <- metadata_nca_parameters %>% filter(!PKNCA %in% needs_impute) %>% pull(PKNCA) %>% @@ -396,6 +402,23 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa rev } +#' Build a dependency map from the Depends column. +#' Mirror image of `.build_consumer_map()`: for each parameter A, returns the +#' parameters A depends on. +#' e.g., vss.obs lists "cl.obs, mrt.obs" in Depends, so +#' dependency_map$vss.obs = c("cl.obs", "mrt.obs"). +#' @noRd +.build_dependency_map <- function(metadata) { + fwd <- 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 + fwd[[pkg_name]] <- trimws(strsplit(dep_str, ",")[[1]]) + } + fwd +} + #' Check if a parameter is a new upstream consumer of the current chain. #' @noRd .is_new_upstream_consumer <- function(pkg, consumer_map, needs, obs_params) { @@ -430,3 +453,25 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa } needs } + +#' Find all downstream consumers transitively from `start_set`. +#' Params whose Depends list intersects the current chain consume AUC-derived +#' quantities (e.g., vss.obs consumes cl.obs and mrt.obs) and therefore compute +#' AUC internally — they must use the same BLQ-imputed data (#1057). +#' +#' The fixpoint walk is direction-agnostic: `.find_upstream_deps()` follows +#' "who consumes X" edges, so passing the reverse (dependency) map from +#' `.build_dependency_map()` walks the graph downstream. Purely observational +#' leaf params (cmax, tmax, tlast) are excluded as in the upstream walk. +#' +#' @param start_set Character vector of starting PKNCA parameter names. +#' @param dependency_map Named list from `.build_dependency_map()`. +#' @param obs_params Character vector of observational params to exclude. +#' @param max_iter Maximum iterations to guard against infinite loops from +#' circular dependencies. +#' @noRd +.find_downstream_consumers <- function(start_set, dependency_map, + obs_params = c("cmax", "tmax", "tlast"), + max_iter = 50L) { + .find_upstream_deps(start_set, dependency_map, obs_params, max_iter) +} diff --git a/tests/testthat/test-intervals.R b/tests/testthat/test-intervals.R index 3c37a3297..0d44c63f9 100644 --- a/tests/testthat/test-intervals.R +++ b/tests/testthat/test-intervals.R @@ -507,6 +507,50 @@ describe(".find_upstream_deps", { }) }) +describe(".find_downstream_consumers", { + it("finds AUC consumers like vss.obs/vz.obs via the dependency map (#1057)", { + metadata <- data.frame( + PKNCA = c("aucinf.obs", "cl.obs", "mrt.obs", "vss.obs", "vz.obs", + "cmax", "cmax.dn"), + Depends = c("auclast, clast.obs, lambda.z", "aucinf.obs", + "aucinf.obs, aumcinf.obs", "cl.obs, mrt.obs", + "cl.obs, lambda.z", NA, "cmax"), + stringsAsFactors = FALSE + ) + dependency_map <- .build_dependency_map(metadata) + start_set <- c("aucinf.obs", "cl.obs", "mrt.obs", "lambda.z") + result <- .find_downstream_consumers(start_set, dependency_map) + expect_true("vss.obs" %in% result) + expect_true("vz.obs" %in% result) + # cmax.dn only depends on cmax, which is not in the chain → not added + expect_false("cmax" %in% result) + expect_false("cmax.dn" %in% result) + }) + + it("excludes observational leaf params even if they depend on the chain", { + metadata <- data.frame( + PKNCA = c("auclast", "cmax"), + Depends = c(NA, "auclast"), + stringsAsFactors = FALSE + ) + dependency_map <- .build_dependency_map(metadata) + result <- .find_downstream_consumers(c("auclast"), dependency_map) + expect_false("cmax" %in% result) + }) + + it("walks transitive downstream chains", { + metadata <- data.frame( + PKNCA = c("cl.obs", "vss.obs", "vss.dn"), + Depends = c("aucinf.obs", "cl.obs", "vss.obs"), + stringsAsFactors = FALSE + ) + dependency_map <- .build_dependency_map(metadata) + result <- .find_downstream_consumers(c("cl.obs"), dependency_map) + expect_true("vss.obs" %in% result) + expect_true("vss.dn" %in% result) + }) +}) + describe("rm_impute_obs_params integration", { it("retains imputation for half-life chain and removes it for observational params (#1057)", { data <- FIXTURE_PKNCA_DATA @@ -604,4 +648,55 @@ describe("BLQ imputation end-to-end on real bug dataset (#1057)", { expect_equal(get_pp("lambda.z"), 0.32248, tolerance = 1e-4) expect_equal(get_pp("half.life"), 2.1494, tolerance = 1e-4) }) + + it("keeps imputation for downstream AUC consumers (vss.obs, vz.obs) and stays warning-free", { + # vss.obs/vz.obs have no "auc" in name or Depends, but compute AUC + # internally via cl.obs/mrt.obs/lambda.z. Stripping their imputation + # makes PKNCA warn "AUC range starting before the first measurement" + # and computes them on non-imputed data (#1057 downstream regression). + default_like_params <- c( + "aucinf.obs", "auclast", "cmax", "clast.obs", "tlast", "tmax", + "half.life", "cl.obs", "vss.obs", "vz.obs", "mrt.last", "mrt.obs", + "lambda.z", "r.squared", "span.ratio", "adj.r.squared" + ) + default_selections <- setNames( + lapply(study_types, function(x) default_like_params), + study_types + ) + default_data <- PKNCA_update_data_object( + adnca_data = PKNCA_create_data_object(blq_adnca), + method = "lin up/log down", + selected_analytes = "DrugA", + selected_profile = "DOSE 1", + selected_pcspec = "SERUM", + start_impute = TRUE, + parameter_selections = default_selections, + blq_imputation_rule = blq_rule + ) + + # Downstream consumers stay in the imputed row, cmax stays stripped + vss_rows <- default_data$intervals[default_data$intervals$vss.obs, ] + expect_true(all(grepl("blq", vss_rows$impute))) + cmax_rows <- default_data$intervals[default_data$intervals$cmax, ] + expect_true(all(is.na(cmax_rows$impute) | cmax_rows$impute == "")) + + # No "AUC range starting before the first measurement" warning + run_warnings <- character() + default_results <- withCallingHandlers( + PKNCA_calculate_nca(default_data, blq_rule = blq_rule), + warning = function(w) { + run_warnings <<- c(run_warnings, conditionMessage(w)) + invokeRestart("muffleWarning") + } + ) + expect_false(any(grepl("before the first measurement", run_warnings))) + + # Consumers are consistent with the imputed AUC chain values + default_df <- as.data.frame(default_results$result) + get_dp <- function(pp) default_df$PPORRES[default_df$PPTESTCD == pp] + expect_equal(get_dp("vz.obs"), get_dp("cl.obs") / get_dp("lambda.z"), + tolerance = 1e-6) + expect_equal(get_dp("vss.obs"), get_dp("cl.obs") * get_dp("mrt.obs"), + tolerance = 1e-6) + }) })