Nothing
## logistic() ========================================================
test_that("logistic() returns correct vector length", {
t <- 1:60
result <- logistic(t, A = 0, B = 100, xmid = 30, slope = 4)
expect_length(result, length(t))
expect_type(result, "double")
})
test_that("logistic() 4-param symmetric form: y(xmid) = (A + B) / 2", {
t <- 1:60
A <- 10
B <- 100
xmid <- 30
result <- logistic(t, A = A, B = B, xmid = xmid, slope = 4)
idx <- which(t == xmid)
expect_true(
all.equal(result[idx], (A + B) / 2, tolerance = 1e-8, scale = 1)
)
})
test_that("logistic() approaches A and B asymptotes", {
t <- -200:200
result <- logistic(t, A = 10, B = 100, xmid = 0, slope = 4)
expect_true(
all.equal(result[1L], 10, tolerance = 0.01, scale = 1)
)
expect_true(
all.equal(result[length(result)], 100, tolerance = 0.01, scale = 1)
)
})
test_that("logistic() 4-param is monotonically increasing for B > A", {
t <- 1:60
result <- logistic(t, A = 10, B = 100, xmid = 30, slope = 4)
expect_true(all(diff(result) > 0))
})
test_that("logistic() handles falling curves (B < A)", {
t <- 1:60
result <- logistic(t, A = 100, B = 10, xmid = 30, slope = -4)
expect_true(all(diff(result) < 0))
expect_true(
all.equal(result[which(t == 30)], 55, tolerance = 1e-8, scale = 1)
)
})
test_that("logistic() 5-param with asym = 0.5 matches 4-param", {
t <- 1:60
args <- list(A = 0, B = 100, xmid = 30, slope = 4)
y4 <- do.call(logistic, c(list(t = t), args))
y5 <- do.call(logistic, c(list(t = t), args, asym = 0.5))
expect_true(all.equal(y4, y5, tolerance = 1e-6, scale = 1))
})
test_that("logistic() 5-param supports full asym range (0, 1)", {
t <- seq(0, 1, length.out = 200)
args <- list(A = 0, B = 1, xmid = 0.5, slope = 2)
## span across symmetric centre with both Gompertz-limit directions
asym_vals <- seq(0.2, 0.9, 0.1)
## visual check
# supported <- do.call(rbind, lapply(asym_vals, \(a) {
# data.frame(
# t = t,
# x = do.call(logistic, c(list(t = t), args, asym = a)),
# asym = factor(a)
# )
# }))
# ggplot2::ggplot(supported, ggplot2::aes(t, x, colour = asym)) +
# ggplot2::labs(
# title = "logistic() supported asym range",
# subtitle = paste(
# "dashed: Gompertz curves at `y = A + (B-A)/e` and `y = 1 - (A + (B-A)/e)`",
# "dotted: xmid",
# sep = "\n"
# )
# ) +
# theme_mnirs() +
# scale_colour_mnirs() +
# ggplot2::geom_line() +
# ggplot2::geom_hline(
# yintercept = c(
# args$A + (args$B - args$A) / exp(1),
# 1 - (args$A + (args$B - args$A) / exp(1))
# ),
# linetype = "dashed"
# ) +
# # ggplot2::geom_hline(yintercept = 0.5, linetype = "dotted") +
# ggplot2::geom_vline(xintercept = args$xmid, linetype = "dotted")
for (a in asym_vals) {
result <- do.call(logistic, c(list(t = t), args, asym = a))
expect_all_true(is.finite(result))
expect_length(result, length(t))
}
})
test_that("logistic() 5-param asym < 0.5 shifts inflection height down", {
## asym -> 0 means inflection y near A
t <- seq(0, 100, length.out = 1000)
A <- 0
B <- 100
xmid <- 50
args <- list(A = A, B = B, xmid = xmid, slope = 2)
y_low <- do.call(logistic, c(list(t = t), args, asym = 0.05))
# plot(t, y_low)
## inflection at xmid; height should be near A
y_at_xmid <- y_low[which.min(abs(t - xmid))]
expect_true(all.equal(
y_at_xmid, A + (B - A) * 0.05, tolerance = 1, scale = 1
))
})
test_that("logistic() 5-param asym > 0.5 shifts inflection height up", {
## asym -> 1 means inflection y near B
t <- seq(0, 100, length.out = 1000)
A <- 0
B <- 100
xmid <- 50
args <- list(A = A, B = B, xmid = xmid, slope = 2)
y_high <- do.call(logistic, c(list(t = t), args, asym = 0.95))
# plot(t, y_high)
y_at_xmid <- y_high[which.min(abs(t - xmid))]
expect_true(all.equal(
y_at_xmid, A + (B - A) * 0.95, tolerance = 1, scale = 1
))
})
test_that("logistic() 5-param is smooth across asym = 0.5", {
## both branches should agree in the limit; check small offsets either side
t <- 1:60
args <- list(A = 0, B = 100, xmid = 30, slope = 4)
y_just_below <- do.call(logistic, c(list(t = t), args, asym = 0.5 - 1e-6))
y_just_above <- do.call(logistic, c(list(t = t), args, asym = 0.5 + 1e-6))
expect_true(
all.equal(y_just_below, y_just_above, tolerance = 1e-4, scale = 1)
)
})
## SSlogistic() ======================================================
test_that("SSlogistic() 4-param fit recovers parameters", {
set.seed(13)
t <- 1:60
base <- list(A = 10, B = 100, xmid = 30, slope = 4)
x <- do.call(logistic, c(list(t = t), base)) + rnorm(length(t), 0, 2)
data <- data.frame(t, x)
## visual check
# ggplot2::ggplot(data, ggplot2::aes(x = t, y = x)) +
# ggplot2::geom_line()
model <- nls(x ~ SSlogistic(t, A, B, xmid, slope), data = data)
expect_s3_class(model, "nls")
expect_named(coef(model), c("A", "B", "xmid", "slope"))
coefs <- coef(model)
expect_true(all.equal(coefs[["A"]], base$A, tolerance = 1, scale = 1))
expect_true(all.equal(coefs[["B"]], base$B, tolerance = 1, scale = 1))
expect_true(
all.equal(coefs[["xmid"]], base$xmid, tolerance = 1, scale = 1)
)
expect_true(
all.equal(coefs[["slope"]], base$slope, tolerance = 0.1, scale = 1)
)
})
test_that("SSlogistic() 4-param handles falling sigmoids (B < A)", {
set.seed(456)
t <- 1:60
base <- list(A = 100, B = 10, xmid = 30, slope = -4)
x <- do.call(logistic, c(list(t = t), base)) + rnorm(length(t), 0, 2)
data <- data.frame(t, x)
model <- nls(x ~ SSlogistic(t, A, B, xmid, slope), data = data)
coefs <- coef(model)
expect_s3_class(model, "nls")
expect_true(all.equal(coefs[["A"]], base$A, tolerance = 1, scale = 1))
expect_true(all.equal(coefs[["B"]], base$B, tolerance = 2, scale = 1))
expect_true(all.equal(coefs[["xmid"]], base$xmid, tolerance = 1, scale = 1))
expect_true(
all.equal(coefs[["slope"]], base$slope, tolerance = 0.2, scale = 1)
)
})
test_that("SSlogistic() predict() returns correct length", {
set.seed(202)
t <- 1:60
x <- logistic(t, 10, 100, 30, 4) + rnorm(length(t), 0, 2)
data <- data.frame(t, x)
model <- nls(x ~ SSlogistic(t, A, B, xmid, slope), data = data)
predictions <- predict(model, data)
expect_length(predictions, nrow(data))
})
test_that("SSlogistic() 5-param recovers parameters (symmetric centre)", {
set.seed(15)
t <- 1:60
base <- list(A = 10, B = 100, xmid = 30, slope = 4, asym = 0.5)
x <- do.call(logistic, c(list(t = t), base)) + rnorm(length(t), 0, 2)
data <- data.frame(t, x)
## visual check
# ggplot2::ggplot(data, ggplot2::aes(x = t, y = x)) +
# ggplot2::geom_line()
model <- nls(x ~ SSlogistic(t, A, B, xmid, slope, asym), data = data)
coefs <- coef(model)
expect_named(coefs, c("A", "B", "xmid", "slope", "asym"))
expect_true(all.equal(coefs[["A"]], base$A, tolerance = 2, scale = 1))
expect_true(all.equal(coefs[["B"]], base$B, tolerance = 2, scale = 1))
expect_true(
all.equal(coefs[["xmid"]], base$xmid, tolerance = 2, scale = 1)
)
expect_true(
all.equal(coefs[["slope"]], base$slope, tolerance = 0.1, scale = 1)
)
expect_true(
all.equal(coefs[["asym"]], base$asym, tolerance = 0.15, scale = 1)
)
})
test_that("SSlogistic() 5-param recovers asym > 0.5 (late acceleration)", {
set.seed(15)
t <- 1:60
base <- list(A = 10, B = 100, xmid = 30, slope = 4, asym = 0.8)
x <- do.call(logistic, c(list(t = t), base)) + rnorm(length(t), 0, 2)
data <- data.frame(t, x)
## visual check
# ggplot2::ggplot(data, ggplot2::aes(x = t, y = x)) +
# ggplot2::geom_line()
model <- nls(x ~ SSlogistic(t, A, B, xmid, slope, asym), data = data)
coefs <- coef(model)
expect_true(all.equal(coefs[["A"]], base$A, tolerance = 2, scale = 1))
expect_true(all.equal(coefs[["B"]], base$B, tolerance = 2, scale = 1))
expect_true(
all.equal(coefs[["xmid"]], base$xmid, tolerance = 2, scale = 1)
)
expect_true(
all.equal(coefs[["slope"]], base$slope, tolerance = 0.25, scale = 1)
)
expect_true(
all.equal(coefs[["asym"]], base$asym, tolerance = 0.15, scale = 1)
)
})
test_that("SSlogistic() 5-param recovers asym < 0.5 (early acceleration)", {
set.seed(15)
t <- 1:60
base <- list(A = 10, B = 100, xmid = 30, slope = 4, asym = 0.8)
x <- do.call(logistic, c(list(t = t), base)) + rnorm(length(t), 0, 2)
data <- data.frame(t, x)
## visual check
# ggplot2::ggplot(data, ggplot2::aes(x = t, y = x)) +
# ggplot2::geom_line()
model <- nls(x ~ SSlogistic(t, A, B, xmid, slope, asym), data = data)
coefs <- coef(model)
expect_true(all.equal(coefs[["A"]], base$A, tolerance = 2, scale = 1))
expect_true(all.equal(coefs[["B"]], base$B, tolerance = 2, scale = 1))
expect_true(
all.equal(coefs[["xmid"]], base$xmid, tolerance = 2, scale = 1)
)
expect_true(
all.equal(coefs[["slope"]], base$slope, tolerance = 0.25, scale = 1)
)
expect_true(
all.equal(coefs[["asym"]], base$asym, tolerance = 0.15, scale = 1)
)
})
test_that("SSlogistic() 4-param converges on most random realisations", {
skip("Manual fit convergence check")
## quantify fit success rate for the 4-param symmetric form;
## should be highly reliable across varied true xmid/slope values
n_rep <- 1000L
t <- 1:60
A <- 10
B <- 100
xmid_grid <- runif(n_rep, min = 15, max = 45)
slope_grid <- runif(n_rep, min = 2, max = 8)
# set.seed(13)
fits <- vapply(seq_len(n_rep), \(i) {
x <- logistic(t, A, B, xmid_grid[i], slope_grid[i]) +
rnorm(length(t), 0, 2)
data <- data.frame(t, x)
model <- tryCatch(
nls(x ~ SSlogistic(t, A, B, xmid, slope), data = data),
error = \(e) NULL,
warning = \(w) NULL
)
if (is.null(model)) {
return(c(success = 0, xmid_err = NA_real_, slope_err = NA_real_))
}
c(
success = 1,
xmid_err = abs(coef(model)[["xmid"]] - xmid_grid[i]),
slope_err = abs(coef(model)[["slope"]] - slope_grid[i])
)
}, numeric(3L))
success_rate <- mean(fits["success", ])
success_rate
expect_true(success_rate >= 0.95)
## of successful fits, params should be recovered within tight tolerance
ok <- fits["success", ] == 1
expect_true(median(fits["xmid_err", ][ok], na.rm = TRUE) < 1)
expect_true(median(fits["slope_err", ][ok], na.rm = TRUE) < 0.5)
})
test_that("SSlogistic() 5-param converges on most random realisations", {
skip("Manual fit convergence check")
## quantify fit success rate across noisy realisations spanning asym range;
## the 5-param model is known to be fragile
n_rep <- 1000L
t <- 1:60
base <- list(A = 10, B = 100, xmid = 30, slope = 4)
asym_grid <- seq(0.3, 0.7, length.out = n_rep)
# set.seed(13)
fits <- vapply(asym_grid, \(a) {
x <- do.call(logistic, c(list(t = t), base, asym = a)) +
rnorm(length(t), 0, 2)
data <- data.frame(t, x)
model <- tryCatch(
nls(x ~ SSlogistic(t, A, B, xmid, slope, asym), data = data),
error = \(e) NULL,
warning = \(w) NULL
)
if (is.null(model)) {
return(c(success = 0, asym_err = NA_real_))
}
c(success = 1, asym_err = abs(coef(model)[["asym"]] - a))
}, numeric(2L))
success_rate <- mean(fits["success", ])
success_rate
## current implementation: report observed rate to flag regressions;
## aim for >= 75% on interior of (0, 1) once init is improved
expect_true(success_rate >= 0.95)
## of successful fits, asym should be recovered within tolerance
converged <- fits["asym_err", ][fits["success", ] == 1]
expect_true(median(converged, na.rm = TRUE) < 0.1)
})
test_that("SSlogistic() fails gracefully on non-sigmoidal data", {
## monotonic linear data has no inflection -> nls should fail
set.seed(13)
t <- 1:60
x <- t + rnorm(length(t), 0, 1)
data <- data.frame(t, x)
expect_error(
nls(x ~ SSlogistic(t, A, B, xmid, slope), data = data)
)
})
## gompertz() / gompertz_left() ===================================
test_that("gompertz() returns correct vector length and type", {
t <- 1:60
result <- gompertz(t, A = 0, B = 100, xmid = 30, slope = 4)
expect_length(result, length(t))
expect_type(result, "double")
})
test_that("gompertz() inflection at xmid equals A + (B - A) / e", {
t <- seq(0, 60, length.out = 1000)
A <- 10
B <- 100
xmid <- 30
result <- gompertz(t, A = A, B = B, xmid = xmid, slope = 4)
y_at_xmid <- result[which.min(abs(t - xmid))]
expect_true(all.equal(
y_at_xmid, A + (B - A) / exp(1), tolerance = 1, scale = 1
))
})
test_that("gompertz_left() inflection at xmid equals A + (B - A) * (1 - 1/e)", {
t <- seq(0, 60, length.out = 1000)
A <- 10
B <- 100
xmid <- 30
result <- gompertz_left(t, A = A, B = B, xmid = xmid, slope = 4)
## visual check
# ggplot2::ggplot(data.frame(), ggplot2::aes(x = t, y = result)) +
# ggplot2::geom_line()
y_at_xmid <- result[which.min(abs(t - xmid))]
expect_true(all.equal(
y_at_xmid, A + (B - A) * (1 - 1 / exp(1)), tolerance = 1, scale = 1
))
})
test_that("gompertz() recovers slope at inflection numerically", {
t <- seq(0, 60, length.out = 10000)
slope <- 4
result <- gompertz(t, A = 10, B = 100, xmid = 30, slope = slope)
## numerical derivative at the inflection
dy_dt <- diff(result) / diff(t)
i_infl <- which.min(abs(t[-1L] - 30))
expect_true(all.equal(
dy_dt[i_infl], slope, tolerance = 0.1, scale = 1
))
})
test_that("gompertz_left() recovers slope at inflection numerically", {
t <- seq(0, 60, length.out = 10000)
slope <- 4
result <- gompertz_left(t, A = 10, B = 100, xmid = 30, slope = slope)
dy_dt <- diff(result) / diff(t)
i_infl <- which.min(abs(t[-1L] - 30))
expect_true(all.equal(
dy_dt[i_infl], slope, tolerance = 0.1, scale = 1
))
})
test_that("gompertz() approaches A and B asymptotes", {
t <- -200:200
result <- gompertz(t, A = 10, B = 100, xmid = 0, slope = 4)
expect_true(all.equal(result[1L], 10, tolerance = 0.5, scale = 1))
expect_true(
all.equal(result[length(result)], 100, tolerance = 0.5, scale = 1)
)
})
test_that("gompertz_left() approaches A and B asymptotes", {
t <- -200:200
result <- gompertz_left(t, A = 10, B = 100, xmid = 0, slope = 4)
expect_true(all.equal(result[1L], 10, tolerance = 0.5, scale = 1))
expect_true(
all.equal(result[length(result)], 100, tolerance = 0.5, scale = 1)
)
})
test_that("gompertz()/gompertz_left() are monotonic for B > A", {
t <- 1:60
right <- gompertz(t, A = 10, B = 100, xmid = 30, slope = 4)
left <- gompertz_left(t, A = 10, B = 100, xmid = 30, slope = 4)
expect_true(all(diff(right) > 0))
expect_true(all(diff(left) > 0))
})
test_that("gompertz()/gompertz_left() handle falling curves (B < A)", {
t <- 1:60
right <- gompertz(t, A = 100, B = 10, xmid = 30, slope = -4)
left <- gompertz_left(t, A = 100, B = 10, xmid = 30, slope = -4)
expect_true(all(diff(right) < 0))
expect_true(all(diff(left) < 0))
})
## SSgompertz() / SSgompertz_left() ================================
test_that("SSgompertz() recovers parameters", {
set.seed(13)
t <- 1:60
base <- list(A = 10, B = 100, xmid = 30, slope = 4)
x <- do.call(gompertz, c(list(t = t), base)) + rnorm(length(t), 0, 2)
data <- data.frame(t, x)
model <- nls(x ~ SSgompertz(t, A, B, xmid, slope), data = data)
expect_s3_class(model, "nls")
expect_named(coef(model), c("A", "B", "xmid", "slope"))
coefs <- coef(model)
expect_true(all.equal(coefs[["A"]], base$A, tolerance = 1, scale = 1))
expect_true(all.equal(coefs[["B"]], base$B, tolerance = 1, scale = 1))
expect_true(all.equal(coefs[["xmid"]], base$xmid, tolerance = 1, scale = 1))
expect_true(
all.equal(coefs[["slope"]], base$slope, tolerance = 0.1, scale = 1)
)
})
test_that("SSgompertz_left() recovers parameters", {
set.seed(13)
t <- 1:60
base <- list(A = 10, B = 100, xmid = 30, slope = 4)
x <- do.call(gompertz_left, c(list(t = t), base)) + rnorm(length(t), 0, 2)
data <- data.frame(t, x)
model <- nls(x ~ SSgompertz_left(t, A, B, xmid, slope), data = data)
expect_s3_class(model, "nls")
expect_named(coef(model), c("A", "B", "xmid", "slope"))
coefs <- coef(model)
expect_true(all.equal(coefs[["A"]], base$A, tolerance = 1, scale = 1))
expect_true(all.equal(coefs[["B"]], base$B, tolerance = 1, scale = 1))
expect_true(all.equal(coefs[["xmid"]], base$xmid, tolerance = 1, scale = 1))
expect_true(
all.equal(coefs[["slope"]], base$slope, tolerance = 0.1, scale = 1)
)
})
test_that("SSgompertz() handles falling curves (B < A)", {
set.seed(456)
t <- 1:60
base <- list(A = 100, B = 10, xmid = 30, slope = -4)
x <- do.call(gompertz, c(list(t = t), base)) + rnorm(length(t), 0, 2)
data <- data.frame(t, x)
model <- nls(x ~ SSgompertz(t, A, B, xmid, slope), data = data)
coefs <- coef(model)
expect_s3_class(model, "nls")
expect_true(all.equal(coefs[["A"]], base$A, tolerance = 1, scale = 1))
expect_true(all.equal(coefs[["B"]], base$B, tolerance = 2, scale = 1))
expect_true(all.equal(coefs[["xmid"]], base$xmid, tolerance = 1, scale = 1))
expect_true(
all.equal(coefs[["slope"]], base$slope, tolerance = 0.1, scale = 1)
)
})
test_that("SSlogistic()/SSgompertz() gradients match numericDeriv", {
t <- seq(0, 120, by = 0.5)
env <- list2env(list(t = t, A = 10, B = 100, xmid = 40, slope = 4))
pars <- c("A", "B", "xmid", "slope")
chk <- function(expr) {
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(SSlogistic(t, A, B, xmid, slope)))
chk(quote(SSgompertz(t, A, B, xmid, slope)))
chk(quote(SSgompertz_left(t, A, B, xmid, slope)))
## a fixed parameter contributes no gradient column
an <- attr(eval(quote(SSgompertz(t, A = 10, B, xmid, slope)), env), "gradient")
expect_identical(colnames(an), c("B", "xmid", "slope"))
## the 5-parameter form and the plain model fns carry no gradient
expect_null(attr(eval(quote(SSlogistic(t, A, B, xmid, slope, asym = 0.3)), env), "gradient"))
expect_null(attr(logistic(t, 10, 100, 40, 4), "gradient"))
expect_null(attr(gompertz(t, 10, 100, 40, 4), "gradient"))
})
## init_inflection() fallbacks =======================================
test_that("init_inflection() falls back to sign() slope when t_range == 0", {
## identical t -> non-finite derivative -> slope fallback;
## t_range == 0 so slope is the direction sign of (B - A)
x <- 1:6
t <- rep(5, 6)
up <- init_inflection(x, t, A_init = 1, B_init = 6)
expect_equal(up$slope, 1)
down <- init_inflection(x, t, A_init = 6, B_init = 1)
expect_equal(down$slope, -1)
})
test_that("init_inflection() falls back to mean-rate slope on flat response", {
## flat x -> zero smoothed derivative -> slope fallback;
## t_range > 0 so slope is the mean rate (B - A) / t_range
x <- rep(3, 6)
t <- 0:5
res <- init_inflection(x, t, A_init = 0, B_init = 10)
expect_equal(res$slope, 10 / 5)
expect_true(is.finite(res$xmid))
})
test_that("init_inflection() falls back to half-response point on non-finite xmid", {
## derivative peak lands on a non-finite `t` -> xmid_init non-finite ->
## fallback to the half-response point x = (A + B) / 2
x <- c(0, 0, 0, 0, 100, 50, 50, 50)
t <- c(0, 1, Inf, 3, 4, 5, 6, 7)
res <- init_inflection(x, t, A_init = 0, B_init = 100)
expect_true(is.finite(res$xmid))
## first t where x reaches the half-response (50) is index 6, t = 5
expect_equal(res$xmid, 5)
})
test_that("init_inflection() returns derivative-based estimates on clean sigmoid", {
## non-degenerate data bypasses both fallbacks
t <- seq(0, 60, length.out = 200)
x <- gompertz(t, A = 10, B = 100, xmid = 30, slope = 4)
res <- init_inflection(x, t, A_init = 10, B_init = 100)
expect_true(all.equal(res$xmid, 30, tolerance = 3, scale = 1))
expect_true(res$slope > 0)
})
## logistic_init() fallbacks =========================================
## helper: invoke logistic_init() as selfStart() would, building the
## matched call and named data frame it expects
call_logistic_init <- function(x, t, asym = FALSE) {
data <- data.frame(.t = t, .x = x)
## logistic_init() reads mCall[["t"]] by name and detects the 5-param
## branch via "asym" %in% names(mCall), so args must be named
args <- list(
t = quote(.t),
A = quote(A),
B = quote(B),
xmid = quote(xmid),
slope = quote(slope)
)
if (asym) {
args <- c(args, list(asym = quote(asym)))
}
mCall <- as.call(c(quote(logistic), args))
logistic_init(mCall, data, quote(.x))
}
test_that("logistic_init() falls back to mean-rate slope when linearisation fails", {
## only 2 distinct points -> fewer than 3 finite linearisation points
## -> xmid_init/slope_init stay NA -> both fallbacks fire. t_range > 0
## so slope is the mean rate (B - A) / t_range == 100 / 10 == 10
x <- c(0, 100)
t <- c(0, 10)
res <- call_logistic_init(x, t)
expect_equal(res[["slope"]], 10)
expect_true(res[["xmid"]] >= min(t) && res[["xmid"]] <= max(t))
})
test_that("logistic_init() mean-rate slope is negative for falling data", {
## same degenerate path, falling direction -> negative slope fallback
## (B - A) / t_range == -100 / 10 == -10
x <- c(100, 0)
t <- c(0, 10)
res <- call_logistic_init(x, t)
expect_equal(res[["slope"]], -10)
expect_true(is.finite(res[["xmid"]]))
})
test_that("logistic_init() xmid fallback pins out-of-range xmid to t range", {
## only the early tail of a sigmoid is observed, so the linearised
## xmid extrapolates past max(t); fallback pins it into [min(t), max(t)]
t <- 1:30
x <- logistic(t, A = 10, B = 100, xmid = 80, slope = 4)
res <- call_logistic_init(x, t)
expect_true(res[["xmid"]] >= min(t) && res[["xmid"]] <= max(t))
expect_true(is.finite(res[["slope"]]))
})
test_that("logistic_init() uses linearisation on a clean sigmoid", {
## non-degenerate data bypasses both fallbacks and recovers params
t <- 1:60
x <- logistic(t, A = 10, B = 100, xmid = 30, slope = 4)
res <- call_logistic_init(x, t)
expect_true(all.equal(res[["A"]], 10, tolerance = 5, scale = 1))
expect_true(all.equal(res[["B"]], 100, tolerance = 5, scale = 1))
expect_true(all.equal(res[["xmid"]], 30, tolerance = 5, scale = 1))
expect_true(res[["slope"]] > 0)
})
test_that("logistic_init() 5-param branch returns asym estimate", {
t <- 1:60
x <- logistic(t, A = 10, B = 100, xmid = 30, slope = 4, asym = 0.5)
res <- call_logistic_init(x, t, asym = TRUE)
expect_named(res, c("A", "B", "xmid", "slope", "asym"))
expect_true(res[["asym"]] >= 0.1 && res[["asym"]] <= 0.9)
expect_true(res[["xmid"]] >= min(t) && res[["xmid"]] <= max(t))
})
## analyse_logistic() ===================================================
## helper: create logistic test data with known parameters
create_logistic_data <- function(
A = 10,
B = 100,
xmid = 30,
slope = 4,
asym = NULL,
n = 60,
sample_rate = 1,
noise_sd = 2,
channels = "smo2",
seed = 13
) {
set.seed(seed)
t <- seq(0, (n - 1) / sample_rate, length.out = n)
x <- logistic(t, A, B, xmid, slope, asym) + 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]] <- logistic(t, A + 5, B + 5, xmid, slope, asym) +
rnorm(n, 0, noise_sd)
}
}
create_mnirs_data(
df,
nirs_channels = channels,
time_channel = "time",
sample_rate = sample_rate
)
}
test_that("analyse_logistic() returns correct structure", {
data <- create_logistic_data()
# plot(data)
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
verbose = FALSE
)
expect_s3_class(result, "data.frame")
expect_named(result, c(
"interval", "nirs_channels", "A", "B", "xmid", "slope", "xmid_fitted"
))
expect_equal(nrow(result), 1L)
## attributes
expect_type(attr(result, "model"), "list")
expect_true(inherits(attr(result, "model")$smo2, "nls"))
expect_type(attr(result, "fitted_data"), "list")
## predicted data frame for each `nirs_channels`
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")
expect_equal(nrow(attr(result, "channel_args")), 1L)
})
test_that("analyse_logistic() validates shape argument", {
data <- create_logistic_data()
expect_error(
analyse_logistic(
data,
nirs_channels = "smo2",
shape = "not_a_shape"
)
)
})
test_that("analyse_logistic() recovers symmetric known parameters", {
A <- 10
B <- 100
xmid <- 30
slope <- 4
data <- create_logistic_data(
A = A, B = B, xmid = xmid, slope = slope, n = 100, noise_sd = 1
)
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
verbose = FALSE
)
expect_true(all.equal(result$A, A, tolerance = 1, scale = 1))
expect_true(all.equal(result$B, B, tolerance = 1, scale = 1))
expect_true(all.equal(result$xmid, xmid, tolerance = 1, scale = 1))
expect_true(all.equal(result$slope, slope, tolerance = 0.2, scale = 1))
})
test_that("analyse_logistic() recovers gompertz known parameters", {
A <- 10
B <- 100
xmid <- 30
slope <- 4
set.seed(13)
n <- 100
t <- seq(0, n - 1, length.out = n)
x <- gompertz(t, A, B, xmid, slope) + rnorm(n, 0, 1)
df <- data.frame(time = t, smo2 = x)
data <- create_mnirs_data(
df, nirs_channels = "smo2", time_channel = "time", sample_rate = 1
)
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "gompertz",
verbose = FALSE
)
expect_true(all.equal(result$A, A, tolerance = 2, scale = 1))
expect_true(all.equal(result$B, B, tolerance = 2, scale = 1))
expect_true(all.equal(result$xmid, xmid, tolerance = 2, scale = 1))
expect_true(all.equal(result$slope, slope, tolerance = 0.3, scale = 1))
})
test_that("analyse_logistic() recovers gompertz_left known parameters", {
A <- 10
B <- 100
xmid <- 30
slope <- 4
set.seed(13)
n <- 100
t <- seq(0, n - 1, length.out = n)
x <- gompertz_left(t, A, B, xmid, slope) + rnorm(n, 0, 1)
df <- data.frame(time = t, smo2 = x)
data <- create_mnirs_data(
df, nirs_channels = "smo2", time_channel = "time", sample_rate = 1
)
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "gompertz_left",
verbose = FALSE
)
expect_true(all.equal(result$A, A, tolerance = 2, scale = 1))
expect_true(all.equal(result$B, B, tolerance = 2, scale = 1))
expect_true(all.equal(result$xmid, xmid, tolerance = 2, scale = 1))
expect_true(all.equal(result$slope, slope, tolerance = 0.3, scale = 1))
})
test_that("analyse_logistic() uses start_time correctly", {
A <- 10
B <- 100
xmid <- 30
slope <- 4
start_time <- 12
data <- create_logistic_data(
A = A, B = B, xmid = xmid, slope = slope, n = 100, noise_sd = 1
)
data$time <- data$time + start_time
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
start_time = start_time,
verbose = FALSE
)
## xmid reported as offset from start_time — should match original xmid
expect_true(all.equal(result$A, A, tolerance = 1, scale = 1))
expect_true(all.equal(result$B, B, tolerance = 1, scale = 1))
expect_true(all.equal(result$xmid, xmid, tolerance = 1, scale = 1))
expect_true(all.equal(result$slope, slope, tolerance = 0.2, scale = 1))
})
test_that("analyse_logistic() start_time edge cases", {
A <- 10
B <- 100
xmid <- 30
slope <- 4
start_time <- 12
data <- create_logistic_data(
A = A, B = B, xmid = xmid, slope = slope, n = 100, noise_sd = 1
)
data$time <- data$time + start_time
## start_time specified before time start, falls forward to t[1L]
expect_warning(
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
start_time = 0,
verbose = TRUE
),
"start_time.*before first valid.*time_channel"
)
expect_true(all.equal(result$xmid, xmid, tolerance = 1, scale = 1))
## start_time beyond time range
expect_error(
analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
start_time = max(data$time) + 10,
verbose = TRUE
),
"No observations.*before.*start_time"
)
})
test_that("analyse_logistic() returns NA for failed fit", {
## only 10 observations for a 4-param model with near-zero noise —
## insufficient distinct response to converge
custom_name <- create_logistic_data(n = 10, noise_sd = 0.1)
expect_warning(
result <- analyse_logistic(
custom_name,
nirs_channels = "smo2",
shape = "symmetric"
),
"fit failed for.*smo2.*custom_name" ## call custom interval name
)
expect_true(is.na(result$A))
expect_true(is.na(result$xmid))
expect_true(is.na(result$slope))
})
test_that("analyse_logistic() suppresses fit-failure warning when verbose = FALSE", {
## small n + low noise — guaranteed fit failure
custom_name <- create_logistic_data(n = 10, noise_sd = 0.1)
expect_no_warning(
analyse_logistic(
custom_name,
nirs_channels = "smo2",
shape = "symmetric",
verbose = FALSE
)
)
})
test_that("analyse_logistic() works with multiple channels", {
nirs_channels <- c("smo2_left", "smo2_right")
data <- create_logistic_data(channels = nirs_channels)
result <- analyse_logistic(
data,
nirs_channels = nirs_channels,
shape = "symmetric",
verbose = FALSE
)
expect_equal(nrow(result), 2L)
expect_equal(result$nirs_channels, nirs_channels)
fitted_data <- attr(result, "fitted_data")
expect_length(fitted_data, 2L)
expect_named(fitted_data, nirs_channels)
})
test_that("analyse_logistic() channel_args override defaults", {
data <- create_logistic_data(
channels = c("ch1", "ch2"), n = 100, noise_sd = 1
)
result <- analyse_logistic(
data,
nirs_channels = c("ch1", "ch2"),
shape = list("symmetric", ch2 = "gompertz"),
verbose = FALSE
)
expect_equal(nrow(result), 2L)
ca <- attr(result, "channel_args")
ch1_row <- ca[ca$nirs_channels == "ch1", ]
ch2_row <- ca[ca$nirs_channels == "ch2", ]
expect_equal(ch1_row$shape, "symmetric")
expect_equal(ch2_row$shape, "gompertz")
})
test_that("analyse_logistic() fix resolves per channel", {
## ch2 asymptotes are A + 5 and B + 5 by construction
data <- create_logistic_data(
A = 10, B = 100, channels = c("ch1", "ch2"), n = 100, noise_sd = 1
)
result <- analyse_logistic(
data,
nirs_channels = c("ch1", "ch2"),
fix = list(ch1 = list(A = 10), ch2 = list(A = 15)),
verbose = FALSE
)
expect_equal(result$A, c(10, 15))
models <- attr(result, "model")
expect_false("A" %in% names(coef(models$ch1)))
expect_false("A" %in% names(coef(models$ch2)))
ca <- attr(result, "channel_args")
expect_equal(ca$fix, c("list(A = 10)", "list(A = 15)"))
})
test_that("analyse_logistic() fitted_data attribute is well-formed", {
data <- create_logistic_data(n = 100, noise_sd = 1)
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
verbose = FALSE
)
fitted_data <- attr(result, "fitted_data")
expect_named(fitted_data, "smo2")
expect_named(fitted_data$smo2, c("window_idx", "fitted"))
expect_type(fitted_data$smo2$fitted, "double")
## fitted should correlate and agree well with original data
expect_true(cor(data$smo2, fitted_data$smo2$fitted) > 0.95)
expect_all_true(abs(data$smo2 - fitted_data$smo2$fitted) <= 3)
})
test_that("analyse_logistic() diagnostics contain expected columns", {
data <- create_logistic_data()
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
verbose = FALSE
)
diag <- attr(result, "diagnostics")
expect_true(all(
c("nirs_channels", "n_obs", "r2", "adj_r2", "rmse") %in% names(diag)
))
expect_true(diag$r2 > 0.9)
})
## direction =====================================================
test_that("analyse_logistic() direction = 'negative' matches auto on falling data", {
data <- create_logistic_data(
A = 100, B = 10, slope = -4, n = 100, noise_sd = 1
)
result_auto <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
direction = "auto",
verbose = FALSE
)
result_neg <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
direction = "negative",
verbose = FALSE
)
## matching direction leaves the unconstrained fit untouched
expect_equal(result_auto$A, result_neg$A)
expect_equal(result_auto$B, result_neg$B)
expect_equal(result_auto$slope, result_neg$slope)
expect_true(result_auto$B < result_auto$A)
expect_true(result_auto$slope < 0)
})
test_that("analyse_logistic() direction = 'positive' rejects falling fit", {
data <- create_logistic_data(
A = 100, B = 10, slope = -4, n = 100, noise_sd = 1
)
expect_warning(
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
direction = "positive",
verbose = TRUE
),
"satisfy"
)
## never returns an inverted (B < A) fit against requested direction
expect_true(is.na(result$A))
expect_true(is.na(result$B))
expect_true(is.na(result$slope))
})
test_that("analyse_logistic() suppresses direction warning when verbose = FALSE", {
data <- create_logistic_data(
A = 100, B = 10, slope = -4, n = 100, noise_sd = 1
)
expect_no_warning(
analyse_logistic(
data,
nirs_channels = "smo2",
shape = "symmetric",
direction = "positive",
verbose = FALSE
)
)
})
test_that("analyse_logistic() direction constrains gompertz shapes", {
## falling gompertz data; forced positive returns NA
set.seed(13)
n <- 100
t <- seq(0, n - 1, length.out = n)
x <- gompertz(t, A = 100, B = 10, xmid = 30, slope = -4) + rnorm(n, 0, 1)
data <- create_mnirs_data(
data.frame(time = t, smo2 = x),
nirs_channels = "smo2",
time_channel = "time",
sample_rate = 1
)
expect_warning(
result <- analyse_logistic(
data,
nirs_channels = "smo2",
shape = "gompertz",
direction = "positive",
verbose = TRUE
),
"satisfy"
)
expect_true(is.na(result$A))
})
test_that("analyse_kinetics() passes direction to sigmoidal method", {
data <- create_logistic_data(
A = 100, B = 10, slope = -4, n = 100, noise_sd = 1
)
expect_warning(
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "sigmoidal",
shape = "symmetric",
direction = "positive"
),
"satisfy"
)
expect_true(is.na(result$coefficients$A))
})
## fixed parameters ==============================================
test_that("SSlogistic() fixes parameters as constants", {
set.seed(15)
t <- 1:60
x <- logistic(t, A = 0, B = 100, xmid = 30, slope = 4) +
rnorm(length(t), 0, 2)
data <- data.frame(t, x)
model <- nls(x ~ SSlogistic(t, A = 0, B, xmid, slope), data = data)
expect_s3_class(model, "nls")
expect_named(coef(model), c("B", "xmid", "slope"))
expect_true(all.equal(coef(model)[["B"]], 100, tolerance = 5, scale = 1))
expect_true(all.equal(coef(model)[["xmid"]], 30, tolerance = 2, scale = 1))
## fix xmid instead of A
model_xmid <- nls(x ~ SSlogistic(t, A, B, xmid = 30, slope), data = data)
expect_named(coef(model_xmid), c("A", "B", "slope"))
})
test_that("SSgompertz() / SSgompertz_left() fix parameters as constants", {
set.seed(15)
t <- 1:60
x <- gompertz(t, A = 0, B = 100, xmid = 30, slope = 4) +
rnorm(length(t), 0, 2)
data <- data.frame(t, x)
model <- nls(x ~ SSgompertz(t, A = 0, B, xmid, slope), data = data)
expect_named(coef(model), c("B", "xmid", "slope"))
expect_true(all.equal(coef(model)[["B"]], 100, tolerance = 5, scale = 1))
set.seed(16)
x2 <- gompertz_left(t, A = 0, B = 100, xmid = 30, slope = 4) +
rnorm(length(t), 0, 2)
data2 <- data.frame(t, x = x2)
model_left <- nls(
x ~ SSgompertz_left(t, A = 0, B, xmid, slope),
data = data2
)
expect_named(coef(model_left), c("B", "xmid", "slope"))
})
test_that("analyse_logistic() fix holds parameters constant", {
data <- create_logistic_data(
A = 0, B = 100, xmid = 30, slope = 4, noise_sd = 1
)
result <- analyse_logistic(
data,
nirs_channels = "smo2",
fix = list(A = 0),
verbose = FALSE
)
expect_equal(result$A, 0)
expect_equal(result$B, 100, tolerance = 5)
expect_equal(result$xmid, 30, tolerance = 2)
## fixed A excluded from the fitted model coefficients
expect_named(coef(attr(result, "model")$smo2), c("B", "xmid", "slope"))
expect_equal(attr(result, "channel_args")$fix, "list(A = 0)")
})
test_that("analyse_logistic() validates fix argument", {
data <- create_logistic_data()
expect_error(
analyse_logistic(data, nirs_channels = "smo2", fix = list(tau = 5)),
"not recognised"
)
expect_error(
analyse_logistic(
data, nirs_channels = "smo2",
fix = list(A = 0, B = 100, xmid = 30, slope = 4)
),
"Nothing to estimate"
)
})
test_that("analyse_kinetics() passes fix to the sigmoidal method", {
data <- create_logistic_data(
A = 0, B = 100, xmid = 30, slope = 4, noise_sd = 1
)
result <- analyse_kinetics(
data,
nirs_channels = "smo2",
method = "sigmoidal",
fix = list(A = 0),
verbose = FALSE
)
expect_equal(result$coefficients$A, 0)
expect_named(coef(result$model[[1L]]$smo2), c("B", "xmid", "slope"))
})
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.