Nothing
## biexponential() ==================================================
test_that("biexponential() returns correct vector length", {
t <- 0:120
result <- biexponential(
t, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40
)
expect_length(result, length(t))
expect_type(result, "double")
})
test_that("biexponential() starts at A and approaches the plateau", {
t <- 0:500
A <- 70
B <- 40
B2 <- 60
result <- biexponential(
t, A = A, B = B, tau = 5, B2 = B2, tau2 = 40
)
expect_equal(result[1], A)
expect_true(
all.equal(result[length(result)], B2, tolerance = 0.01,
scale = 1)
)
})
test_that("biexponential() slow component is active from the onset", {
t <- 0:120
A <- 70
B <- 40
tau <- 5
B2 <- 60
tau2 <- 40
result <- biexponential(t, A = A, B = B, tau = tau, B2 = B2, tau2 = tau2)
## the fast and slow terms run concurrently from t = 0, so the curve
## differs from the pure fast monoexponential for every t > 0
fast_only <- monoexponential(t, A = A, B = B, tau = tau)
expect_true(all(result[t > 0] != fast_only[t > 0]))
expect_equal(
result,
fast_only + (B2 - B) * (1 - exp(-t / tau2))
)
})
test_that("biexponential() drops to a nadir below A and below the plateau", {
t <- 0:120
A <- 70
B <- 40
B2 <- 60
result <- biexponential(
t, A = A, B = B, tau = 5, B2 = B2, tau2 = 40
)
## interior minimum below both endpoints (nadir-recovery shape)
expect_true(min(result) < A)
expect_true(min(result) < B2)
expect_true(which.min(result) > 1 && which.min(result) < length(result))
})
test_that("biexponential() TD form is flat before the delay", {
t <- 0:120
TD <- 15
result <- biexponential(
t, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40,
TD = TD
)
expect_true(all(result[t < TD] == 70))
expect_equal(result[t == TD], 70)
})
test_that("biexponential() reduces to the monoexponential when B = B2", {
t <- 0:120
## the slow term vanishes and the model is the exact monoexponential
expect_equal(
biexponential(
t, A = 70, B = 40, tau = 5, B2 = 40, tau2 = 50
),
monoexponential(t, A = 70, B = 40, tau = 5)
)
})
## SSbiexponential() ================================================
test_that("SSbiexponential() converges on known parameters", {
set.seed(1)
t <- 0:120
x <- biexponential(
t, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40
) + rnorm(length(t), 0, 0.5)
data <- data.frame(t, x)
model <- nls(
x ~ SSbiexponential(t, A, B, tau, B2, tau2),
data = data,
algorithm = "port",
lower = c(-Inf, -Inf, 0, -Inf, 0),
control = nls.control(warnOnly = TRUE)
)
expect_s3_class(model, "nls")
expect_named(coef(model), c("A", "B", "tau", "B2", "tau2"))
coefs <- coef(model)
expect_true(all.equal(coefs[["A"]], 70, tolerance = 2, scale = 1))
expect_true(all.equal(coefs[["B"]], 40, tolerance = 4, scale = 1))
expect_true(all.equal(coefs[["B2"]], 60, tolerance = 4, scale = 1))
expect_true(all.equal(coefs[["tau"]], 5, tolerance = 2, scale = 1))
expect_true(all.equal(coefs[["tau2"]], 40, tolerance = 15, scale = 1))
})
test_that("SSbiexponential() fits the 6-parameter TD form", {
set.seed(4)
t <- 0:120
x <- biexponential(
t, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40,
TD = 10
) + rnorm(length(t), 0, 0.5)
data <- data.frame(t, x)
model <- nls(
x ~ SSbiexponential(t, A, B, tau, B2, tau2, TD),
data = data,
algorithm = "port",
lower = c(-Inf, -Inf, 0, -Inf, 0, 0),
control = nls.control(warnOnly = TRUE)
)
expect_named(
coef(model), c("A", "B", "tau", "B2", "tau2", "TD")
)
expect_true(all.equal(coef(model)[["TD"]], 10, tolerance = 3, scale = 1))
})
test_that("SSbiexponential() predict() returns correct length", {
set.seed(2)
t <- 0:120
x <- biexponential(t, 70, 40, 5, 60, 40) + rnorm(length(t), 0, 0.5)
data <- data.frame(t, x)
model <- nls(
x ~ SSbiexponential(t, A, B, tau, B2, tau2),
data = data,
algorithm = "port",
lower = c(-Inf, -Inf, 0, -Inf, 0),
control = nls.control(warnOnly = TRUE)
)
expect_length(predict(model, data), nrow(data))
})
test_that("SSbiexponential() fixes A at a constant", {
set.seed(3)
t <- 0:120
x <- biexponential(
t, A = 0, B = -25, tau = 5, B2 = -10, tau2 = 40
) + rnorm(length(t), 0, 0.5)
data <- data.frame(t, x)
suppressWarnings(
model <- nls(
x ~ SSbiexponential(t, A = 0, B, tau, B2, tau2),
data = data,
algorithm = "port",
lower = c(-Inf, 0, -Inf, 0),
control = nls.control(warnOnly = TRUE)
)
)
expect_named(coef(model), c("B", "tau", "B2", "tau2"))
expect_equal(unname(predict(model, data.frame(t = 0))[1]), 0)
})
test_that("SSbiexponential() gradient matches numericDeriv for the free parameters", {
t <- seq(-10, 120, by = 0.5)
env <- list2env(list(
t = t, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40, TD = 3
))
chk <- function(expr, pars) {
an <- attr(eval(expr, env), "gradient")
nd <- attr(numericDeriv(expr, pars, env), "gradient")
expect_identical(colnames(an), pars)
expect_equal(unname(an), unname(nd), tolerance = 1e-5)
}
chk(
quote(SSbiexponential(t, A, B, tau, B2, tau2, TD)),
c("A", "B", "tau", "B2", "tau2", "TD")
)
## a constant in the formula contributes no column
chk(
quote(SSbiexponential(t, A, B, tau = 5, B2, tau2)),
c("A", "B", "B2", "tau2")
)
## no free parameter, no gradient; the exported fn stays plain
expect_null(attr(SSbiexponential(t, 70, 40, 5, 60, 40), "gradient"))
expect_null(attr(biexponential(t, 70, 40, 5, 60, 40), "gradient"))
})
test_that("biexp_start() matches a per-point least-squares grid search", {
set.seed(8)
t <- 0:120
x <- biexponential(t, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40) +
rnorm(length(t), 0, 0.5)
start <- biexp_start(x, t, has_TD = TRUE)
expect_named(start, c("A", "B", "tau", "B2", "tau2", "TD"))
## the linear coefficients at the chosen grid point are the lm solution
ts <- pmax(t - start[["TD"]], 0)
e1 <- exp(-ts / start[["tau"]])
e2 <- exp(-ts / start[["tau2"]])
cf <- lm.fit(cbind(e1, e2 - e1, 1 - e2), x)$coefficients
expect_equal(unname(start[c("A", "B", "B2")]), unname(cf), tolerance = 1e-8)
expect_true(start[["tau2"]] >= start[["tau"]] / tau_ratio)
})
test_that("SSbiexponential() fixes tau in the formula", {
set.seed(5)
t <- 0:120
x <- biexponential(
t, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40
) + rnorm(length(t), 0, 0.5)
data <- data.frame(t, x)
model <- nls(
x ~ SSbiexponential(t, A, B, tau = 5, B2, tau2),
data = data,
algorithm = "port",
lower = c(-Inf, -Inf, -Inf, 0),
control = nls.control(warnOnly = TRUE)
)
expect_named(coef(model), c("A", "B", "B2", "tau2"))
expect_true(all.equal(coef(model)[["B"]], 40, tolerance = 4, scale = 1))
})
## analyse_biexponential() ==========================================
## helper: create excursion-recovery test data with known parameters
create_biexp_data <- function(
A = 70,
B = 40,
tau = 5,
B2 = 60,
tau2 = 40,
n = 120,
sample_rate = 1,
noise_sd = 0.5,
channels = "smo2",
seed = 42
) {
set.seed(seed)
t <- seq(0, (n - 1) / sample_rate, length.out = n)
x <- biexponential(t, A, B, tau, B2, tau2) +
rnorm(n, 0, noise_sd)
df <- setNames(data.frame(t, x), c("time", channels[1]))
if (length(channels) > 1) {
for (ch in channels[-1]) {
df[[ch]] <- biexponential(t, A + 5, B + 5, tau, B2 + 5, tau2) +
rnorm(n, 0, noise_sd)
}
}
create_mnirs_data(
df,
nirs_channels = channels,
time_channel = "time",
sample_rate = sample_rate
)
}
test_that("analyse_biexponential() returns correct structure", {
data <- create_biexp_data()
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
verbose = TRUE
)
expect_s3_class(result, "data.frame")
## the chain's union schema, `model` naming the fit per row
expect_named(result, c(
"interval", "nirs_channels", "model",
kinetics_chain_cols("biexponential")
))
expect_equal(result$model, "biexponential")
expect_equal(nrow(result), 1L)
## attributes
expect_type(attr(result, "model"), "list")
expect_true(inherits(attr(result, "model")$smo2, "nls"))
expect_s3_class(attr(result, "fitted_data")$smo2, "data.frame")
expect_named(attr(result, "fitted_data")$smo2, c("window_idx", "fitted"))
expect_s3_class(attr(result, "diagnostics"), "data.frame")
expect_equal(nrow(attr(result, "diagnostics")), 1L)
expect_s3_class(attr(result, "channel_args"), "data.frame")
})
test_that("analyse_biexponential() recovers known parameters", {
A <- 70
B <- 40
B2 <- 60
data <- create_biexp_data(A = A, B = B, B2 = B2, noise_sd = 0.3)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
verbose = FALSE
)
expect_true(all.equal(result$A, A, tolerance = 3, scale = 1))
## the fast asymptote absorbs some slow phase over the stage-1 window
expect_true(all.equal(result$B, B, tolerance = 5, scale = 1))
expect_true(all.equal(result$B2, B2, tolerance = 3, scale = 1))
## no TD: MRT is the fast time constant
expect_equal(result$MRT, result$tau)
## excursion sits inside the window, below the starting value
expect_true(result$texc > 0)
expect_true(result$texc_fitted < result$A)
## good fit
expect_true(attr(result, "diagnostics")$r2 > 0.9)
})
test_that("analyse_biexponential() texc is the fitted excursion point", {
result <- analyse_biexponential(
create_biexp_data(noise_sd = 0.3),
nirs_channels = "smo2",
use_TD = FALSE,
verbose = FALSE
)
## texc_fitted is the model prediction at the fitted excursion point
expect_equal(
result$texc_fitted,
biexponential(
result$texc, result$A, result$B, result$tau,
result$B2, result$tau2
)
)
expect_true(result$texc > 0)
expect_true(result$tau < result$tau2)
## the excursion point sits near the fitted-curve minimum
fitted <- attr(result, "fitted_data")$smo2$fitted
expect_true(all.equal(result$texc_fitted, min(fitted), tolerance = 1,
scale = 1))
})
test_that("analyse_biexponential() reports tau <= tau2", {
result <- analyse_biexponential(
create_biexp_data(noise_sd = 0.3),
nirs_channels = "smo2",
use_TD = FALSE,
verbose = FALSE
)
expect_lte(result$tau, result$tau2)
set.seed(6)
t <- 0:120
x <- biexponential(t, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40) +
rnorm(length(t), 0, 0.3)
data <- data.frame(t, x)
model <- nls(
x ~ SSbiexponential(t, A, B, tau, B2, tau2),
data = data,
algorithm = "port",
lower = c(-Inf, -Inf, 0, -Inf, 0),
control = nls.control(warnOnly = TRUE)
)
expect_true(coef(model)[["tau"]] < coef(model)[["tau2"]])
})
test_that("analyse_biexponential() reports NA texc for a monotonic fit", {
## B between A and B2: the fitted curve is monotonic, so there is no
## interior excursion point
set.seed(7)
t <- 0:120
x <- biexponential(t, A = 70, B = 55, tau = 5, B2 = 40, tau2 = 40) +
rnorm(length(t), 0, 0.3)
data <- create_mnirs_data(
data.frame(time = t, smo2 = x),
nirs_channels = "smo2", time_channel = "time", sample_rate = 1
)
## the raw fit: a monotonic response otherwise falls back
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
verbose = FALSE,
model_fallback = FALSE
)
expect_true(is.na(result$texc))
expect_true(is.na(result$texc_fitted))
expect_false(is.na(result$A))
expect_false(is.na(result$B))
expect_false(is.na(result$tau))
expect_false(is.na(result$B2))
expect_false(is.na(result$tau2))
})
test_that("analyse_biexponential() uses start_time correctly", {
start_time <- 12
data <- create_biexp_data(noise_sd = 0.3)
data$time <- data$time + start_time
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
start_time = start_time,
use_TD = FALSE,
verbose = FALSE
)
expect_true(all.equal(result$A, 70, tolerance = 3, scale = 1))
## plateau = B2
expect_true(all.equal(result$B2, 60, tolerance = 3, scale = 1))
})
test_that("analyse_biexponential() works with multiple channels", {
nirs_channels <- c("smo2_left", "smo2_right")
data <- create_biexp_data(channels = nirs_channels)
result <- analyse_biexponential(
data,
nirs_channels = nirs_channels,
verbose = FALSE
)
expect_equal(nrow(result), 2L)
expect_equal(result$nirs_channels, nirs_channels)
expect_named(attr(result, "fitted_data"), nirs_channels)
})
test_that("analyse_biexponential() returns NA for failed fit", {
## too few observations for the model
custom_name <- create_biexp_data(n = 4, noise_sd = 0.1)
expect_warning(
result <- analyse_biexponential(
custom_name,
nirs_channels = "smo2",
use_TD = FALSE,
model_fallback = FALSE
),
"fit failed for.*smo2.*custom_name"
)
expect_true(is.na(result$A))
expect_true(is.na(result$tau))
expect_true(is.na(result$MRT))
expect_true(is.na(result$texc_fitted))
})
test_that("analyse_biexponential() suppresses fit-failure warning when verbose = FALSE", {
custom_name <- create_biexp_data(n = 4, noise_sd = 0.1)
expect_no_warning(
analyse_biexponential(
custom_name,
nirs_channels = "smo2",
verbose = FALSE
)
)
})
test_that("analyse_biexponential() end_window bounds the fast phase only", {
data <- create_biexp_data(noise_sd = 0.3)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
end_window = 40,
use_TD = FALSE,
verbose = FALSE
)
## stage 2 spans the full response regardless of end_window
fitted_data <- attr(result, "fitted_data")$smo2
expect_equal(nrow(fitted_data), nrow(data))
expect_equal(attr(result, "diagnostics")$n_obs, nrow(data))
## the stage-1 window drives tau
full <- analyse_biexponential(
data, nirs_channels = "smo2", use_TD = FALSE, verbose = FALSE
)
expect_false(isTRUE(all.equal(result$tau, full$tau)))
})
test_that("analyse_biexponential() bounds the fast phase about the stage-1 fit", {
data <- create_biexp_data(noise_sd = 0.3)
## stage 1 is the monoexponential fit on the same window
fast <- analyse_monoexponential(
data, nirs_channels = "smo2", end_window = 20, use_TD = FALSE,
verbose = FALSE
)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
end_window = 20,
use_TD = FALSE,
tau_flex = 0.1,
A_flex = 0.5,
verbose = FALSE
)
expect_true(result$tau >= fast$tau / 1.1 - 1e-8)
expect_true(result$tau <= fast$tau * 1.1 + 1e-8)
expect_true(abs(result$A - fast$A) <= 0.5 + 1e-8)
## the slow phase separates above the fast-phase ceiling
expect_true(result$tau2 >= fast$tau * 1.1 / tau_ratio - 1e-8)
## flex args pass through `...` of analyse_kinetics() and are recorded
ca <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
end_window = 20,
tau_flex = list(smo2 = 0.1),
TD_flex = 1,
verbose = FALSE
)$channel_args
expect_equal(ca$tau_flex, 0.1)
expect_equal(ca$TD_flex, 1)
expect_true(is.na(ca$A_flex))
ca <- analyse_kinetics(
data, nirs_channels = "smo2", method = "biexponential", verbose = FALSE
)$channel_args
expect_equal(ca$tau_flex, 1 / 3)
expect_equal(ca$TD_flex, 2)
})
test_that("analyse_biexponential() fits a mirrored rise-overshoot response", {
## inverted kinetics: the response rises to a peak before settling back.
## the asymptote ordering flips (B above A, B2 below B) and is
## recovered from the data
set.seed(31)
t <- 0:119
x <- biexponential(
t, A = 30, B = 55, tau = 10, B2 = 40, tau2 = 50
) + rnorm(120, 0, 0.3)
data <- create_mnirs_data(
data.frame(time = t, smo2 = x),
nirs_channels = "smo2", time_channel = "time", sample_rate = 1
)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
verbose = FALSE
)
expect_true(all.equal(result$B, 55, tolerance = 5, scale = 1))
expect_true(all.equal(result$B2, 40, tolerance = 5, scale = 1))
expect_true(attr(result, "diagnostics")$r2 > 0.9)
## a mirrored response reports a genuine interior excursion: a maximum
## above the baseline
expect_true(result$texc > 0)
expect_true(result$texc_fitted > result$A)
})
## time delay =======================================================
test_that("analyse_biexponential() use_TD = FALSE forces the 5-param fit", {
data <- create_biexp_data(noise_sd = 0.3)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
verbose = FALSE
)
expect_named(coef(attr(result, "model")$smo2),
c("A", "B", "tau", "B2", "tau2"))
expect_true(is.na(result$TD))
})
test_that("analyse_biexponential() falls back to the 5-parameter fit", {
## the stage-1 TD fit fails on seven observations and retries without
## TD, so stage 2 fits the 5-parameter model
data <- create_biexp_data(n = 7, noise_sd = 0.1)
warns <- capture_warnings(
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = TRUE,
model_fallback = FALSE
)
)
expect_match(warns[[1L]], "4-parameter `SSmonoexponential")
expect_match(warns[[1L]], "Attempting")
## TD is absent from the reduced model whether or not the retry converges
expect_true(is.na(result$TD))
})
test_that("analyse_biexponential() texc is elapsed from start_time", {
## 6-parameter TD fit with a non-zero start_time: texc must be
## measured from start_time, not from the model's internal (TD) onset
set.seed(11)
start_time <- 20
TD <- 8
t <- start_time + 0:119
x <- biexponential(
t - start_time, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40,
TD = TD
) + rnorm(120, 0, 0.15)
data <- create_mnirs_data(
data.frame(time = t, smo2 = x),
nirs_channels = "smo2", time_channel = "time", sample_rate = 1
)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
start_time = start_time,
use_TD = TRUE,
verbose = FALSE
)
## must fit the 6-param model, not fall back to the 5-param form
expect_named(coef(attr(result, "model")$smo2),
c("A", "B", "tau", "B2", "tau2", "TD"))
## MRT is the fast-phase mean response time from the fit onset
expect_equal(result$MRT, result$TD + result$tau)
## texc includes the TD offset from the fit onset
expect_true(result$texc > TD)
})
## direction ========================================================
test_that("analyse_biexponential() direction steers the fit window", {
## an excursion in the requested direction fits without a refit
data <- create_biexp_data(noise_sd = 0.3)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
direction = "negative",
use_TD = FALSE,
verbose = FALSE
)
expect_false(is.na(result$A))
expect_equal(attr(result, "channel_args")$direction, "negative")
})
test_that("analyse_biexponential() direction = 'negative' matches auto on falling data", {
data <- create_biexp_data(noise_sd = 0.3)
result_auto <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
direction = "auto",
verbose = FALSE
)
result_neg <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
direction = "negative",
verbose = FALSE
)
## matching direction leaves the unconstrained fit untouched
expect_equal(result_auto$A, result_neg$A)
expect_equal(result_auto$B2, result_neg$B2)
expect_equal(result_auto$tau, result_neg$tau)
expect_true(result_auto$B2 < result_auto$A)
})
test_that("analyse_biexponential() direction = 'positive' rejects a negative response", {
## genuinely falling drop-recovery: no positive response exists
## within the data span, so the bounded refit degenerates
data <- create_biexp_data(noise_sd = 0.3)
expect_warning(
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
direction = "positive",
verbose = TRUE
),
"satisfy"
)
## never returns a within-span negative fit against requested direction
expect_true(is.na(result$A))
expect_true(is.na(result$B2))
expect_true(is.na(result$tau))
})
test_that("analyse_biexponential() suppresses direction warning when verbose = FALSE", {
data <- create_biexp_data(noise_sd = 0.3)
expect_no_warning(
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
direction = "positive",
verbose = FALSE
)
)
expect_true(is.na(result$A))
})
test_that("analyse_biexponential() direction with both asymptotes fixed returns NA", {
## falling data with both asymptotes fixed contradicts the requested
## positive direction: no refit possible
data <- create_biexp_data(noise_sd = 0.3)
expect_warning(
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
fix = list(A = 70, B2 = 60),
direction = "positive",
verbose = TRUE
),
"satisfy"
)
expect_true(is.na(result$A))
expect_true(is.na(result$tau))
})
test_that("analyse_kinetics() passes direction to biexponential method", {
data <- create_biexp_data(noise_sd = 0.3)
expect_warning(
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
use_TD = FALSE,
direction = "positive"
),
"satisfy"
)
expect_true(is.na(result$coefficients$A))
})
## fixed parameters =================================================
test_that("analyse_biexponential() fix holds parameters constant", {
data <- create_biexp_data(A = 0, B = -20, B2 = -10, noise_sd = 0.3)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
fix = list(A = 0),
use_TD = FALSE,
verbose = FALSE
)
expect_equal(result$A, 0)
## fixed A excluded from the fitted model coefficients
expect_named(
coef(attr(result, "model")$smo2),
c("B", "tau", "B2", "tau2")
)
expect_equal(attr(result, "channel_args")$fix, "list(A = 0)")
expect_false(is.na(attr(result, "diagnostics")$adj_r2))
})
test_that("analyse_biexponential() fix holds tau constant", {
data <- create_biexp_data(noise_sd = 0.3)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
fix = list(tau = 12),
use_TD = FALSE,
verbose = FALSE
)
expect_equal(result$tau, 12)
expect_named(
coef(attr(result, "model")$smo2), c("A", "B", "B2", "tau2")
)
})
test_that("analyse_biexponential() fix holds tau2 constant on the sequential fit", {
data <- create_biexp_data(noise_sd = 0.3)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
fix = list(tau2 = 40),
use_TD = FALSE,
verbose = FALSE
)
expect_equal(result$tau2, 40)
expect_named(
coef(attr(result, "model")$smo2), c("A", "B", "tau", "B2")
)
expect_true(all.equal(result$tau, 5, tolerance = 2, scale = 1))
})
test_that("analyse_biexponential() returned model is canonical and converged", {
data <- create_biexp_data(noise_sd = 0.3)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
verbose = FALSE
)
model <- attr(result, "model")$smo2
expect_named(coef(model), c("A", "B", "tau", "B2", "tau2", "TD"))
expect_true(model$convInfo$isConv)
expect_true(result$tau2 >= result$tau / tau_ratio)
## coefficient table mirrors the model
expect_equal(result$tau, coef(model)[["tau"]])
## the self-start model predicts with a gradient attribute
pred <- predict(model, newdata = data.frame(time = c(0, 10)))
expect_length(pred, 2L)
expect_true(all(is.finite(pred)))
})
test_that("analyse_biexponential() caps a runaway tau2 at 10x the span", {
## fast dip onto a linear ramp: the slow limb has no finite time
## constant, so tau2 runs to its upper bound
set.seed(42)
t <- 0:59
x <- 70 - 25 * exp(-t / 5) + 0.15 * t + rnorm(60, 0, 0.3)
data <- create_mnirs_data(
data.frame(time = t, smo2 = x),
nirs_channels = "smo2", time_channel = "time", sample_rate = 1
)
result <- suppressWarnings(analyse_biexponential(
data, nirs_channels = "smo2", use_TD = FALSE, verbose = FALSE,
model_fallback = FALSE
))
## pinned at the port upper bound rather than left to diverge
expect_true(result$tau2 <= 10 * diff(range(t)) + 1e-6)
})
test_that("analyse_biexponential() fix holds TD constant", {
set.seed(21)
TD <- 8
t <- 0:119
x <- biexponential(
t, A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40,
TD = TD
) + rnorm(120, 0, 0.2)
data <- create_mnirs_data(
data.frame(time = t, smo2 = x),
nirs_channels = "smo2", time_channel = "time", sample_rate = 1
)
result <- analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = TRUE,
fix = list(TD = TD),
verbose = FALSE
)
expect_equal(result$TD, TD)
## a fixed TD is excluded from estimation and disables the 5-param retry
expect_named(
coef(attr(result, "model")$smo2),
c("A", "B", "tau", "B2", "tau2")
)
})
test_that("analyse_biexponential() TD is only fixable when use_TD = TRUE", {
data <- create_biexp_data()
expect_error(
analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = FALSE,
fix = list(TD = 0)
),
"not recognised"
)
})
test_that("analyse_biexponential() fix resolves per channel", {
## ch2 asymptotes are each + 5 by construction
data <- create_biexp_data(
A = 0, B = -20, B2 = -10, noise_sd = 0.3,
channels = c("ch1", "ch2")
)
result <- analyse_biexponential(
data,
nirs_channels = c("ch1", "ch2"),
use_TD = FALSE,
fix = list(ch1 = list(A = 0), ch2 = list(A = 5)),
verbose = FALSE
)
expect_equal(result$A, c(0, 5))
models <- attr(result, "model")
expect_named(coef(models$ch1), c("B", "tau", "B2", "tau2"))
expect_named(coef(models$ch2), c("B", "tau", "B2", "tau2"))
ca <- attr(result, "channel_args")
expect_equal(ca$fix, c("list(A = 0)", "list(A = 5)"))
})
test_that("analyse_biexponential() validates fix argument", {
data <- create_biexp_data()
## unnamed list
expect_error(
analyse_biexponential(data, nirs_channels = "smo2", fix = list(0)),
"uniquely named"
)
## unknown parameter name
expect_error(
analyse_biexponential(data, nirs_channels = "smo2", fix = list(Q = 1)),
"not recognised"
)
## cannot fix every parameter
expect_error(
analyse_biexponential(
data,
nirs_channels = "smo2",
use_TD = TRUE,
fix = list(
A = 70, B = 40, tau = 5, B2 = 60, tau2 = 40, TD = 0
)
),
"Nothing to estimate"
)
})
## dispatch =========================================================
test_that("analyse_kinetics() dispatches to the biexponential method", {
data <- create_biexp_data(noise_sd = 0.3)
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
use_TD = FALSE,
verbose = FALSE
)
expect_s3_class(result, "mnirs_kinetics")
expect_equal(result$method, "biexponential")
expect_true(all(
c("B", "tau", "B2", "tau2", "texc") %in%
names(result$coefficients)
))
## the model carries its fit data in the call for update()/insight
model <- result$model[[1L]]$smo2
fit_data <- eval(model$call$data, envir = baseenv())
expect_s3_class(fit_data, "data.frame")
expect_named(fit_data, c("smo2", "time"))
expect_equal(nrow(fit_data), length(stats::fitted(model)))
})
test_that("analyse_kinetics() resolves the 'biexp' alias", {
data <- create_biexp_data(noise_sd = 0.3)
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexp",
use_TD = FALSE,
verbose = FALSE
)
expect_equal(result$method, "biexponential")
})
test_that("analyse_kinetics() passes fix to the biexponential method", {
data <- create_biexp_data(A = 0, B = -20, B2 = -10, noise_sd = 0.3)
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
fix = list(A = 0),
use_TD = FALSE,
verbose = FALSE
)
expect_equal(result$coefficients$A, 0)
expect_named(
coef(result$model[[1L]]$smo2),
c("B", "tau", "B2", "tau2")
)
})
## model fallback chain =============================================
## monotonic two-phase response: B between A and B2, no excursion point
create_monotonic_data <- function(seed = 7, TD = NULL, t = 0:119) {
set.seed(seed)
x <- biexponential(t, A = 70, B = 55, tau = 5, B2 = 40, tau2 = 40, TD) +
rnorm(length(t), 0, 0.3)
create_mnirs_data(
data.frame(time = t, smo2 = x),
nirs_channels = "smo2",
time_channel = "time",
sample_rate = 1
)
}
## fast drop then a linear recovery: a slow phase no record can resolve
create_linear_tail_data <- function(seed = 5, t = 0:119) {
set.seed(seed)
# fmt: skip
x <- exponential_drift(
t, A = 70, B = 40, tau = 5, slope_B = 0.1, drift_fraction = 0.95
) +
rnorm(length(t), 0, 0.3)
create_mnirs_data(
data.frame(time = t, smo2 = x),
nirs_channels = "smo2",
time_channel = "time",
sample_rate = 1
)
}
fallback_models <- c("exponential_drift", "monoexponential")
biexp_only <- c("B2", "tau2")
test_that("analyse_kinetics() falls back from a monotonic biexponential fit", {
data <- create_monotonic_data()
expect_warning(
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
use_TD = FALSE
),
"fell back to"
)
cf <- result$coefficients
model <- result$model[[1L]]$smo2
expect_equal(names(cf)[1:4], c("interval", "nirs_channels", "start_time", "model"))
expect_true(cf$model %in% fallback_models)
## the schema unions the method's columns with the fallback model's
expect_setequal(
names(cf)[-(1:4)],
union(kinetics_coef_cols$biexponential, kinetics_coef_cols[[cf$model]])
)
## the fallback row reports its own model's coefficients
expect_true(all(is.na(cf[biexp_only])))
expect_equal(cf$A, coef(model)[["A"]])
expect_equal(cf$B, coef(model)[["B"]])
expect_equal(cf$tau, coef(model)[["tau"]])
expect_equal(cf$MRT, cf$tau)
## diagnostics and fitted values follow the fallback model
expect_equal(result$diagnostics$n_params, length(coef(model)))
expect_equal(
result$data[[1L]]$smo2_fitted,
as.vector(predict(model))
)
## recorded with the reason, regardless of verbose
msgs <- result$warnings$message
expect_true(any(grepl("fell back to", msgs)))
expect_true(any(grepl("monotonic", msgs)))
})
test_that("analyse_kinetics() falls back to a monoexponential response", {
set.seed(3)
t <- 0:120
x <- monoexponential(t, A = 70, B = 40, tau = 8) + rnorm(length(t), 0, 0.3)
data <- create_mnirs_data(
data.frame(time = t, smo2 = x),
nirs_channels = "smo2", time_channel = "time", sample_rate = 1
)
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
use_TD = FALSE,
verbose = FALSE
)
cf <- result$coefficients
expect_equal(cf$model, "monoexponential")
expect_true(all.equal(cf$tau, 8, tolerance = 1, scale = 1))
expect_true(all(is.na(cf[c(biexp_only, "texc")])))
## drift-only columns are dropped when no row kept the drift model
expect_false(any(c("slope_B", "drift_fraction") %in% names(cf)))
expect_true(inherits(result$model[[1L]]$smo2, "nls"))
## both fallbacks are recorded
expect_equal(sum(grepl("fell back to", result$warnings$message)), 2L)
})
test_that("analyse_kinetics() keeps a supported excursion-recovery fit", {
data <- create_biexp_data(noise_sd = 0.3)
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
verbose = FALSE
)
forced <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
model_fallback = FALSE,
verbose = FALSE
)
cf <- result$coefficients
expect_equal(cf$model, "biexponential")
expect_true(is.finite(cf$texc))
## only the biexponential's own columns are reported
expect_equal(names(cf)[-(1:4)], kinetics_coef_cols$biexponential)
## the fast phase shares the primary-phase columns, not its own pair
expect_false(any(c("B1", "tau1") %in% names(cf)))
expect_named(
coef(result$model[[1L]]$smo2),
c("A", "B", "tau", "B2", "tau2", "TD")
)
expect_false(any(grepl("fell back to", result$warnings$message)))
## the raw fit is unchanged by the triggers
expect_equal(cf, forced$coefficients)
expect_equal(result$diagnostics, forced$diagnostics)
})
test_that("model_fallback = FALSE keeps a monotonic fit", {
data <- create_monotonic_data()
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
use_TD = FALSE,
model_fallback = FALSE,
verbose = FALSE
)
cf <- result$coefficients
expect_equal(cf$model, "biexponential")
expect_true(is.na(cf$texc))
expect_false(is.na(cf$tau2))
expect_false(cf$B == cf$B2)
expect_false(any(grepl("fell back to", result$warnings$message)))
})
test_that("a slow phase beyond the record falls back to exponential_drift", {
data <- create_linear_tail_data()
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
use_TD = FALSE,
verbose = FALSE
)
cf <- result$coefficients
expect_equal(cf$model, "exponential_drift")
expect_true(all.equal(cf$slope_B, 0.1, tolerance = 0.05, scale = 1))
expect_true(is.finite(cf$texc))
expect_named(coef(result$model[[1L]]$smo2), c("A", "B", "tau", "slope_B"))
expect_true(all(is.na(cf[biexp_only])))
msgs <- result$warnings$message
expect_true(any(grepl("fell back to", msgs)))
expect_true(any(grepl("tau2 exceeds", msgs)))
})
test_that("fallback fits the full response, not end_window", {
data <- create_monotonic_data(seed = 11, TD = 10, t = -20:120)
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
end_window = 30,
verbose = FALSE
)
cf <- result$coefficients
model <- result$model[[1L]]$smo2
expect_true(cf$model %in% fallback_models)
expect_equal(is.finite(cf$TD), "TD" %in% names(coef(model)))
expect_gte(result$diagnostics$n_obs, sum(data$time >= 0))
expect_equal(
result$diagnostics$n_obs,
sum(is.finite(result$data[[1L]]$smo2_fitted))
)
## resolved arguments are the fallback fit's
expect_equal(result$channel_args$end_window, Inf)
})
test_that("fallback carries fixed parameters over", {
data <- create_monotonic_data()
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
use_TD = FALSE,
fix = list(A = 70, B = 55),
verbose = FALSE
)
cf <- result$coefficients
expect_true(cf$model %in% fallback_models)
expect_equal(cf$A, 70)
expect_equal(cf$B, 55)
expect_false(any(c("A", "B") %in% names(coef(result$model[[1L]]$smo2))))
## parameters without a counterpart are dropped
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
use_TD = FALSE,
fix = list(tau2 = 40),
verbose = FALSE
)
expect_true(result$coefficients$model %in% fallback_models)
expect_true(all(c("A", "B", "tau") %in% names(coef(result$model[[1L]]$smo2))))
})
test_that("fallback resolves per interval", {
data <- list(
excursion = create_biexp_data(noise_sd = 0.3),
monotonic = create_monotonic_data()
)
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
use_TD = FALSE,
verbose = FALSE
)
cf <- result$coefficients
expect_equal(cf$interval, c("excursion", "monotonic"))
expect_equal(cf$model[[1L]], "biexponential")
expect_true(cf$model[[2L]] %in% fallback_models)
expect_named(
coef(result$model$excursion$smo2), c("A", "B", "tau", "B2", "tau2")
)
expect_true(is.finite(cf$texc[[1L]]))
expect_true(is.na(cf$tau2[[2L]]))
fell <- result$warnings[grepl("fell back to", result$warnings$message), ]
expect_true(all(fell$interval == "monotonic"))
expect_true(all(fell$nirs_channels == "smo2"))
})
test_that("fallback resolves per channel", {
data <- create_biexp_data(noise_sd = 0.3, channels = c("smo2", "hhb"))
data$hhb <- create_monotonic_data()$smo2
## the per-channel map is keyed to the fallback channel only
expect_no_warning(
result <- analyse_kinetics(
data,
nirs_channels = c(smo2, hhb),
method = "biexponential",
use_TD = list(smo2 = TRUE, hhb = FALSE),
fix = list(smo2 = list(A = 70), hhb = list(A = 70)),
verbose = FALSE
)
)
cf <- result$coefficients
expect_equal(cf$nirs_channels, c("smo2", "hhb"))
expect_equal(cf$model[[1L]], "biexponential")
expect_true(cf$model[[2L]] %in% fallback_models)
expect_true(is.finite(cf$TD[[1L]]))
expect_true(is.na(cf$TD[[2L]]))
expect_equal(cf$A, c(70, 70))
expect_true(inherits(result$model[[1L]]$smo2, "nls"))
expect_true(inherits(result$model[[1L]]$hhb, "nls"))
expect_equal(result$diagnostics$nirs_channels, c("smo2", "hhb"))
expect_equal(result$channel_args$nirs_channels, c("smo2", "hhb"))
expect_equal(
sum(is.finite(result$data[[1L]]$hhb_fitted)),
result$diagnostics$n_obs[[2L]]
)
})
test_that("a row where every fit fails reports the last method", {
data <- create_mnirs_data(
data.frame(time = 0:2, smo2 = c(70, 60, 55)),
nirs_channels = "smo2", time_channel = "time", sample_rate = 1
)
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "biexponential",
verbose = FALSE
)
cf <- result$coefficients
expect_equal(cf$model, "monoexponential")
expect_true(is.na(cf$A))
expect_null(result$model[[1L]]$smo2)
expect_equal(sum(grepl("fell back to", result$warnings$message)), 2L)
})
test_that("kinetics_chain_cols() unions the chain with `_fitted` columns last", {
expect_equal(
kinetics_chain_cols("biexponential"),
c(
"A", "B", "TD", "tau", "MRT", "texc", "B2", "tau2", "k", "HRT",
"slope_B", "drift_fraction", "MRT_fitted", "texc_fitted",
"HRT_fitted"
)
)
expect_equal(
kinetics_chain_cols("monoexponential"),
kinetics_coef_cols$monoexponential
)
})
test_that("bind_union() pads differing columns and keeps a fixed order", {
a <- data.frame(x = 1, y = "a")
b <- data.frame(y = "b", z = TRUE)
out <- bind_union(list(a, b))
expect_named(out, c("x", "y", "z"))
expect_equal(out$x, c(1, NA))
expect_equal(out$z, c(NA, TRUE))
expect_named(bind_union(list(a, b), c("z", "y", "x", "w")), c("z", "y", "x", "w"))
expect_equal(bind_union(list(a[0, ], b))$y, "b")
expect_null(bind_union(list(a[0, ])))
})
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.