R/ct_fit_encounter_response.R

Defines functions print.summary.ct_encounter_response summary.ct_encounter_response print.ct_encounter_response .ct_encounter_mask_empty .ct_encounter_estimates .ct_encounter_lag_group .ct_encounter_lag_labels .ct_encounter_dispersion .ct_encounter_coef .ct_encounter_fit .ct_encounter_prepare .ct_independent_encounters .ct_encounter_irls ct_fit_encounter_response

Documented in ct_fit_encounter_response

#' Fit a conditional encounter-response model for camera-trap events
#'
#' Estimates whether encounters of a response species become more or less likely
#' in the minutes to hours after encounters of a trigger species at the same
#' camera. Unlike a diel overlap coefficient -- which only compares two species'
#' marginal activity curves -- this is a directed, time-lagged association: it
#' contrasts the response encounter rate in successive windows after a trigger
#' with that camera's expected rate at the same time of day and month. It
#' quantifies association, not causation.
#'
#' @details
#' **What is being approximated.** The quantity of interest -- "does an encounter
#' of species A raise or lower the short-term encounter intensity of species B?"
#' -- is formally a *mutually exciting (Hawkes-type) point process*. Fitting such
#' a process directly is data-hungry and fragile with sparse camera-trap
#' detections, so this function uses a transparent discrete-time approximation:
#' camera uptime is sliced into short intervals, the number of response
#' detections per interval is modelled as a count, and time-since-last-trigger is
#' entered as a step function (`lag_breaks`). Because it is an approximation, the
#' interval width matters -- always inspect `interval_sensitivity` before
#' interpreting an effect.
#'
#' **How confounders are handled.** Camera uptime enters as an offset so unequal
#' effort is accounted for; circular (harmonic) terms hold the shared diel
#' activity curve constant; a month factor absorbs broad seasonal variation; and
#' each camera contributes only *within-camera* temporal contrasts, so
#' time-invariant differences in habitat, placement and baseline abundance are
#' differenced out. With `engine = "glm"` cameras are fixed effects; with
#' `engine = "glmm"` they are random intercepts (partial pooling), which is more
#' efficient with many sparse cameras and avoids the incidental-parameter problem
#' of many fixed effects.
#'
#' **Inference.** Successive intervals at one camera are serially correlated and
#' response detections cluster in time. A quasi-Poisson dispersion correction
#' rescales for overdispersion but *not* for this autocorrelation, so model-based
#' intervals from `engine = "glm"` are optimistic. The default inference is
#' therefore a **camera-block bootstrap** (`n_boot`), which resamples whole
#' cameras and so respects within-camera dependence; treat those intervals as the
#' primary uncertainty. With `engine = "glmm"` the camera random effect already
#' propagates between-camera variance, so the bootstrap is skipped.
#'
#' **The dominant limitation -- shared transient drivers.** The controls above
#' remove *stable* and *diel/seasonal* confounding, but they cannot remove
#' *episodic* shared drivers: a fruiting tree, a waterhole, a prey pulse,
#' moonlight or a weather front can draw both species independently within the
#' same hours and manufacture an apparent "response" with no behavioural
#' interaction whatsoever. Detection is also a thinned, imperfect observation of
#' presence, so a shift in detection rate need not reflect a shift in true site
#' use. For these reasons the result is an *association conditional on the fitted
#' baseline*, never evidence of a direct behavioural or causal effect. Corroborate
#' with randomization or simulation checks, and never treat deployments
#' reconstructed from first/last detections as camera uptime.
#'
#' **Independence filtering.** `independence` collapses repeat detections of the
#' same species at the same camera that fall within a short window -- these are
#' usually one animal lingering in front of the sensor, i.e. one biological
#' event. Note the tension with this model: unlike activity-level estimation
#' (where 30 min is conventional), a long filter here would erase exactly the
#' rapid successive detections that a short-term response would produce. The
#' default is therefore deliberately small (2 minutes): long enough to merge a
#' single pass into one event, short enough to preserve genuine reactivity in the
#' first lag window. Set `independence = 0` to keep every record.
#'
#' @param data A data frame with one row per camera-trap record (the observation
#'   data).
#' @param deployment A data frame of actual camera deployment intervals. It must
#'   contain the camera ID, start, and end columns. Intervals for the same camera
#'   may not overlap.
#' @param trigger Character scalar naming the species whose event initiates a
#'   response window.
#' @param response Character scalar naming the species whose encounter rate is
#'   modelled.
#' @param species_column Unquoted column in `data` giving the species name.
#' @param cam_column Unquoted column giving the camera ID. This column must be
#'   present, with the same name, in both `data` and `deployment` so that records
#'   can be matched to their deployment intervals.
#' @param datetime_column Unquoted column in `data` giving the event date-time.
#' @param start_column,end_column Unquoted columns in `deployment` giving the
#'   deployment interval start and end date-times respectively.
#' @param interval Width of analysis intervals in seconds. It must not exceed the
#'   narrowest response-lag window.
#' @param lag_breaks Numeric vector of lag boundaries in seconds. The default
#'   estimates associations for 0--30 minutes, 30 minutes--2 hours, and 2--6
#'   hours after a trigger. Time after the final boundary is the reference.
#' @param independence Minimum number of seconds between retained records of the
#'   same species at the same camera. Defaults to 2 minutes; set to `0` to retain
#'   every record. See Details for why this default is smaller than the value used
#'   for activity-level estimation.
#' @param engine Fitting engine. `"glm"` (default) fits a camera-stratified
#'   quasi-Poisson GLM with camera fixed effects and a camera-block bootstrap.
#'   `"glmm"` fits a Poisson/negative-binomial mixed model with camera (and, where
#'   present, deployment) random intercepts, using \pkg{glmmTMB} if installed and
#'   otherwise \pkg{lme4}.
#' @param n_boot Number of camera-block bootstrap replicates used for percentile
#'   confidence intervals. If `NULL` (default) it is set to 199 for
#'   `engine = "glm"` and to 0 for `engine = "glmm"`. Set to `0` to return
#'   model-based intervals only.
#' @param interval_sensitivity Optional numeric vector of alternative `interval`
#'   widths (seconds). When supplied, the lag-specific rate ratios are refitted at
#'   each width and returned in `$sensitivity`, so the reader can judge how far the
#'   conclusion depends on the binning choice. Each value must not exceed the
#'   narrowest lag window.
#' @param seed Optional integer seed for the bootstrap.
#'
#' @return An object of class `ct_encounter_response` containing the fitted model,
#'   lag-specific rate ratios, analysis-interval data, an optional bin-width
#'   sensitivity table, and settings. A rate ratio below one indicates fewer
#'   response encounters than expected after a trigger, conditional on the fitted
#'   baseline terms.
#'
#' @references
#' Hawkes, A. G. (1971). Spectra of some self-exciting and mutually exciting point
#' processes. \emph{Biometrika}, 58(1), 83--90. \doi{10.1093/biomet/58.1.83}
#'
#' Ridout, M. S., & Linkie, M. (2009). Estimating overlap of daily activity
#' patterns from camera trap data. \emph{Journal of Agricultural, Biological, and
#' Environmental Statistics}, 14(3), 322--337. \doi{10.1198/jabes.2009.08038}
#'
#' @examples
#' \dontrun{
#' data(ACBR)
#' fit <- ct_fit_encounter_response(
#'   data = ACBR$acbr_data,
#'   deployment = ACBR$deployment,
#'   trigger = "Cercopithecus mona", response = "Tragelaphus spekii",
#'   species_column = species, cam_column = cam, datetime_column = datetime,
#'   start_column = start, end_column = end
#' )
#' summary(fit)
#'
#' # Random-effects engine plus a bin-width sensitivity check
#' fit_glmm <- ct_fit_encounter_response(
#'   data = ACBR$acbr_data,
#'   deployment = ACBR$deployment,
#'   trigger = "Cercopithecus mona", response = "Tragelaphus spekii",
#'   species_column = species, cam_column = cam, datetime_column = datetime,
#'   start_column = start, end_column = end,
#'   engine = "glmm",
#'   interval_sensitivity = c(5 * 60, 10 * 60, 30 * 60)
#' )
#' fit_glmm$sensitivity
#' }
#'
#' @export
ct_fit_encounter_response <- function(data,
                                      deployment,
                                      trigger,
                                      response,
                                      species_column,
                                      cam_column,
                                      datetime_column,
                                      start_column,
                                      end_column,
                                      interval = 15 * 60,
                                      lag_breaks = c(0, 30 * 60, 2 * 3600, 6 * 3600),
                                      independence = 2 * 60,
                                      engine = c("glm", "glmm"),
                                      n_boot = NULL,
                                      interval_sensitivity = NULL,
                                      seed = NULL) {
  engine <- match.arg(engine)
  if (!is.data.frame(data) || !is.data.frame(deployment)) {
    cli::cli_abort("`data` and `deployment` must both be data frames.")
  }
  if (!is.character(trigger) || length(trigger) != 1 ||
      !is.character(response) || length(response) != 1 || trigger == response) {
    cli::cli_abort("`trigger` and `response` must be distinct character scalars.")
  }
  if (!is.numeric(interval) || length(interval) != 1 || interval <= 0 ||
      !is.numeric(independence) || length(independence) != 1 || independence < 0) {
    cli::cli_abort("`interval` must be positive and `independence` must be non-negative.")
  }
  if (!is.numeric(lag_breaks) || length(lag_breaks) < 2 || lag_breaks[1] != 0 ||
      any(!is.finite(lag_breaks)) || any(diff(lag_breaks) <= 0)) {
    cli::cli_abort("`lag_breaks` must be increasing, finite, and start at 0.")
  }
  if (interval > min(diff(lag_breaks))) {
    cli::cli_abort("`interval` must not exceed the narrowest lag window.")
  }
  if (!is.null(interval_sensitivity)) {
    if (!is.numeric(interval_sensitivity) || any(!is.finite(interval_sensitivity)) ||
        any(interval_sensitivity <= 0)) {
      cli::cli_abort("`interval_sensitivity` must be a vector of positive, finite seconds.")
    }
    if (any(interval_sensitivity > min(diff(lag_breaks)))) {
      cli::cli_abort("Every `interval_sensitivity` value must not exceed the narrowest lag window.")
    }
  }

  # Resolve inference defaults: bootstrap is primary for the fixed-effects GLM,
  # while the mixed model carries between-camera uncertainty in its random effect.
  if (is.null(n_boot)) n_boot <- if (engine == "glm") 199L else 0L
  if (!is.numeric(n_boot) || length(n_boot) != 1 || n_boot < 0 || n_boot != as.integer(n_boot)) {
    cli::cli_abort("`n_boot` must be a non-negative integer.")
  }
  n_boot <- as.integer(n_boot)
  if (engine == "glmm") {
    if (!requireNamespace("glmmTMB", quietly = TRUE) &&
        !requireNamespace("lme4", quietly = TRUE)) {
      cli::cli_abort(c(
        "Package {.pkg glmmTMB} or {.pkg lme4} is required for {.code engine = \"glmm\"}.",
        "i" = "Install one, e.g. {.code install.packages(\"glmmTMB\")}."
      ))
    }
    if (n_boot > 0) {
      cli::cli_inform("Ignoring {.code n_boot} for {.code engine = \"glmm\"}; the camera random effect already propagates between-camera uncertainty.")
      n_boot <- 0L
    }
  }

  # Resolve column names from unquoted arguments. `cam_column` is shared: it must
  # name the same camera-ID column in both `data` and `deployment`.
  if (missing(species_column) || missing(cam_column) || missing(datetime_column)) {
    cli::cli_abort("`species_column`, `cam_column`, and `datetime_column` must be supplied as unquoted columns of `data`.")
  }
  if (missing(start_column) || missing(end_column)) {
    cli::cli_abort("`start_column` and `end_column` must be supplied as unquoted columns of `deployment`.")
  }
  species_col <- rlang::as_name(rlang::ensym(species_column))
  camera_col <- rlang::as_name(rlang::ensym(cam_column))
  datetime_col <- rlang::as_name(rlang::ensym(datetime_column))
  start_col <- rlang::as_name(rlang::ensym(start_column))
  end_col <- rlang::as_name(rlang::ensym(end_column))
  required_data <- c(species_col, camera_col, datetime_col)
  required_deployment <- c(camera_col, start_col, end_col)
  missing_data <- setdiff(required_data, names(data))
  missing_deployment <- setdiff(required_deployment, names(deployment))
  if (length(missing_data)) {
    cli::cli_abort("`data` is missing column{?s} {.val {missing_data}}.")
  }
  if (length(missing_deployment)) {
    cli::cli_abort("`deployment` is missing column{?s} {.val {missing_deployment}}.")
  }

  events <- data.frame(
    camera_id = as.character(data[[camera_col]]),
    species = as.character(data[[species_col]]),
    time = as.POSIXct(data[[datetime_col]], tz = "UTC"),
    stringsAsFactors = FALSE
  )
  events <- events[events$species %in% c(trigger, response) &
                   !is.na(events$camera_id) & !is.na(events$time), , drop = FALSE]
  if (!all(c(trigger, response) %in% events$species)) {
    cli::cli_abort("Both `trigger` and `response` must occur in `data`.")
  }

  deployments <- data.frame(
    camera_id = as.character(deployment[[camera_col]]),
    start = as.POSIXct(deployment[[start_col]], tz = "UTC"),
    end = as.POSIXct(deployment[[end_col]], tz = "UTC"),
    stringsAsFactors = FALSE
  )
  deployments <- deployments[!is.na(deployments$camera_id) & !is.na(deployments$start) &
                               !is.na(deployments$end), , drop = FALSE]
  if (!nrow(deployments) || any(deployments$end <= deployments$start)) {
    cli::cli_abort("Deployment intervals must have non-missing starts and ends with `end > start`.")
  }
  deployments <- deployments[order(deployments$camera_id, deployments$start), , drop = FALSE]
  for (cam in unique(deployments$camera_id)) {
    x <- deployments[deployments$camera_id == cam, , drop = FALSE]
    if (nrow(x) > 1 && any(x$start[-1] < x$end[-nrow(x)])) {
      cli::cli_abort("Deployment intervals overlap for camera {.val {cam}}.")
    }
  }
  if (engine == "glmm" && length(unique(deployments$camera_id)) < 2 &&
      nrow(deployments) < 2) {
    cli::cli_abort("{.code engine = \"glmm\"} needs more than one camera or deployment to estimate a random effect.")
  }

  events <- .ct_independent_encounters(events, independence)
  deployments$deployment_id <- seq_len(nrow(deployments))
  lag_labels <- .ct_encounter_lag_labels(lag_breaks)

  interval_data <- .ct_encounter_prepare(events, deployments, trigger, response,
                                          interval, lag_breaks, lag_labels)
  if (!any(interval_data$count > 0)) {
    cli::cli_abort("No independent response events fall within the supplied deployment intervals.")
  }
  if (!any(interval_data$lag_group != "No recent trigger")) {
    cli::cli_abort("No trigger events fall within the supplied deployment intervals.")
  }

  fit <- .ct_encounter_fit(interval_data, engine)
  estimates <- .ct_encounter_estimates(fit, lag_labels)
  lag_diagnostics <- stats::aggregate(
    cbind(n_response_events = count, exposure_seconds = exposure_seconds) ~ lag_group,
    data = interval_data, FUN = sum
  )
  diagnostic_index <- match(estimates$lag, lag_diagnostics$lag_group)
  estimates$n_response_events <- lag_diagnostics$n_response_events[diagnostic_index]
  estimates$exposure_hours <- lag_diagnostics$exposure_seconds[diagnostic_index] / 3600
  estimates <- .ct_encounter_mask_empty(estimates)
  empty_lags <- estimates$lag[!is.na(estimates$n_response_events) &
                                estimates$n_response_events == 0 &
                                estimates$lag != "No recent trigger"]
  if (length(empty_lags)) {
    warning("Lag window(s) with zero response events carry no information on the rate ratio and were set to NA: ",
            paste(empty_lags, collapse = ", "), ".", call. = FALSE)
  }
  sparse_lags <- !is.na(estimates$n_response_events) & estimates$n_response_events > 0 &
    estimates$n_response_events < 5 & estimates$lag != "No recent trigger"
  if (any(sparse_lags)) {
    warning("At least one lag window has fewer than five response events; treat its estimate as exploratory.",
            call. = FALSE)
  }
  estimable <- !is.na(estimates$rate_ratio)
  estimable[1] <- FALSE
  if (any(estimable & (!is.finite(estimates$lower) | !is.finite(estimates$upper)))) {
    warning("A lag estimate has an unbounded confidence interval, indicating sparse data or separation; do not interpret its point estimate alone.",
            call. = FALSE)
  }

  if (n_boot > 0 && nrow(interval_data) > 20000) {
    cli::cli_inform(c(
      "Running {n_boot} camera-block bootstrap refits over {nrow(interval_data)} intervals; this can be slow with {.code engine = \"glm\"}.",
      "i" = "Consider {.code engine = \"glmm\"}, whose random-effect standard errors need no bootstrap."
    ))
  }
  if (n_boot > 0) {
    if (!is.null(seed)) set.seed(seed)
    cameras <- levels(interval_data$camera_id)
    boot <- matrix(NA_real_, nrow = n_boot, ncol = length(lag_labels),
                   dimnames = list(NULL, lag_labels))
    # A camera drawn k times contributes k copies of its block, each with its
    # own camera and deployment effects. With camera fixed effects this gives
    # the same coefficients as fitting the camera once with prior weight k, so
    # each replicate is one weighted fit on the design matrix built once.
    # Rows with the same covariate pattern (camera, deployment, month, time
    # slot, lag window) are merged by summing counts and exposures, which leaves
    # the Poisson estimating equations, and hence the coefficients, unchanged.
    X <- stats::model.matrix(fit)
    off <- if (is.null(fit$offset)) rep(0, nrow(X)) else fit$offset
    cam_row <- match(as.character(interval_data$camera_id), cameras)
    key <- drop(X %*% (1 / sqrt(seq_len(ncol(X)) + pi))) + cam_row * (ncol(X) + 1)
    grp <- match(key, unique(key))
    first <- !duplicated(grp)
    y <- rowsum(fit$y, grp, reorder = FALSE)[, 1]
    off <- log(rowsum(exp(off), grp, reorder = FALSE)[, 1])
    X <- X[first, , drop = FALSE]
    cam_row <- cam_row[first]
    lag_cols <- grep("^lag_group", colnames(X))
    lag_names <- sub("^lag_group", "", colnames(X)[lag_cols])
    for (b in seq_len(n_boot)) {
      sampled <- sample(cameras, length(cameras), replace = TRUE)
      w <- tabulate(match(sampled, cameras), nbins = length(cameras))[cam_row]
      beta <- tryCatch(.ct_encounter_irls(X, y, w, off, start = stats::coef(fit)),
                       error = function(e) NULL)
      if (!is.null(beta)) {
        boot[b, lag_names] <- exp(beta[lag_cols])
      }
    }
    estimates$bootstrap_lower <- c(1, apply(boot, 2, stats::quantile, probs = 0.025,
                                             na.rm = TRUE, names = FALSE))
    estimates$bootstrap_upper <- c(1, apply(boot, 2, stats::quantile, probs = 0.975,
                                             na.rm = TRUE, names = FALSE))
    estimates <- .ct_encounter_mask_empty(estimates)
  }

  sensitivity <- NULL
  if (!is.null(interval_sensitivity)) {
    grid_iv <- sort(unique(c(interval, interval_sensitivity)))
    sens_rows <- lapply(grid_iv, function(iv) {
      id <- .ct_encounter_prepare(events, deployments, trigger, response,
                                  iv, lag_breaks, lag_labels)
      if (!any(id$count > 0) || !any(id$lag_group != "No recent trigger")) return(NULL)
      f <- tryCatch(.ct_encounter_fit(id, engine), error = function(e) NULL)
      if (is.null(f)) return(NULL)
      est <- .ct_encounter_estimates(f, lag_labels)
      n_by_lag <- tapply(id$count, id$lag_group, sum)
      est$n_response_events <- as.numeric(n_by_lag[est$lag])
      est <- .ct_encounter_mask_empty(est)
      data.frame(interval = iv, lag = est$lag, rate_ratio = est$rate_ratio,
                 lower = est$lower, upper = est$upper, stringsAsFactors = FALSE)
    })
    sensitivity <- do.call(rbind, sens_rows)
    if (!is.null(sensitivity)) {
      sensitivity$lag <- factor(sensitivity$lag,
                                levels = c("No recent trigger", lag_labels))
      sensitivity <- tibble::as_tibble(sensitivity)
    }
  }

  out <- list(
    estimates = estimates,
    model = fit,
    engine = engine,
    dispersion = .ct_encounter_dispersion(fit),
    intervals = interval_data,
    sensitivity = sensitivity,
    settings = list(trigger = trigger, response = response, interval = interval,
                    lag_breaks = lag_breaks, independence = independence,
                    engine = engine, n_boot = n_boot,
                    columns = list(species = species_col, camera = camera_col,
                                   datetime = datetime_col, start = start_col,
                                   end = end_col)),
    call = match.call()
  )
  class(out) <- "ct_encounter_response"
  out
}

