R/helpers.R

Defines functions validObject slotNames .hasSlot `slot<-` slot upgrade_s4_to_s3 check_for_misspellings get_steady_state_n w2l l2w bin_midpoints resource_power_law power_law_bin_average exact_column different

Documented in bin_midpoints different get_steady_state_n .hasSlot l2w power_law_bin_average resource_power_law slot slotNames upgrade_s4_to_s3 validObject w2l

#' Check whether two objects are different
#'
#' Check whether two objects are numerically different, ignoring all attributes.
#'
#' We use this helper function in particular to see if a new value for a slot
#' in MizerParams is different from the existing value in order to give the
#' appropriate messages.
#'
#' @param a First object
#' @param b Second object
#'
#' @return TRUE or FALSE
#' @concept helper
different <- function(a, b) {
    !isTRUE(all.equal(a, b, check.attributes = FALSE, scale = 1,
                      tolerance = 10 * .Machine$double.eps))
}

#' Extract a column from a parameter table without partial matching
#'
#' The `$` operator on a data frame partially matches column names, so
#' `species_params(params)$a` would return the `alpha` column when there is no
#' `a` column. That silently gives the wrong parameter, so the `$` methods for
#' the mizer parameter tables use this helper instead, which matches exactly.
#'
#' When the name would have partially matched a single column under the old
#' behaviour we warn, because that is precisely the case where existing user
#' code changes its meaning. A name that matches nothing at all returns `NULL`
#' silently, so that `is.null(species_params(params)$foo)` stays a usable way
#' of testing whether a parameter is present.
#'
#' @param x The parameter table.
#' @param name The name of the column.
#' @param what The name of the class, used in the warning message.
#'
#' @return The column, or `NULL` if there is no column of that name.
#' @concept helper
#' @noRd
exact_column <- function(x, name, what) {
    out <- .subset2(x, name, exact = TRUE)
    if (is.null(out)) {
        partial <- names(x)[startsWith(names(x), name)]
        if (length(partial) == 1) {
            warning("There is no `", name, "` column in this `", what,
                    "` table, so `$", name, "` returns NULL. Earlier versions ",
                    "of mizer partially matched the column name and returned ",
                    "the `", partial, "` column instead. Use `$", partial,
                    "` if that is what you wanted.")
        }
    }
    out
}

#' Bin average of a power law over geometric bins
#'
#' Computes the exact average of the power law \eqn{w^d} over each bin
#' \eqn{[w_j, w_{j+1}]}, i.e.
#' \deqn{\overline{w^d}_j = \frac{1}{\Delta w_j}\int_{w_j}^{w_{j+1}} w^d\, dw.}
#'
#' The integral has a closed form, so the result is exact (not merely second
#' order):
#' \deqn{\overline{w^d}_j = \frac{w_{j+1}^{d+1} - w_j^{d+1}}{(d+1)\,\Delta w_j},
#'   \quad d \neq -1,}
#' \deqn{\overline{w^d}_j = \frac{\ln(w_{j+1}/w_j)}{\Delta w_j},
#'   \quad d = -1.}
#'
#' This is used by the bin-averaged (second-order) code paths that need the
#' average of a power-law rate over each bin, for example [setExtMort()] and
#' the resource capacity and rate in [setResource()]. The grid does not need to
#' be geometric; only the left bin edges `w` and the bin widths `dw` are used,
#' with \eqn{w_{j+1} = w_j + \Delta w_j}.
#'
#' An optional upper cutoff `w_max` handles a knife-edge truncation of the power
#' law (for example the resource carrying capacity, which is \eqn{\kappa
#' w^{-\lambda}} below `w_pp_cutoff` and zero above it). The bin straddling the
#' cutoff then receives the *partial* bin-average — the power-law average over
#' the part of the bin below `w_max`, divided by the full bin width — and bins
#' entirely above the cutoff get zero.
#'
#' @param w Numeric vector of left bin edges \eqn{w_j}.
#' @param dw Numeric vector of bin widths \eqn{\Delta w_j} (same length as `w`).
#' @param d Single numeric exponent of the power law.
#' @param w_max Optional upper cutoff. The power law is taken to be zero above
#'   `w_max`, with the straddling bin getting the partial average. Defaults to
#'   `Inf` (no cutoff), in which case the result is identical to the uncut
#'   formula.
#'
#' @return A numeric vector (same length as `w`) of bin averages of
#'   \eqn{w^d} (truncated at `w_max` when supplied).
#' @concept helper
#' @keywords internal
power_law_bin_average <- function(w, dw, d, w_max = Inf) {
    # Upper integration edge of each bin, clipped at the cutoff. The pmax keeps
    # bins lying entirely above the cutoff from contributing a negative (they
    # collapse to a zero-width interval and so average to zero).
    w_next <- pmax(pmin(w + dw, w_max), w)
    if (d == -1) {
        log(w_next / w) / dw
    } else {
        (w_next^(d + 1) - w^(d + 1)) / ((d + 1) * dw)
    }
}

