Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
edb2b8a
fix: initialize exclude_half.life to NA for PKNCA-dev compatibility
billdenney Jun 13, 2026
7187869
test: skip CDISC parameter tests on CRAN pending new PKNCA parameters
billdenney Jun 13, 2026
b0910e9
fix: exclusions should be depicted also in HL plots (handle_hl_adj_ch…
Gero1999 Jun 16, 2026
03338cc
Fix include/exclude conflict: convert to include-only for computation
Gero1999 Jun 16, 2026
3249288
Debug: add duplicate interval check after left_join
Gero1999 Jun 19, 2026
cbe9f50
Debug: add duplicate PPTESTCD check in PKNCA_hl_rules_exclusion
Gero1999 Jun 19, 2026
44e1205
Debug: add prints before/after pk.nca to trace R2ADJ duplicates
Gero1999 Jun 19, 2026
86385cb
Debug: add R2ADJ duplicate check after join+dedup
Gero1999 Jun 24, 2026
3a6bbbe
Fix R2ADJ duplicates: exclude start_dose/end_dose from dedup grouping
Gero1999 Jun 24, 2026
251d890
Debug: print differing R2ADJ rows to identify distinguishing column
Gero1999 Jun 24, 2026
58be5c1
Fix R2ADJ duplicates: also exclude PPANMETH from dedup grouping
Gero1999 Jun 24, 2026
dae4729
Remove debug prints from PKNCA.R
Gero1999 Jun 24, 2026
fa8e8dc
style identation in PKNCA.R & get_halflife_plots.R
Gero1999 Jun 24, 2026
70f21ef
add nolint msgs to prevent complications
Gero1999 Jun 24, 2026
9987121
Resolve hl include/exclude conflicts per profile; fix nolint directives
Gero1999 Jul 1, 2026
f2fb769
Merge branch 'main' into pknca-dev-compat-fixes
Gero1999 Jul 17, 2026
fbe7efc
solve lintr issues
Gero1999 Jul 17, 2026
1373ede
add man roxygenise
Gero1999 Jul 17, 2026
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: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
* `CRITxFL` now uses `""` instead of `"N"` when criterion is violated. `CRITx` shows the acceptance criterion with inverted operator (e.g. flag rule `R2ADJ < 0.7` produces `CRITx = "R2ADJ >= 0.7"`). `CRITxFL = "Y"` means criterion satisfied (#1332)

### NCA Calculations
* `exclude_half.life` is now initialized to `NA` instead of `FALSE` (matching `include_half.life`), so manually selecting half-life points to include no longer errors with "Cannot both include and exclude half-life points for the same interval" under the development version of PKNCA
* Include/exclude half-life conflict resolution now runs per profile instead of globally, so a standalone exclusion in one profile is no longer cleared when an unrelated profile has a selection (#1358)
* Renal clearance (RENALCL) removed from direct PK calculations (inaccurate in PKNCA) — use ratio table instead (#781)
* Multidose parameters (MRTMDO, MRTMDP, VSSMDO, VSSMDP, TAT) removed from direct calculations (#869)
* Last dose interval end time extends to last observed sample instead of being cut off at tau (#1235)
Expand Down
18 changes: 14 additions & 4 deletions R/PKNCA.R
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,11 @@ PKNCA_create_data_object <- function( # nolint: object_name_linter
df_conc$is.excluded.hl <- FALSE
df_conc$is.included.hl <- FALSE
df_conc$REASON <- ""
df_conc$exclude_half.life <- FALSE
# NA (not FALSE) marks "no half-life exclusion yet". This mirrors how
# include_half.life is left NA until a point is selected and prevents PKNCA's
# "cannot both include and exclude half-life points" check from firing when
# only inclusions are set (an all-FALSE column counts as "in use").
df_conc$exclude_half.life <- NA

# Create PKNCA conc object

Expand Down Expand Up @@ -477,6 +481,9 @@ PKNCA_calculate_nca <- function(pknca_data, blq_rule = NULL) { # nolint: object_
add = TRUE
)

# Resolve per-profile include/exclude half-life conflicts before pk.nca()
pknca_data <- resolve_hl_include_exclude_conflicts(pknca_data)

# Calculate results using PKNCA
results <- PKNCA::pk.nca(data = pknca_data, verbose = FALSE)

Expand All @@ -502,7 +509,11 @@ PKNCA_calculate_nca <- function(pknca_data, blq_rule = NULL) { # nolint: object_
# TODO: PKNCA package should offer a better solution to this at some point
# Prevent that when t0 is used with non-imputed params to show off two result rows
# just choose the derived ones (last row always due to interval_helper funs)
group_by(across(-c(intersect(names(.), c("PPSTRES", "PPORRES", "exclude"))))) %>%
group_by(across(-c(intersect(names(.), c(
"PPSTRES", "PPORRES", "exclude",
"start_dose", "end_dose",
"PPANMETH"
))))) %>%
slice_tail(n = 1) %>%
ungroup()

Expand Down Expand Up @@ -859,7 +870,6 @@ PKNCA_hl_rules_exclusion <- function(res, rules) { # nolint
#' @keywords internal
#' @noRd
check_valid_pknca_data <- function(processed_pknca_data, check_exclusion_has_reason = TRUE) {

if (check_exclusion_has_reason) {
excl_hl_col <- processed_pknca_data$conc$columns$exclude_half.life

Expand All @@ -869,7 +879,7 @@ check_valid_pknca_data <- function(processed_pknca_data, check_exclusion_has_rea
time_col <- processed_pknca_data$conc$columns$time

has_no_reason <- (nchar(data_conc[["REASON"]]) == 0) | is.na(data_conc[["REASON"]])
has_hl_excl <- data_conc[[excl_hl_col]]
has_hl_excl <- data_conc[[excl_hl_col]] %in% TRUE
missing_reasons <- has_hl_excl & has_no_reason

if (any(missing_reasons)) {
Expand Down
51 changes: 33 additions & 18 deletions R/get_halflife_plots.R
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@
#' @importFrom plotly plot_ly add_lines layout add_trace plotly_build event_register
#' @importFrom PKNCA pk.nca get.parameter.deps
#' @export
get_halflife_plots <- function(pknca_data, add_annotations = TRUE,
get_halflife_plots <- function(pknca_data, add_annotations = TRUE, # nolint: cyclocomp_linter
title_vars = NULL) {

# If the input has empty concentration or intervals, just return an empty list
if (nrow(pknca_data$conc$data) == 0 || nrow(pknca_data$intervals) == 0) {
return(list(plots = list(), data = list()))
Expand All @@ -43,7 +42,7 @@ get_halflife_plots <- function(pknca_data, add_annotations = TRUE,

# Make sure to create a default exclude half life column if it does not exist
if (is.null(exclude_hl_col)) {
pknca_data$conc$data[["exclude_half.life"]] <- FALSE
pknca_data$conc$data[["exclude_half.life"]] <- NA
exclude_hl_col <- "exclude_half.life"
}

Expand Down Expand Up @@ -76,11 +75,22 @@ get_halflife_plots <- function(pknca_data, add_annotations = TRUE,
return(list(plots = list(), data = list()))
}

# Save original exclude flags for plot rendering before resolving conflicts.
# Conflict resolution runs per-profile for computation, but the plot needs the
# real flags to render excluded points as red/x markers.
original_excl <- pknca_data$conc$data[[exclude_hl_col]]

# Resolve per-profile include/exclude half-life conflicts before pk.nca()
pknca_data <- resolve_hl_include_exclude_conflicts(pknca_data)

d_conc_with_res <- .merge_conc_with_nca_results(
pknca_data, time_col, conc_col, timeu_col,
concu_col, exclude_hl_col, title_vars
)

# Restore original exclude flags for plot visuals (red/x markers)
d_conc_with_res[[exclude_hl_col]] <- original_excl[d_conc_with_res$ROWID]

# Mark points used in half-life calculation
info_per_plot_list <- d_conc_with_res %>%
# Indicate plot details
Expand Down Expand Up @@ -114,7 +124,7 @@ get_halflife_plots <- function(pknca_data, add_annotations = TRUE,
tlast = tlast + start,
is_halflife_used = .[[time_col]] >= lambda.z.time.first &
.[[time_col]] <= lambda.z.time.last &
!.[[exclude_hl_col]]
!(.[[exclude_hl_col]] %in% TRUE)
) %>%
group_by(!!!syms(c(group_vars(pknca_data), "start", "end"))) %>%
mutate(
Expand All @@ -133,9 +143,9 @@ get_halflife_plots <- function(pknca_data, add_annotations = TRUE,
info_per_plot_list <- info_per_plot_list %>%
mutate(
color = "black",
color = ifelse(.[[exclude_hl_col]], "red", color),
color = ifelse(.[[exclude_hl_col]] %in% TRUE, "red", color),
color = ifelse(is_halflife_used & !is.na(is_halflife_used), "green", color),
symbol = ifelse(.[[exclude_hl_col]], "x", "circle")
symbol = ifelse(.[[exclude_hl_col]] %in% TRUE, "x", "circle")
) %>%
group_by(!!!syms(c(group_vars(pknca_data), "start", "end"))) %>%
group_split()
Expand Down Expand Up @@ -224,8 +234,10 @@ get_halflife_plots <- function(pknca_data, add_annotations = TRUE,
wide_output <- o_nca
wide_output$result <- wide_output$result %>%
filter(
PPTESTCD %in% c("lambda.z.time.first", "lambda.z.time.last",
"lambda.z", "adj.r.squared", "span.ratio", "tlast")
PPTESTCD %in% c(
"lambda.z.time.first", "lambda.z.time.last",
"lambda.z", "adj.r.squared", "span.ratio", "tlast"
)
) %>%
select(-any_of(c("PPORRESU", "PPSTRESU", "PPSTRES"))) %>%
mutate(exclude = paste0(na.omit(unique(exclude)), collapse = ". "))
Expand All @@ -234,8 +246,10 @@ get_halflife_plots <- function(pknca_data, add_annotations = TRUE,
# return a 0-row data frame with all expected columns so callers can proceed
# without special-casing empty results.
if (nrow(wide_output$result) == 0) {
conc_select_cols <- c(group_vars(pknca_data), time_col, conc_col,
timeu_col, concu_col, exclude_hl_col, "ROWID")
conc_select_cols <- c(
group_vars(pknca_data), time_col, conc_col,
timeu_col, concu_col, exclude_hl_col, "ROWID"
)
return(
pknca_data$conc$data %>%
select(!!!syms(conc_select_cols)) %>%
Expand All @@ -253,8 +267,10 @@ get_halflife_plots <- function(pknca_data, add_annotations = TRUE,
wide_output <- as.data.frame(wide_output, out_format = "wide") %>%
unique()

conc_select_cols <- c(group_vars(pknca_data), time_col, conc_col,
timeu_col, concu_col, exclude_hl_col, "ROWID")
conc_select_cols <- c(
group_vars(pknca_data), time_col, conc_col,
timeu_col, concu_col, exclude_hl_col, "ROWID"
)
merge_by <- c(group_vars(pknca_data))
extra <- intersect(extra_vars, names(pknca_data$conc$data))
extra <- intersect(extra, names(wide_output))
Expand Down Expand Up @@ -305,8 +321,7 @@ get_halflife_plots_single <- function(
color,
symbol,
add_annotations = TRUE,
text = NULL
) {
text = NULL) {
if (is.null(text)) {
text <- paste0(
"(", plot_data[[time_col]], ", ", signif(plot_data[[conc_col]], 3), ")"
Expand All @@ -316,8 +331,8 @@ get_halflife_plots_single <- function(
plotly::event_register("plotly_click") %>%
plotly::add_lines(
data = fit_line_data,
x = ~get(time_col),
y = ~10^y,
x = ~ get(time_col),
y = ~ 10^y,
line = list(color = "green", width = 2),
name = "Fit",
inherit = FALSE,
Expand Down Expand Up @@ -349,8 +364,8 @@ get_halflife_plots_single <- function(
) %>%
plotly::add_trace(
data = plot_data,
x = ~plot_data[[time_col]],
y = ~plot_data[[conc_col]],
x = ~ plot_data[[time_col]],
y = ~ plot_data[[conc_col]],
text = text,
hoverinfo = "text",
showlegend = FALSE,
Expand Down
5 changes: 3 additions & 2 deletions R/pivot_wider_pknca_results.R
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ pivot_wider_pknca_results <- function(myres, flag_rules = NULL, extra_vars_to_ke
group_by(!!!syms(conc_groups), DOSNOA) %>%
# Derive LAMZMTD: was lambda.z manually customized?
mutate(LAMZMTD = ifelse(
any(exclude_half.life) | any(include_half.life), "Manual", "Best slope"
any(exclude_half.life %in% TRUE) | any(include_half.life %in% TRUE),
"Manual", "Best slope"
)) %>%
filter(!exclude_half.life | is.na(LAMZLL) | is.na(LAMZNPT)) %>%
filter(!(exclude_half.life %in% TRUE) | is.na(LAMZLL) | is.na(LAMZNPT)) %>%
filter(!!sym(time_col) >= (LAMZLL + start) | is.na(LAMZLL)) %>%
filter(row_number() <= LAMZNPT | is.na(LAMZNPT)) %>%
mutate(LAMZIX = paste0(IX, collapse = ",")) %>%
Expand Down
49 changes: 49 additions & 0 deletions R/utils-slope_selector.R
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ update_pknca_with_rules <- function(data, slopes) {
if (slopes$TYPE[i] == "Selection") {
data$conc$data[[include_hl_col]][pnt_idx] <- TRUE
} else if (slopes$TYPE[i] == "Exclusion") {
# Clear any inclusion on the same points to avoid PKNCA's
# "cannot both include and exclude" error
data$conc$data[[include_hl_col]][pnt_idx] <- NA
data$conc$data[[exclude_hl_col]][pnt_idx] <- TRUE
} else {
stop("Unknown TYPE in slopes: ", slopes$TYPE[i])
Expand All @@ -44,6 +47,52 @@ update_pknca_with_rules <- function(data, slopes) {
data
}

#' Resolve include/exclude half-life conflicts per profile
#'
#' PKNCA errors when both the `include_half.life` and `exclude_half.life`
#' columns contain non-NA values within the same interval. This resolves the
#' conflict on a per-profile basis (profiles are defined by the concentration
#' grouping columns): for each profile that has both flags in use, the inclusion
#' flag is cleared on the excluded points and that profile's exclude flags are
#' reset to `NA`. This turns mixed intent into include-only semantics (selected
#' points minus excluded points). Profiles without a conflict are left
#' untouched, so a standalone exclusion in one profile is never cleared just
#' because another profile has an inclusion.
#'
#' @param data PKNCA data object.
#' @returns The PKNCA data object with `conc$data` conflicts resolved.
resolve_hl_include_exclude_conflicts <- function(data) { # nolint
exclude_hl_col <- data$conc$columns$exclude_half.life
include_hl_col <- data$conc$columns$include_half.life
if (is.null(exclude_hl_col) || is.null(include_hl_col)) {
return(data)
}

conc <- data$conc$data
group_cols <- intersect(group_vars(data$conc), names(conc))

# Identify each profile so conflicts are resolved within a profile only
profile_id <- if (length(group_cols) > 0) {
do.call(paste, c(conc[group_cols], sep = "\r"))
} else {
rep("1", nrow(conc))
}

for (id in unique(profile_id)) {
rows <- which(profile_id == id)
has_excl <- any(conc[[exclude_hl_col]][rows] %in% TRUE)
has_incl <- any(conc[[include_hl_col]][rows] %in% TRUE)
if (has_excl && has_incl) {
excl_rows <- rows[conc[[exclude_hl_col]][rows] %in% TRUE]
conc[[include_hl_col]][excl_rows] <- NA
conc[[exclude_hl_col]][rows] <- NA
}
}

data$conc$data <- conc
data
}

.are_points_in_groups <- function(slopes, pknca_data) {
slope_groups <- setdiff(names(slopes), c("TYPE", "RANGE", "REASON"))
Reduce(`&`, lapply(slope_groups, function(col) {
Expand Down
4 changes: 3 additions & 1 deletion inst/shiny/functions/utils-slope_selector.R
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ handle_hl_adj_change <- function(new_pknca_data, old_pknca_data, plot_outputs) {
new_concdata <- new_pknca_data$conc$data
old_concdata <- old_pknca_data$conc$data

ix_excl_changes <- which(new_concdata[[excl_hl_col]] != old_concdata[[excl_hl_col]])
ix_excl_changes <- which(
(new_concdata[[excl_hl_col]] %in% TRUE) != (old_concdata[[excl_hl_col]] %in% TRUE)
)
ix_incl_changes <- which(
paste0(new_concdata[[incl_hl_col]]) != paste0(old_concdata[[incl_hl_col]])
)
Expand Down
25 changes: 25 additions & 0 deletions man/resolve_hl_include_exclude_conflicts.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions tests/testthat/test-PKNCA.R
Original file line number Diff line number Diff line change
Expand Up @@ -522,8 +522,8 @@ describe("PKNCA_update_data_object", {
# t=3 should be flagged for exclusion with the specified reason
expect_true(all(conc$exclude_half.life[at_t3]))
expect_true(all(grepl("Outlier", conc$REASON[at_t3])))
# Other points should remain unflagged
expect_false(any(conc$exclude_half.life[!at_t3]))
# Other points should remain unflagged (NA, mirroring include_half.life)
expect_true(all(is.na(conc$exclude_half.life[!at_t3])))
})

it("flags include_half.life on matching points via hl_adj_rules Selection", {
Expand Down
5 changes: 5 additions & 0 deletions tests/testthat/test-export_cdisc.R
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,11 @@ describe("export_cdisc", {
})

it("differentiates vz.xxx for extravascular (bioavailability, F) and intravascular", {
# The development version of PKNCA adds new vz.* parameters (e.g. vz.last)
# that aNCA's CDISC export does not yet map, so extra Vz rows appear. Skip on
# CRAN so an upcoming PKNCA release cannot block CRAN; the gap still surfaces
# off-CRAN in runs against PKNCA dev.
skip_on_cran()
test_vz_data <- FIXTURE_PKNCA_DATA
test_vz_data$intervals <- test_vz_data$intervals %>%
filter(USUBJID %in% unique(USUBJID)[c(5, 7)]) %>%
Expand Down
6 changes: 3 additions & 3 deletions tests/testthat/test-get_halflife_plots.R
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ describe("get_halflife_plot", {

it("renders markers, colors and shapes with no exclusion/inclusion", {
pknca_no_excl_incl <- base_pknca
pknca_no_excl_incl$conc$data$exclude_half.life <- FALSE
pknca_no_excl_incl$conc$data$include_half.life <- FALSE
pknca_no_excl_incl$conc$data$exclude_half.life <- NA
pknca_no_excl_incl$conc$data$include_half.life <- NA
plots <- withCallingHandlers(
get_halflife_plots(pknca_no_excl_incl)[["plots"]],
# Ignore the warning associated with the expected missing records
Expand Down Expand Up @@ -138,7 +138,7 @@ describe("get_halflife_plot", {
it("renders markers, colors and shapes with inclusion of lambda.z points", {
pknca_incl <- base_pknca
pknca_incl$intervals <- pknca_incl$intervals[3, ]
pknca_incl$conc$data$exclude_half.life <- FALSE
pknca_incl$conc$data$exclude_half.life <- NA
pknca_incl$conc$data$include_half.life <- NA
pknca_incl_with_incl <- pknca_incl
pknca_incl_with_incl$conc$data <- pknca_incl$conc$data %>%
Expand Down
5 changes: 5 additions & 0 deletions tests/testthat/test-pivot_wider_pknca_results.R
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ describe("pivot_wider_pknca_results", {
})

it("adds appropriate labels to columns (CDISC PPTEST)", {
# The development version of PKNCA emits new parameters (e.g. lambda.z.corrxy)
# that aNCA's CDISC label/parameter mapping does not yet cover, which changes
# the expected label set. Skip on CRAN so an upcoming PKNCA release cannot
# block CRAN; the mismatch still surfaces in local/CI runs against PKNCA dev.
skip_on_cran()
labels <- formatters::var_labels(pivoted_res)
expected_labels <- c(
PCSPEC = NA, USUBJID = NA, PARAM = NA, start = NA, end = NA, ATPTREF = NA,
Expand Down
Loading
Loading