# Weighted Poisson log-link fit by iteratively reweighted least squares, used
# for the camera-block bootstrap. Same estimating equations as glm.fit() (the
# quasi-Poisson dispersion does not affect coefficients), solved through the
# small p x p cross-product matrix, which is much faster than the QR of the
# n x p design when n is large. Columns that the resample cannot identify
# (cameras not drawn) are returned as NA. Convergence follows glm.fit().
.ct_encounter_irls <- function(X, y, w, offset, start = NULL,
                               epsilon = 1e-8, maxit = 50) {
  beta <- if (is.null(start)) rep(0, ncol(X)) else start
  beta[is.na(beta)] <- 0
  eta <- drop(X %*% beta) + offset
  mu <- exp(eta)
  dev_fun <- function(mu) 2 * sum(w * (ifelse(y > 0, y * log(y / mu), 0) - (y - mu)))
  dev_old <- dev_fun(mu)
  aliased <- rep(FALSE, ncol(X))
  for (it in seq_len(maxit)) {
    z <- (eta - offset) + (y - mu) / mu
    W <- w * mu
    XtWX <- crossprod(X, W * X)
    XtWz <- crossprod(X, W * z)
    q <- qr(XtWX, tol = 1e-10)
    b_new <- qr.coef(q, XtWz)[, 1]
    aliased <- is.na(b_new)
    b_new[aliased] <- 0
    eta <- drop(X %*% b_new) + offset
    mu <- exp(eta)
    dev <- dev_fun(mu)
    beta <- b_new
    if (abs(dev - dev_old) / (abs(dev) + 0.1) < epsilon) break
    dev_old <- dev
  }
  beta[aliased] <- NA
  beta
}