#' Construct the background resource power-law spectrum
#'
#' Internal helper returning the auto-calculated resource power law
#' \eqn{\kappa\, w^{-\lambda}} on the full size grid, optionally truncated at an
#' upper cutoff `w_max`. When the `bin_average` entry of the model's
#' `second_order_w` slot is set, the exact bin average of the power law over
#' each bin is returned instead (with the bin straddling `w_max` getting the
#' partial average), so that the initial resource is the finite-volume cell
#' average of the background spectrum, consistent with the bin-integrated
#' encounter convolution that consumes it as a cell average. Otherwise the
#' left-edge point values are returned, byte-identical to previous mizer.
#'
#' This is used wherever the background resource spectrum \eqn{\kappa
#' w^{-\lambda}} is constructed from scratch: the initial resource abundance in
#' [newMultispeciesParams()] and the temporary prey spectra used to compute the
#' default `gamma`/`f0` ([get_gamma_default()], [get_f0_default()]) and the
#' consumer initial abundances ([get_initial_n()]). The bin-averaged resource
#' capacity and rate are produced directly by [setResource()].
#'
#' @param params A MizerParams object whose `second_order_w` slot controls the
#'   gating and whose `w_full`/`dw_full` give the grid.
#' @param kappa The coefficient \eqn{\kappa} of the power law.
#' @param lambda The exponent so the power law is \eqn{w^{-\lambda}}.
#' @param w_max Optional upper cutoff. The power law is taken to be zero at and
#'   above `w_max`. Defaults to `Inf` (no cutoff).
#'
#' @return A numeric vector (same length as `w_full`) of the resource spectrum.
#' @concept helper
#' @keywords internal
resource_power_law <- function(params, kappa, lambda, w_max = Inf) {
    w_full <- params@w_full
    if (isTRUE(params@second_order_w[["bin_average"]])) {
        kappa * power_law_bin_average(w_full, params@dw_full, -lambda,
                                      w_max = w_max)
    } else {
        n_pp <- kappa * w_full ^ (-lambda)
        n_pp[w_full >= w_max] <- 0
        n_pp
    }
}

#' Geometric bin centres of the size grid
#'
#' Internal helper for the second-order plotting code. A finite-volume cell
#' average \eqn{N_j = (1/\Delta w_j)\int_{w_j}^{w_{j+1}} N\,dw} does not live at
#' the left bin edge \eqn{w_j} but at the geometric bin centre
#' \deqn{w^*_j = \sqrt{w_j\,w_{j+1}} = w_j\sqrt{\beta},}
#' where \eqn{\beta = w_{j+1}/w_j} is the (constant) bin ratio of the
#' logarithmic grid. This is the log-symmetric, second-order-correct location at
#' which to plot a bin-averaged quantity (it is exact for the community spectrum
#' \eqn{N\propto w^{-2}}). It is a uniform half-bin shift to the right on the log
#' axis, the same for the consumer grid `w` and the full prey/resource grid
#' `w_full`.
#'
#' @param params A MizerParams object.
#' @param w_full If `TRUE`, return the centres of the full (resource) grid
#'   `params@w_full`; otherwise the consumer grid `params@w`.
#' @return A numeric vector of geometric bin centres, one per grid node.
#' @concept helper
#' @keywords internal
bin_midpoints <- function(params, w_full = FALSE) {
    # The grid is geometric, so the bin ratio beta is constant across the whole
    # (consumer and resource) grid.
    beta <- params@w_full[2] / params@w_full[1]
    w <- if (w_full) params@w_full else params@w
    w * sqrt(beta)
}

