From 7a0fd29d54c5c398b821f610535639a5c51f5ffc Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Sun, 9 Aug 2026 11:44:02 -0400 Subject: [PATCH 1/3] fix: superposition converges instead of hanging when tlast < tau with auc.type AUClast With auc.type="AUClast" (or "AUCall"), concentrations after tlast extrapolate as exactly zero, so when tlast < tau the superposed profile keeps structural zeros (for example at time 0 and tau) at steady-state. The zero-concentration guard in the steady-state loop reset the convergence tolerance whenever any concentration was zero, on the assumption that all values eventually become nonzero, so superposition() with the default n.tau=Inf looped forever (issue 580). Two of the twelve Theoph subjects have tlast < 24, so a superposition(conc_obj, tau=24, auc.type="AUClast") call on that dataset hung. Now, once a completed dosing interval draws all of its concentrations from times after tlast, any remaining zero concentration can never become nonzero, so steady-state is assessed on the nonzero concentrations only, and a warning explains that zero concentrations remain in the steady-state profile. Zeros that can still accumulate from later dosing intervals keep the previous guard behavior, so all previously-converging cases are unchanged (verified byte-identical before and after the fix for AUCinf steady-state on all Theoph subjects, finite-n.tau AUClast, and the documented multi-dose cases). A generous 10000-interval backstop now raises an informative error instead of looping forever if steady-state cannot be reached. Co-Authored-By: Claude Fable 5 --- NEWS.md | 8 +++ R/superposition.R | 43 +++++++++++-- man/superposition.Rd | 6 ++ tests/testthat/test-superpostion.R | 97 ++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index 563fa773..186c652e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,14 @@ the dosing including dose amount and route. # Development version +* Bug fix: `superposition()` no longer loops forever when steady-state is + requested (the default `n.tau=Inf`) and concentrations are extrapolated as + zero after `tlast` (for example, `auc.type="AUClast"` when `tlast < tau`). + Steady-state is now assessed on the concentrations that can become nonzero, + a warning is given when zero concentrations remain in the steady-state + profile, and an informative error (instead of an infinite loop) is raised if + steady-state cannot be reached within 10000 dosing intervals (#580). + * Bug fix: `pk.nca()` no longer errors on unsorted concentration-time data. Group-level concentration data are now sorted by time before calculation, so parameters that use the full group (e.g. `aucint.all` and the other `aucint*` diff --git a/R/superposition.R b/R/superposition.R index f1084957..8b46c849 100644 --- a/R/superposition.R +++ b/R/superposition.R @@ -35,6 +35,12 @@ #' times: 0 (zero), `dose.times`, `time modulo tau` (shifting `time` for each #' dose time as well), `additional.times`, and `tau`. #' +#' When concentrations are extrapolated as zero after `tlast` (for example, +#' `auc.type = "AUClast"` when `tlast < tau`), some concentrations in the +#' profile can never become nonzero. Steady-state (`n.tau = Inf`) is then +#' assessed on the nonzero concentrations, and a warning is given that zero +#' concentrations remain in the steady-state profile. +#' #' @seealso [interp.extrap.conc()] #' @family Superposition #' @export @@ -202,10 +208,22 @@ superposition.numeric <- function(conc, time, dose.input = NULL, # Do the math! (Finally) current.tol <- steady.state.tol + 1 tau.count <- 0 + # Generous backstop so that a case that cannot reach steady-state errors + # instead of looping forever (normal convergence takes far fewer + # intervals; the default steady.state.tol needs at most ~1000 even for + # negligible accumulation decay). + max.tau.count <- 10000L # Stop either for reaching steady-state or for reaching the requested number of doses while (tau.count < n.tau & !is.na(current.tol) & current.tol >= steady.state.tol) { + if (is.infinite(n.tau) && tau.count >= max.tau.count) { + stop( + "Superposition did not reach steady-state within ", max.tau.count, + " dosing intervals (steady.state.tol=", steady.state.tol, + "). Check the lambda.z, tau, and steady.state.tol inputs, or use a finite n.tau." + ) + } prev.conc <- ret$conc # Perform the dosing for a single dosing interval. for (i in seq_along(dose.times)) { @@ -228,14 +246,29 @@ superposition.numeric <- function(conc, time, dose.input = NULL, ) } tau.count <- tau.count + 1 - if (any(ret$conc %in% 0)) { - # prevent division by 0. Since not all concentrations are 0, - # all values will eventually be nonzero. - current.tol <- steady.state.tol + 1 - } else { + zero.conc <- ret$conc %in% 0 + if (!any(zero.conc)) { current.tol <- max(1-(prev.conc/ret$conc)) + } else if ((tau*(tau.count - 1) - max(dose.times)) > tlast) { + # The dosing interval just added drew all of its concentrations from + # times after tlast, where extrapolation may be zero (e.g. with + # auc.type="AUClast"). A concentration that is still zero can + # therefore never become nonzero (a structural zero), so assess + # steady-state on the nonzero concentrations only. + current.tol <- max(1-(prev.conc[!zero.conc]/ret$conc[!zero.conc])) + } else { + # A zero concentration may still become nonzero from a later dosing + # interval; prevent division by 0 and do not yet assess steady-state. + current.tol <- steady.state.tol + 1 } } + if (!is.na(current.tol) && current.tol < steady.state.tol && any(ret$conc %in% 0)) { + warning( + "Zero concentrations remain in the steady-state superposition profile. ", + "They come from concentrations extrapolated as zero after tlast (", + signif(tlast, 6), ") with auc.type=", dQuote(auc.type, q = FALSE), "." + ) + } } ret } diff --git a/man/superposition.Rd b/man/superposition.Rd index cf427bcc..6c59aa28 100644 --- a/man/superposition.Rd +++ b/man/superposition.Rd @@ -93,6 +93,12 @@ Compute noncompartmental superposition for repeated dosing The returned superposition times will include all of the following times: 0 (zero), \code{dose.times}, \verb{time modulo tau} (shifting \code{time} for each dose time as well), \code{additional.times}, and \code{tau}. + +When concentrations are extrapolated as zero after \code{tlast} (for example, +\code{auc.type = "AUClast"} when \code{tlast < tau}), some concentrations in the +profile can never become nonzero. Steady-state (\code{n.tau = Inf}) is then +assessed on the nonzero concentrations, and a warning is given that zero +concentrations remain in the steady-state profile. } \seealso{ \code{\link[=interp.extrap.conc]{interp.extrap.conc()}} diff --git a/tests/testthat/test-superpostion.R b/tests/testthat/test-superpostion.R index 15eb85b1..23a8bdaf 100644 --- a/tests/testthat/test-superpostion.R +++ b/tests/testthat/test-superpostion.R @@ -512,3 +512,100 @@ test_that("PKNCAconc superposition", { ) ) }) + +test_that("superposition reaches steady-state despite structural zero concentrations (issue 580)", { + # Theoph subjects 6 and 10 have tlast < tau=24, so with auc.type="AUClast" + # the concentrations after tlast extrapolate as exactly zero and stay zero at + # steady-state. This formerly looped forever with n.tau=Inf because the + # zero-concentration guard reset the steady-state tolerance every interval. + d_theoph_6 <- datasets::Theoph[datasets::Theoph$Subject == 6, ] + expect_warning( + v_ss <- + superposition( + conc=d_theoph_6$conc, time=d_theoph_6$Time, tau=24, auc.type="AUClast" + ), + regexp='Zero concentrations remain in the steady-state superposition profile. They come from concentrations extrapolated as zero after tlast (23.85) with auc.type="AUClast".', + fixed=TRUE + ) + # No accumulation is possible because every concentration one or more + # dosing intervals after dosing is zero, so the steady-state profile is + # exactly the single-dose profile with structural zeros at times 0 and tau. + expect_identical( + v_ss, + data.frame(conc=c(d_theoph_6$conc, 0), time=c(d_theoph_6$Time, 24)) + ) + # A finite n.tau with the same input gave the same concentrations before the + # fix (all added dosing intervals contribute zero); that is preserved, and + # partial accumulation with a finite n.tau does not warn. + expect_silent( + v_1 <- + superposition( + conc=d_theoph_6$conc, time=d_theoph_6$Time, tau=24, n.tau=1, auc.type="AUClast" + ) + ) + expect_identical(v_ss, v_1) + + # The default auc.type="AUCinf" on the same subject is unaffected by the fix: + # all concentrations become positive and steady-state is reached silently + # (values are pinned from before the fix). + expect_silent( + v_aucinf <- superposition(conc=d_theoph_6$conc, time=d_theoph_6$Time, tau=24) + ) + expect_equal( + v_aucinf, + data.frame( + conc=c(1.03361737415698, 2.29940375348928, 4.06230162309106, + 7.37435349523853, 7.18488330467035, 6.28550707796221, + 5.60636744789876, 4.57905606419521, 3.92005369868033, + 3.13726988893256, 1.04734393070531, 1.03364150451656), + time=c(0, 0.27, 0.58, 1.15, 2.03, 3.57, 5, 7, 9.22, 12.1, 23.85, 24) + ) + ) + + # AUClast with tlast > tau converges without a warning: the time-zero + # concentration is zero after the first dosing interval, becomes nonzero on + # the second, and no zeros remain at steady-state. + d_theoph_1 <- datasets::Theoph[datasets::Theoph$Subject == 1, ] + expect_silent( + v_s1 <- + superposition( + conc=d_theoph_1$conc, time=d_theoph_1$Time, tau=24, auc.type="AUClast", + check.blq=FALSE + ) + ) + expect_true(all(v_s1$conc > 0)) + + # The PKNCAconc method (the form reported in issue 580) returns with one + # warning per affected subject (check.blq=FALSE because subject 10 has a + # nonzero concentration at time 0) + d_theoph_6_10 <- + datasets::Theoph[datasets::Theoph$Subject %in% c(6, 10), ] + conc_obj <- PKNCAconc(conc~Time|Subject, data=d_theoph_6_10) + w_obj <- + capture_warnings( + v_obj <- superposition(conc_obj, tau=24, auc.type="AUClast", check.blq=FALSE) + ) + expect_length(w_obj, 2) + expect_match( + w_obj, + regexp="Zero concentrations remain in the steady-state superposition profile", + fixed=TRUE, + all=TRUE + ) + expect_true(all(is.finite(v_obj$conc))) +}) + +test_that("superposition errors instead of hanging when steady-state cannot be reached", { + # A negligible lambda.z makes each added dosing interval contribute ~clast, + # so the relative change shrinks only like 1/n.tau and steady.state.tol=1e-8 + # cannot be reached within the iteration cap. + expect_error( + superposition( + conc=c(0, 1, 0.5), time=0:2, tau=1, + lambda.z=1e-8, clast.pred=0.5, tlast=2, + steady.state.tol=1e-8 + ), + regexp="Superposition did not reach steady-state within 10000 dosing intervals", + fixed=TRUE + ) +}) From 49408f12538fd8f0c4c176838d877dc5c4bbd79e Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Thu, 20 Aug 2026 13:18:01 -0400 Subject: [PATCH 2/3] fix: re-emit superposition worker warnings from the parent process superposition.PKNCAconc() runs each subject through parallel::mclapply(). That forks on every platform except Windows, and a warning raised inside a forked worker never reaches the parent, so the new "zero concentrations remain" warning was invisible to everyone not on Windows. CI showed this as macOS and all three Ubuntu jobs failing on the PKNCAconc expectation with zero warnings captured while windows-latest passed, because there mclapply() falls back to lapply(). The worker now muffles its warnings and returns the messages with the result; the parent re-emits them after collecting the results. The existing PKNCAconc test cannot detect this on Windows, since it passed there before the fix, so a test exercises the worker hand-off directly and runs identically on every platform. Co-Authored-By: Claude Opus 5 --- NEWS.md | 4 ++++ R/superposition.R | 23 +++++++++++++++++++++-- tests/testthat/test-superpostion.R | 23 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index cc51ad7c..14f1248f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -13,6 +13,10 @@ the dosing including dose amount and route. a warning is given when zero concentrations remain in the steady-state profile, and an informative error (instead of an infinite loop) is raised if steady-state cannot be reached within 10000 dosing intervals (#580). + The warning is raised by the parent process: `superposition()` on a + `PKNCAconc` object runs each subject through `parallel::mclapply()`, which + forks on every platform except Windows, and a warning raised in a forked + worker never reaches the caller. * Corrected registry description strings that did not match the implementation (#582). `adj.r.squared.factor` is described as selecting diff --git a/R/superposition.R b/R/superposition.R index c012a938..464ce3c6 100644 --- a/R/superposition.R +++ b/R/superposition.R @@ -50,6 +50,22 @@ superposition <- function(conc, ...) { #' @rdname superposition #' @export +# parallel::mclapply() forks on every platform except Windows, and a warning +# raised inside a forked worker never reaches the parent. Return the messages +# with the result so the caller can re-emit them. +superposition_capture_warnings <- function(conc, time, ...) { + messages <- character(0) + result <- + withCallingHandlers( + superposition.numeric(conc=conc, time=time, ...), + warning=function(w) { + messages <<- c(messages, conditionMessage(w)) + invokeRestart("muffleWarning") + } + ) + list(result=result, messages=messages) +} + superposition.PKNCAconc <- function(conc, ...) { # Split the data by grouping and extract just the concentration and # time columns @@ -58,15 +74,18 @@ superposition.PKNCAconc <- function(conc, ...) { parallel::mclapply( X=seq_len(nrow(nested_data)), FUN=function(idx) { - superposition.numeric( + superposition_capture_warnings( conc=nested_data$data_conc[[idx]]$conc, time=nested_data$data_conc[[idx]]$time, ... ) } ) + for (current_message in unlist(lapply(tmp_results, FUN="[[", "messages"))) { + warning(current_message, call.=FALSE) + } # Replace the concentration data with the new results - nested_data$data_conc <- tmp_results + nested_data$data_conc <- lapply(tmp_results, FUN="[[", "result") tidyr::unnest(nested_data, cols="data_conc") } diff --git a/tests/testthat/test-superpostion.R b/tests/testthat/test-superpostion.R index 645c102e..dc0381ea 100644 --- a/tests/testthat/test-superpostion.R +++ b/tests/testthat/test-superpostion.R @@ -611,3 +611,26 @@ test_that("superposition errors instead of hanging when steady-state cannot be r fixed=TRUE ) }) + +test_that("superposition warnings survive the parallel worker (issue 580)", { + # superposition.PKNCAconc() runs each subject through parallel::mclapply(), + # which forks everywhere except Windows. A warning raised in a forked worker + # never reaches the parent, so the worker returns its messages instead. This + # exercises that hand-off directly, which the PKNCAconc test above cannot do + # on Windows because there mclapply() falls back to lapply(). + d_theoph_6 <- datasets::Theoph[datasets::Theoph$Subject == 6, ] + captured <- + superposition_capture_warnings( + conc=d_theoph_6$conc, time=d_theoph_6$Time, + tau=24, auc.type="AUClast", check.blq=FALSE + ) + expect_length(captured$messages, 1) + expect_match( + captured$messages, + regexp="Zero concentrations remain in the steady-state superposition profile", + fixed=TRUE + ) + # The result is returned alongside the messages, not replaced by them. + expect_s3_class(captured$result, "data.frame") + expect_equal(nrow(captured$result), 12) +}) From 39e8be5d788d9787be93d4b6e542f046ae6e6e3e Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Thu, 20 Aug 2026 15:24:19 -0400 Subject: [PATCH 3/3] Use purrr::quietly and re-emit worker messages and output too Replaces the hand-written capture helper with purrr::quietly(), which already returns result/output/warnings/messages and is in Imports. Also re-emit the captured messages and printed output, not just the warnings. Nothing in the superposition call path emits either today, but quietly() captures all three, so a message() added to the calculation later would otherwise be discarded on every platform rather than only on the forking ones -- turning a platform-specific loss into a universal one. Messages keep their trailing newline, so they are re-emitted with appendLF=FALSE to avoid a doubled blank line; printed output comes back newline-stripped and is written with writeLines(). The gate test mocks superposition.numeric() so it produces a warning, a message, and printed output, then asserts all three reach the caller once per subject. Without a mock this cannot be tested, since the real calculation emits only the warning. Co-Authored-By: Claude Opus 5 --- NEWS.md | 9 +++-- R/superposition.R | 36 ++++++++--------- tests/testthat/test-superpostion.R | 63 ++++++++++++++++++++++-------- 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/NEWS.md b/NEWS.md index 14f1248f..62abd419 100644 --- a/NEWS.md +++ b/NEWS.md @@ -13,10 +13,11 @@ the dosing including dose amount and route. a warning is given when zero concentrations remain in the steady-state profile, and an informative error (instead of an infinite loop) is raised if steady-state cannot be reached within 10000 dosing intervals (#580). - The warning is raised by the parent process: `superposition()` on a - `PKNCAconc` object runs each subject through `parallel::mclapply()`, which - forks on every platform except Windows, and a warning raised in a forked - worker never reaches the caller. + Warnings, messages, and printed output from `superposition()` on a + `PKNCAconc` object are now raised by the parent process. Each subject runs + through `parallel::mclapply()`, which forks on every platform except + Windows, and a condition raised in a forked worker never reaches the + caller, so the new warning was previously invisible except on Windows. * Corrected registry description strings that did not match the implementation (#582). `adj.r.squared.factor` is described as selecting diff --git a/R/superposition.R b/R/superposition.R index 464ce3c6..134e3468 100644 --- a/R/superposition.R +++ b/R/superposition.R @@ -50,39 +50,39 @@ superposition <- function(conc, ...) { #' @rdname superposition #' @export -# parallel::mclapply() forks on every platform except Windows, and a warning -# raised inside a forked worker never reaches the parent. Return the messages -# with the result so the caller can re-emit them. -superposition_capture_warnings <- function(conc, time, ...) { - messages <- character(0) - result <- - withCallingHandlers( - superposition.numeric(conc=conc, time=time, ...), - warning=function(w) { - messages <<- c(messages, conditionMessage(w)) - invokeRestart("muffleWarning") - } - ) - list(result=result, messages=messages) -} - superposition.PKNCAconc <- function(conc, ...) { # Split the data by grouping and extract just the concentration and # time columns nested_data <- prepare_PKNCAconc(conc) + # parallel::mclapply() forks on every platform except Windows, and a + # condition raised in a forked worker never reaches the parent, so collect + # each worker's conditions with the result and re-emit them below. + # purrr::quietly() also captures messages and printed output, so those are + # re-emitted as well; otherwise adding a message() to the calculation later + # would silently discard it on every platform. + quiet_superposition <- purrr::quietly(superposition.numeric) tmp_results <- parallel::mclapply( X=seq_len(nrow(nested_data)), FUN=function(idx) { - superposition_capture_warnings( + quiet_superposition( conc=nested_data$data_conc[[idx]]$conc, time=nested_data$data_conc[[idx]]$time, ... ) } ) + for (current_warning in unlist(lapply(tmp_results, FUN="[[", "warnings"))) { + warning(current_warning, call.=FALSE) + } + # `messages` keep their trailing newline; `output` has it stripped. for (current_message in unlist(lapply(tmp_results, FUN="[[", "messages"))) { - warning(current_message, call.=FALSE) + message(current_message, appendLF=FALSE) + } + current_output <- unlist(lapply(tmp_results, FUN="[[", "output")) + current_output <- current_output[nzchar(current_output)] + if (length(current_output) > 0) { + writeLines(current_output) } # Replace the concentration data with the new results nested_data$data_conc <- lapply(tmp_results, FUN="[[", "result") diff --git a/tests/testthat/test-superpostion.R b/tests/testthat/test-superpostion.R index dc0381ea..e9313605 100644 --- a/tests/testthat/test-superpostion.R +++ b/tests/testthat/test-superpostion.R @@ -612,25 +612,56 @@ test_that("superposition errors instead of hanging when steady-state cannot be r ) }) -test_that("superposition warnings survive the parallel worker (issue 580)", { +test_that("superposition warns about structural zeros for a single profile (issue 580)", { # superposition.PKNCAconc() runs each subject through parallel::mclapply(), - # which forks everywhere except Windows. A warning raised in a forked worker - # never reaches the parent, so the worker returns its messages instead. This - # exercises that hand-off directly, which the PKNCAconc test above cannot do - # on Windows because there mclapply() falls back to lapply(). + # which forks everywhere except Windows, and a warning raised in a forked + # worker never reaches the parent; the method collects them with + # purrr::quietly() and re-emits them. The PKNCAconc test above cannot show + # that on Windows, where mclapply() falls back to lapply(), so pin the + # underlying warning here where it is raised. d_theoph_6 <- datasets::Theoph[datasets::Theoph$Subject == 6, ] - captured <- - superposition_capture_warnings( - conc=d_theoph_6$conc, time=d_theoph_6$Time, - tau=24, auc.type="AUClast", check.blq=FALSE - ) - expect_length(captured$messages, 1) - expect_match( - captured$messages, + expect_warning( + v_6 <- + superposition( + conc=d_theoph_6$conc, time=d_theoph_6$Time, + tau=24, auc.type="AUClast", check.blq=FALSE + ), regexp="Zero concentrations remain in the steady-state superposition profile", fixed=TRUE ) - # The result is returned alongside the messages, not replaced by them. - expect_s3_class(captured$result, "data.frame") - expect_equal(nrow(captured$result), 12) + expect_equal(nrow(v_6), 12) + expect_true(all(is.finite(v_6$conc))) +}) + +test_that("superposition.PKNCAconc re-emits everything its workers produce (issue 580)", { + # parallel::mclapply() forks everywhere except Windows, and conditions raised + # in a forked worker never reach the parent. purrr::quietly() collects the + # warnings, messages, and printed output so the method can re-emit them. + # Mock the calculation so all three are produced regardless of the data. + d_theoph_6_10 <- + datasets::Theoph[datasets::Theoph$Subject %in% c(6, 10), ] + conc_obj <- PKNCAconc(conc~Time|Subject, data=d_theoph_6_10) + local_mocked_bindings( + superposition.numeric=function(conc, time, ...) { + warning("mocked warning") + message("mocked message") + cat("mocked output", fill=TRUE) + data.frame(conc=c(1, 2), time=c(0, 1)) + } + ) + printed <- NULL + warned <- + capture_warnings( + messaged <- + capture_messages( + # Assign the result so capture.output() does not also see it printed. + printed <- capture.output(v_obj <- superposition(conc_obj, tau=24)) + ) + ) + # One of each per subject, and messages are not given an extra blank line. + expect_length(warned, 2) + expect_equal(unique(warned), "mocked warning") + expect_length(messaged, 2) + expect_equal(unique(trimws(messaged)), "mocked message") + expect_equal(unique(printed), "mocked output") })