.ct_independent_encounters <- function(events, threshold) {
  events <- events[order(events$camera_id, events$species, events$time), , drop = FALSE]
  if (threshold == 0) return(events)
  group_id <- interaction(events$camera_id, events$species, drop = TRUE)
  keep <- unlist(lapply(split(seq_len(nrow(events)), group_id), function(index) {
    times <- as.numeric(events$time[index])
    selected <- logical(length(index))
    selected[1] <- TRUE
    last_time <- times[1]
    if (length(index) > 1) {
      for (i in 2:length(index)) {
        if (times[i] - last_time >= threshold) {
          selected[i] <- TRUE
          last_time <- times[i]
        }
      }
    }
    index[selected]
  }), use.names = FALSE)
  events[sort(keep), , drop = FALSE]
}

# Build the discrete-time analysis grid: one row per camera-uptime interval, with
# the response count, exposure offset, time-since-last-trigger lag group, and diel
# and seasonal covariates. Factored out so the bin-width sensitivity analysis and
# the bootstrap can reuse exactly the same construction.
.ct_encounter_prepare <- function(events, deployments, trigger, response,
                                  interval, lag_breaks, lag_labels) {
  interval_data <- lapply(seq_len(nrow(deployments)), function(i) {
    dep <- deployments[i, , drop = FALSE]
    bin_start <- seq(as.numeric(dep$start), as.numeric(dep$end) - 1e-6, by = interval)
    bin_end <- pmin(bin_start + interval, as.numeric(dep$end))
    grid <- data.frame(
      camera_id = dep$camera_id,
      deployment_id = dep$deployment_id,
      bin_start = bin_start,
      exposure_seconds = bin_end - bin_start,
      stringsAsFactors = FALSE
    )
    dep_events <- events[events$camera_id == dep$camera_id &
                           as.numeric(events$time) >= as.numeric(dep$start) &
                           as.numeric(events$time) < as.numeric(dep$end), , drop = FALSE]
    trigger_times <- sort(as.numeric(dep_events$time[dep_events$species == trigger]))
    response_times <- as.numeric(dep_events$time[dep_events$species == response])
    response_bins <- findInterval(response_times, bin_start)
    response_bins <- response_bins[response_bins > 0 & response_times < bin_end[response_bins]]
    grid$count <- tabulate(response_bins, nbins = nrow(grid))
    latest_trigger <- findInterval(bin_start, trigger_times)
    lag <- rep(Inf, nrow(grid))
    has_trigger <- latest_trigger > 0
    lag[has_trigger] <- bin_start[has_trigger] - trigger_times[latest_trigger[has_trigger]]
    grid$lag_group <- .ct_encounter_lag_group(lag, lag_breaks)
    grid
  })
  interval_data <- do.call(rbind, interval_data)

  bin_time <- as.POSIXct(interval_data$bin_start, origin = "1970-01-01", tz = "UTC")
  bin_lt <- as.POSIXlt(bin_time, tz = "UTC")
  clock_hour <- bin_lt$hour + bin_lt$min / 60
  angle <- 2 * pi * clock_hour / 24
  interval_data$sin1 <- sin(angle)
  interval_data$cos1 <- cos(angle)
  interval_data$sin2 <- sin(2 * angle)
  interval_data$cos2 <- cos(2 * angle)
  interval_data$month <- factor(bin_lt$mon + 1)
  interval_data$camera_id <- factor(interval_data$camera_id)
  interval_data$deployment_id <- factor(interval_data$deployment_id)
  interval_data$lag_group <- factor(interval_data$lag_group,
                                    levels = c("No recent trigger", lag_labels))
  interval_data
}