#' Length-weight conversion
#'
#' For each species, convert between length and weight using the relationship
#' \deqn{w_i = a_i l_i^{b_i}}{w_i = a_i l_i^b_i} or
#' \deqn{l_i = (w_i / a_i)^{1/b_i}}{l_i = (w_i / a_i)^{1/b_i}}
#' where `a` and `b` are taken from the species parameter data frame and
#' \eqn{i}{i} is the species index.
#'
#' This is useful for converting a length-based species parameter to a
#' weight-based species parameter.
#'
#' @param l Lengths in cm. Either a single number used for all species or a
#'   vector with one number for each species.
#' @param w Weights in grams. Either a single number used for all species or a
#'   vector with one number for each species.
#' @param species_params A species parameter data frame or a MizerParams object.
#' @return A vector with one entry for each species. `l2w()` returns a vector
#' of weights in grams and `w2l()` returns a vector of lengths in cm.
#' @export
#' @concept helper
l2w <- function(l, species_params) {
    # The checks avoid `assert_that()`, which builds its message whether or not
    # it is needed.
    if (!is.numeric(l)) stop("l is not a numeric or integer vector")
    sp <- species_params
    if (inherits(species_params, "MizerParams")) {
        sp <- species_params@species_params
    }
    if (!is.data.frame(sp)) {
        stop("The second argument must be either a MizerParams object or a
             species paramter data frame.")
    }
    if (length(l) != 1 && length(l) != nrow(sp)) {
        stop("The length of 'l' should be one or equal to the number of species.")
    }
    if (!all(c("a", "b") %in% names(sp))) {
        stop("The species parameter data frame must contain columns 'a' and 'b'.")
    }

    sp[["a"]] * l^sp[["b"]]
}

#' @rdname l2w
#' @export
w2l <- function(w, species_params) {
    if (!is.numeric(w)) stop("w is not a numeric or integer vector")
    sp <- species_params
    if (inherits(species_params, "MizerParams")) {
        sp <- species_params@species_params
    }
    if (!is.data.frame(sp)) {
        stop("The second argument must be either a MizerParams object or a
             species paramter data frame.")
    }
    if (length(w) != 1 && length(w) != nrow(sp)) {
        stop("The length of 'w' should be one or equal to the number of species.")
    }
    if (!all(c("a", "b") %in% names(sp))) {
        stop("The species parameter data frame must contain columns 'a' and 'b'.")
    }

    (w / sp[["a"]])^(1 / sp[["b"]])
}

#' Calculate steady state abundance
#'
#' This function calculates the steady state abundance by solving the
#' transport equation with given growth and mortality rates. It sets up a
#' tri-diagonal system and solves it.
#'
#' @param params A MizerParams object
#' @param g A matrix of growth rates (species x size)
#' @param mu A matrix of mortality rates (species x size)
#' @param D A matrix of diffusion rates (species x size)
#' @param N0 A vector with the abundance at the smallest size for each species
#' @param max_iterations Maximum number of Picard iterations used when a flux
#'   limiter is active.
#' @param tol Relative convergence tolerance for the Picard iteration.
#' @param relax Under-relaxation factor in (0, 1] for the Picard iteration when a
#'   flux limiter is active.
#' @return A matrix with the steady state abundance
#'
#' @details
#' The spatial discretisation of the advective flux is read from the
#' `flux` entry of the `second_order_w` slot of `params`. With a second-order
#' flux scheme active the steady state must match the one that [project()] converges
#' to. Because the limiter depends on the solution, the steady state is then
#' found by an under-relaxed Picard iteration: the limiter is frozen at the
#' current iterate, the resulting tridiagonal system is solved, and the iterate
#' is updated towards that solution, repeating until it converges. (At `dt = 1`
#' the limited operator is not diagonally dominant, so the plain fixed-point map
#' only stalls; under-relaxation makes it converge.)
#'
#' The returned abundance is held at zero above each species' `w_max`, the same
#' upper boundary condition that [project()] imposes (via `zero_above_support()`
#' in `project_n()`) and that the Newton solver solves on. Without this the
#' bottom-up solve would carry density above `w_max` whenever growth is still
#' positive there or diffusion pushes density past it.
#' @concept helper
get_steady_state_n <- function(params, g, mu, D, N0,
                               max_iterations = 500, tol = 1e-10,
                               relax = 0.3) {
    # Advective-flux scheme read from the model's second_order_w slot.
    flux_limiter <- flux_limiter_scheme(params)
    no_sp <- nrow(params@species_params)
    no_w <- length(params@w)
    n <- matrix(0, nrow = no_sp, ncol = no_w,
                dimnames = list(params@species_params$species, dimnames(params@initial_n)[[2]]))

    j_start <- params@w_min_idx
    idxs_start <- cbind(1:no_sp, j_start)

    # Highest retained class per species; abundance is held at zero above it,
    # matching the upper boundary condition imposed by the time-steppers.
    w_top <- support_top_idx(params)

    for (iteration in seq_len(max_iterations)) {
        # Coefficients with dt = 1 and no recruitment flux (the boundary is
        # handled manually below). The flux limiter is frozen at the current
        # iterate `n`; with flux_limiter = "none" this is the upwind operator and
        # the loop exits after a single, exact solve.
        coefs <- get_transport_coefs(params, n, g, mu, dt = 1,
                                     recruitment_flux = rep(0, no_sp),
                                     d = D, flux_limiter = flux_limiter)

        a <- coefs$a
        # For steady state, the diagonal term \tilde{B} is B - 1
        b <- coefs$b - 1
        c <- coefs$c
        # Steady-state right-hand side: zero except the fixed egg density at the
        # lower boundary. (We build this directly rather than reusing coefs$S,
        # which equals the frozen iterate `n`.)
        S <- matrix(0, nrow = no_sp, ncol = no_w, dimnames = dimnames(n))

        # Boundary conditions at the start of the size spectrum:
        # A_j = 0, B_j = 1, C_j = 0, S_j = N0
        a[idxs_start] <- 0
        b[idxs_start] <- 1
        c[idxs_start] <- 0
        S[idxs_start] <- N0

        n_solve <- project_n_loop(matrix(0, nrow = no_sp, ncol = no_w,
                                         dimnames = dimnames(n)),
                                  a, b, c, S, j_start)

        # Without a limiter the system is linear, so one solve is exact.
        if (flux_limiter == "none") {
            return(zero_above_support(n_solve, w_top))
        }

        # Under-relax the update and keep the iterate non-negative (the limited
        # operator is not an M-matrix, so a bare solve can dip slightly below 0).
        n_new <- (1 - relax) * n + relax * n_solve
        n_new[n_new < 0] <- 0
        change <- max(abs(n_new - n)) / max(abs(n_new))
        n <- n_new
        if (is.finite(change) && change < tol) {
            break
        }
    }

    return(zero_above_support(n, w_top))
}

#' Warn about column names that look like mis-spelled parameter names
#'
#' Compares the actual column names of a parameter data frame against the set of
#' recognised names and raises a single [signal_info()] report about any names
#' that are a near match (a likely typo) but not an exact match. Names are not
#' corrected, only flagged, so that legitimate custom columns are left
#' untouched.
#'
#' @param actual Character vector of the column names present in the data frame.
#' @param known Character vector of recognised parameter names.
#' @param df_type A short description of the data frame used in the message,
#'   e.g. `"gear parameter"` or `"species parameter"`.
#' @param var A string naming the quantity the report is about, see
#'   [signal_info()]. Given explicitly rather than derived from `df_type`, which
#'   is prose and may be reworded: `var` is the key that handlers and
#'   `with_info_level(except = )` match on.
#' @param curated Character vector of known problematic names that should always
#'   be flagged even when they are further than the fuzzy-match threshold from
#'   any recognised name (e.g. familiar abbreviations).
#'
#' @return Called for its side effect (an information signal). Returns `NULL`
#'   invisibly.
#' @concept helper
#' @noRd
check_for_misspellings <- function(actual, known, df_type, var,
                                   curated = character(0)) {
    unknown <- setdiff(actual, known)
    if (length(unknown) == 0) {
        return(invisible(NULL))
    }
    # Only fuzzy-match against names long enough that a near miss is meaningful.
    known_fuzzy <- known[nchar(known) >= 3]
    hits <- character(0)
    guess <- character(0)
    for (nm in unknown) {
        if (length(known_fuzzy) == 0) {
            j <- integer(0)
            near <- FALSE
        } else {
            d <- as.vector(adist(nm, known_fuzzy, ignore.case = TRUE))
            j <- which.min(d)
            near <- nchar(nm) >= 3 &&
                (d[j] == 1 || (d[j] <= 2 && nchar(known_fuzzy[j]) >= 6))
        }
        if (nm %in% curated || near) {
            hits <- c(hits, nm)
            guess <- c(guess, if (length(j) == 1) known_fuzzy[j] else NA_character_)
        }
    }
    if (length(hits) > 0) {
        suggestions <- ifelse(is.na(guess),
                              paste0("`", hits, "`"),
                              paste0("`", hits, "` (did you mean `", guess,
                                     "`?)"))
        signal_info(var,
                    paste0("Some column names in your ", df_type, " data ",
                           "frame are very close to standard parameter names: ",
                           paste(suggestions, collapse = ", "),
                           ". Please check for mis-spellings."),
                    level = 1, severity = "warning", unhandled = "show")
    }
    invisible(NULL)
}

#' Convert an S4 MizerParams or MizerSim object to an S3 object
#'
#' @param object An S4 MizerParams or MizerSim object.
#' @return An S3 MizerParams or MizerSim object (a list).
#' @keywords internal
upgrade_s4_to_s3 <- function(object) {
    if (!isS4(object)) return(object)
    attrs <- attributes(object)
    sl_names <- setdiff(names(attrs), c("class", "comment", "dotData"))
    obj_list <- lapply(sl_names, function(s) attrs[[s]])
    names(obj_list) <- sl_names
    cl <- class(object)
    attr(cl, "package") <- NULL
    class(obj_list) <- cl
    if ("comment" %in% names(attrs)) {
        comment(obj_list) <- attrs[["comment"]]
    }
    if (inherits(obj_list, "MizerSim") && isS4(obj_list$params)) {
        obj_list$params <- upgrade_s4_to_s3(obj_list$params)
    }
    obj_list
}

#' S4/S3 compatibility slot access helper
#'
#' @param object An object (S4 or S3).
#' @param name Name of the slot / list element.
#' @param ... Additional arguments.
#' @return The value of the slot or list element.
#' @keywords internal
slot <- function(object, name, ...) {
    if (isS4(object)) {
        object <- upgrade_s4_to_s3(object)
    }
    object[[name]]
}

#' S4/S3 compatibility slot assignment helper
#'
#' @param object An object (S4 or S3).
#' @param name Name of the slot / list element.
#' @param check Logical.
#' @param value Value to assign.
#' @return The modified object.
#' @keywords internal
`slot<-` <- function(object, name, check = TRUE, value) {
    if (isS4(object)) {
        object <- upgrade_s4_to_s3(object)
    }
    object[[name]] <- value
    object
}

#' S4/S3 compatibility .hasSlot helper
#'
#' @param object An object (S4 or S3).
#' @param name Name of the slot / list element.
#' @return Logical.
#' @keywords internal
.hasSlot <- function(object, name) {
    if (isS4(object)) {
        methods::.hasSlot(object, name)
    } else {
        name %in% names(object)
    }
}

#' S4/S3 compatibility slotNames helper
#'
#' @param x An object (S4 or S3) or class name.
#' @return Character vector of slot / element names.
#' @keywords internal
slotNames <- function(x) {
    if (isS4(x)) {
        methods::slotNames(x)
    } else if (is.character(x)) {
        if (identical(x, "MizerParams")) {
            names(emptyParams(c(1, 2), 1))
        } else if (identical(x, "MizerSim")) {
            c("params", "n", "n_pp", "n_other", "effort", "sim_params")
        } else {
            methods::slotNames(x)
        }
    } else if (is.list(x)) {
        names(x)
    } else {
        methods::slotNames(x)
    }
}

#' Validity checker compatibility helper
#'
#' @param object A MizerParams or MizerSim object.
#' @param ... Additional arguments.
#' @return TRUE invisibly if valid, or stops with error.
#' @keywords internal
validObject <- function(object, ...) {
    if (isS4(object)) {
        return(methods::validObject(object, ...))
    }
    if (inherits(object, "MizerParams")) {
        val <- validMizerParams(object)
        if (!isTRUE(val)) {
            stop(paste(val, collapse = "\n"))
        }
        return(invisible(TRUE))
    }
    if (inherits(object, "MizerSim")) {
        val <- valid_MizerSim(object)
        if (!isTRUE(val)) {
            stop(paste(val, collapse = "\n"))
        }
        return(invisible(TRUE))
    }
    invisible(TRUE)
}

Try the mizer package in your browser

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

mizer documentation built on Aug. 31, 2026, 5:08 p.m.