Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ inst/shiny/log/*
.DS_Store
# {shinytest2}: Ignore new debug snapshots for `$expect_values()`
*_.new.png
desktop.ini
desktop.ini
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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")),
Expand Down
3 changes: 3 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 63 additions & 5 deletions R/intervals.R
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 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(
!grepl("auc|aumc", PKNCA),
!grepl(paste0(params_auc_dep, collapse = "|"), Depends)
) %>%
filter(!PKNCA %in% needs_impute) %>%
pull(PKNCA) %>%
intersect(names(PKNCA::get.interval.cols()))

Expand Down Expand Up @@ -367,3 +377,51 @@ rm_impute_obs_params <- function(data, metadata_nca_parameters = metadata_nca_pa

data
}

#' 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_consumer_map <- 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
}

#' 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
.find_upstream_deps <- function(start_set, consumer_map,
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)) {
newly_found <- c(newly_found, pkg)
}
}
if (length(newly_found) == 0) break
needs <- c(needs, newly_found)
}
needs
}
77 changes: 77 additions & 0 deletions tests/testthat/test-intervals.R
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,80 @@ describe("rm_impute_obs_params", {
expect_equal(result$intervals, intervals_before)
})
})

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_consumer_map(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_consumer_map(metadata)
expect_type(result, "list")
})
})

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
)
consumer_map <- .build_consumer_map(metadata)
start_set <- c("aucinf.obs")
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)
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
)
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
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 retain imputation (it feeds aucinf.obs via lambda.z)
expect_true("blq" %in% result$intervals$impute)
})
})