# Fit the count model. "glm" -> camera-stratified quasi-Poisson (camera and
# deployment as fixed effects). "glmm" -> Poisson/NB mixed model with camera (and
# deployment) random intercepts, preferring glmmTMB's nbinom2 for overdispersion
# and falling back to lme4 with an observation-level random effect.
.ct_encounter_fit <- function(x, engine) {
  x$camera_id <- factor(x$camera_id)
  x$deployment_id <- factor(x$deployment_id)
  x$month <- factor(x$month)
  fixed <- c("lag_group", "sin1", "cos1", "sin2", "cos2")
  if (nlevels(x$month) > 1) fixed <- c(fixed, "month")

  if (engine == "glm") {
    if (nlevels(x$camera_id) > 1) fixed <- c(fixed, "camera_id")
    if (nlevels(x$deployment_id) > 1) fixed <- c(fixed, "deployment_id")
    form <- stats::as.formula(paste("count ~", paste(fixed, collapse = " + ")))
    return(stats::glm(
      form, family = stats::quasipoisson(link = "log"),
      offset = log(x$exposure_seconds), data = x
    ))
  }

  # Random-effects structure: nest deployment within camera when both vary.
  re <- if (nlevels(x$camera_id) > 1 && nlevels(x$deployment_id) > 1) {
    "(1 | camera_id/deployment_id)"
  } else if (nlevels(x$camera_id) > 1) {
    "(1 | camera_id)"
  } else if (nlevels(x$deployment_id) > 1) {
    "(1 | deployment_id)"
  } else {
    cli::cli_abort("{.code engine = \"glmm\"} needs more than one camera or deployment to estimate a random effect.")
  }
  rhs <- paste(c(fixed, re, "offset(log(exposure_seconds))"), collapse = " + ")
  form <- stats::as.formula(paste("count ~", rhs))

  fit_lme4 <- function() {
    x$.obs <- factor(seq_len(nrow(x)))
    # Observation-level random effect absorbs Poisson overdispersion.
    form_olre <- stats::update(form, . ~ . + (1 | .obs))
    lme4::glmer(form_olre, family = stats::poisson(link = "log"), data = x,
                control = lme4::glmerControl(optimizer = "bobyqa"))
  }

  if (requireNamespace("glmmTMB", quietly = TRUE)) {
    fit <- tryCatch(
      glmmTMB::glmmTMB(form, family = glmmTMB::nbinom2(link = "log"), data = x),
      error = function(e) e
    )
    if (!inherits(fit, "error")) return(fit)
    if (!requireNamespace("lme4", quietly = TRUE)) {
      cli::cli_abort(c("The {.pkg glmmTMB} fit failed.",
                       "x" = conditionMessage(fit),
                       "i" = "Install {.pkg lme4} to enable the fallback engine."))
    }
    cli::cli_inform("{.pkg glmmTMB} fit failed; falling back to {.pkg lme4}.")
    return(fit_lme4())
  }
  fit_lme4()
}

# Extract the fixed-effect coefficient table and an appropriate critical value in
# a way that works across glm (t), glmmTMB and lme4 (z).
.ct_encounter_coef <- function(fit) {
  if (inherits(fit, "glmmTMB")) {
    list(coef = summary(fit)$coefficients$cond, crit = stats::qnorm(0.975))
  } else if (inherits(fit, "glmerMod") || inherits(fit, "merMod")) {
    list(coef = summary(fit)$coefficients, crit = stats::qnorm(0.975))
  } else {
    list(coef = summary(fit)$coefficients, crit = stats::qt(0.975, df = fit$df.residual))
  }
}

# Overdispersion diagnostic that is comparable across engines (Pearson X^2 / df).
.ct_encounter_dispersion <- function(fit) {
  rp <- tryCatch(sum(stats::residuals(fit, type = "pearson")^2), error = function(e) NA_real_)
  df <- tryCatch(stats::df.residual(fit), error = function(e) NA_real_)
  if (is.na(rp) || is.na(df) || df <= 0) return(NA_real_)
  rp / df
}

.ct_encounter_lag_labels <- function(lag_breaks) {
  unit_label <- function(x) {
    if (x %% 3600 == 0) paste0(x / 3600, " h") else paste0(x / 60, " min")
  }
  paste0(utils::head(vapply(lag_breaks, unit_label, character(1)), -1), "--",
         utils::tail(vapply(lag_breaks, unit_label, character(1)), -1))
}

.ct_encounter_lag_group <- function(lag, lag_breaks) {
  labels <- .ct_encounter_lag_labels(lag_breaks)
  group <- rep("No recent trigger", length(lag))
  for (i in seq_along(labels)) {
    group[lag >= lag_breaks[i] & lag < lag_breaks[i + 1]] <- labels[i]
  }
  group
}

.ct_encounter_estimates <- function(fit, lag_labels) {
  ce <- .ct_encounter_coef(fit)
  sm <- ce$coef
  crit <- ce$crit
  lag_rows <- grep("^lag_group", rownames(sm))
  estimates <- data.frame(
    lag = c("No recent trigger", lag_labels),
    rate_ratio = 1,
    lower = 1,
    upper = 1,
    p_value = NA_real_,
    stringsAsFactors = FALSE
  )
  if (length(lag_rows)) {
    coefs <- sm[lag_rows, , drop = FALSE]
    labels <- sub("^lag_group", "", rownames(coefs))
    index <- match(labels, estimates$lag)
    estimates$rate_ratio[index] <- exp(coefs[, 1])
    estimates$lower[index] <- exp(coefs[, 1] - crit * coefs[, 2])
    estimates$upper[index] <- exp(coefs[, 1] + crit * coefs[, 2])
    estimates$p_value[index] <- coefs[, 4]
  }
  estimates
}

# A lag window with zero response events carries no information about its rate
# ratio: the fixed-effects GLM can even return a large finite (wrong-signed)
# coefficient there through non-convergence/separation. Setting such windows to
# NA is the honest, engine-independent behaviour. Requires an `n_response_events`
# column on `estimates`.
.ct_encounter_mask_empty <- function(estimates) {
  empty <- !is.na(estimates$n_response_events) & estimates$n_response_events == 0 &
    estimates$lag != "No recent trigger"
  for (col in c("rate_ratio", "lower", "upper", "p_value",
                "bootstrap_lower", "bootstrap_upper")) {
    if (!is.null(estimates[[col]])) estimates[[col]][empty] <- NA_real_
  }
  estimates
}

#' @export
print.ct_encounter_response <- function(x, ...) {
  cat("Conditional encounter-response model\n")
  cat("Trigger: ", x$settings$trigger, " | Response: ", x$settings$response, "\n", sep = "")
  cat("Engine: ", x$engine,
      if (x$settings$n_boot > 0) paste0(" | Camera-block bootstrap: ", x$settings$n_boot) else "",
      "\n", sep = "")
  print(x$estimates, row.names = FALSE)
  invisible(x)
}

#' @export
summary.ct_encounter_response <- function(object, ...) {
  out <- list(
    estimates = object$estimates,
    engine = object$engine,
    n_intervals = nrow(object$intervals),
    n_response_events = sum(object$intervals$count),
    dispersion = object$dispersion,
    settings = object$settings
  )
  class(out) <- "summary.ct_encounter_response"
  out
}

#' @export
print.summary.ct_encounter_response <- function(x, ...) {
  cat("Conditional encounter-response model\n")
  cat("Engine: ", x$engine,
      " | Inference: ",
      if (x$settings$n_boot > 0) paste0("camera-block bootstrap (", x$settings$n_boot, ")") else "model-based",
      "\n", sep = "")
  cat("Independent response events: ", x$n_response_events,
      " | Analysis intervals: ", x$n_intervals,
      " | Dispersion: ", format(round(x$dispersion, 3), nsmall = 3), "\n", sep = "")
  print(x$estimates, row.names = FALSE)
  invisible(x)
}

Try the ct package in your browser

Any scripts or data that you put into this service are public.

ct documentation built on Oct. 22, 2026, 1:07 a.m.