R/channel_args.R

Defines functions validate_group_channels warn_map_keys is_arg_map resolve_channel_args

Documented in is_arg_map resolve_channel_args validate_group_channels warn_map_keys

#' Resolve per-channel arguments
#'
#' Broadcasts global argument values across `nirs_channels`, applying
#' per-channel overrides where an argument is supplied as a named `list()`
#' keyed by channel name. An argument is treated as per-channel when it is
#' a `list()` with at least one named element, with at most one unnamed
#' element acting as the fallback for unlisted channels (e.g.
#' `width = list(5, q = 7)` gives `q` 7 and every other channel 5). Names
#' must match `nirs_channels` or group names; unrecognised names are
#' warned about and ignored. Any other value (unnamed vectors and fully
#' unnamed lists) is applied globally to every channel.
#'
#' @param nirs_channels Character vector of resolved channel names.
#' @param group_channels An *optional* named list of channel-name vectors
#'   from [validate_group_channels()]. When supplied, arguments are
#'   resolved per group: a group-name key or any member-channel key
#'   applies to the whole group, and conflicting member values within one
#'   group abort.
#' @param args Named list of per-channel-capable arguments. Each element is
#'   either a global value or a per-channel `list()` map. A per-channel map
#'   may include a single unnamed element as the fallback for unlisted
#'   channels.
#' @param defaults Named list of fallback values per argument, used when a
#'   per-channel map omits a channel and supplies no unnamed fallback. Only
#'   needed for arguments whose formal default is not `NULL` (e.g.
#'   `method = "linear"`).
#' @param choices Named list of valid values for choice-type arguments
#'   (e.g. `list(method = c("linear", "median", "locf", "none"))`).
#'   Resolved values are matched per channel; a full default vector
#'   resolves to its first element, matching [match.arg()] behaviour.
#' @param env The calling environment, used to report errors as coming
#'   from the user-facing function (e.g. [rescale_mnirs()]).
#' @inheritParams validate_mnirs
#'
#' @returns A named list with one element per channel (or per group when
#'   `group_channels` is supplied); each element is a named list of that
#'   channel's resolved argument values.
#'
#' @keywords internal
resolve_channel_args <- function(
    nirs_channels,
    group_channels = NULL,
    args,
    defaults = list(),
    choices = list(),
    verbose = TRUE,
    env = rlang::caller_env()
) {
    ## units of resolution: single channels, or groups when supplied
    units <- group_channels %||% setNames(as.list(nirs_channels), nirs_channels)
    valid_ch <- unique(c(nirs_channels, names(units)))

    ## anything that is not a channel map (unnamed vectors and fully
    ## unnamed lists) is global
    per_channel <- vapply(args, is_arg_map, logical(1))

    ## warn once per per-channel arg: unrecognised channel names are
    ## ignored; omitted channels with no unnamed fallback fall back to
    ## the argument's formal default
    if (verbose) {
        lapply(names(args)[per_channel], \(.nm) {
            keys <- names(args[[.nm]])
            ## a group is covered by its own key or any member-channel key
            omitted <- if (all(nzchar(keys))) {
                names(units)[!vapply(names(units), \(.key) {
                    any(c(.key, units[[.key]]) %in% keys)
                }, logical(1))]
            }
            warn_map_keys(
                .nm,
                unknown = setdiff(keys[nzchar(keys)], valid_ch),
                omitted = omitted,
                what = "channel",
                match_hint = "{.arg nirs_channels}",
                env = env
            )
        })
    }

    ## resolve one argument for one channel/group
    resolve_one <- function(.a, .nm, .key) {
        if (per_channel[[.nm]]) {
            ## group-name key preferred, then member-channel keys
            hits <- .a[intersect(names(.a), c(.key, units[[.key]]))]
            if (length(unique(hits)) > 1L) {
                cli_abort(c(
                    "x" = "{.arg {(.nm)}} has conflicting values within \\
                    {.arg group_channels} = {.field {(.key)}}.",
                    "i" = "Grouped channels must share one value per \\
                    argument."
                ), call = env)
            }
            ## the lone unnamed element is the fallback for unlisted channels
            unnamed <- .a[!nzchar(names(.a))]
            hit <- if (length(hits) > 0L) hits[[1L]]
            fallback <- if (length(unnamed) > 0L) unnamed[[1L]]
            .a <- hit %||% fallback %||% defaults[[.nm]]
        }
        ## match choice-type args; a full default vector resolves to its
        ## first element, matching `match.arg()` behaviour
        if (!is.null(choices[[.nm]]) && !is.null(.a)) {
            if (identical(.a, choices[[.nm]])) {
                .a <- .a[[1L]]
            }
            .a <- rlang::arg_match0(
                .a,
                choices[[.nm]],
                arg_nm = .nm,
                error_call = env
            )
        }
        return(.a)
    }

    out <- lapply(setNames(nm = names(units)), \(.key) {
        lapply(setNames(nm = names(args)), \(.nm) {
            resolve_one(args[[.nm]], .nm, .key)
        })
    })
    return(out)
}


#' Classify a per-channel/per-interval argument map
#'
#' An argument is a map when it is a `list()` with at least one named
#' element and at most one unnamed element (the fallback for unlisted
#' keys). Shared by [resolve_channel_args()] and
#' [resolve_interval_args()].
#'
#' @param x An argument value.
#'
#' @returns A logical scalar.
#'
#' @keywords internal
is_arg_map <- function(x) {
    if (!is.list(x) || is.data.frame(x)) {
        return(FALSE)
    }
    named <- nzchar(names(x) %||% rep("", length(x)))
    return(any(named) && sum(!named) <= 1L)
}


#' Warn about unmatched keys in an argument map
#'
#' Shared by [resolve_channel_args()] and [resolve_interval_args()]:
#' unrecognised keys are warned about and ignored; omitted keys (only
#' reported by callers when the map has no unnamed fallback) fall back
#' to the argument's default.
#'
#' @param arg_nm Character; the argument name.
#' @param unknown,omitted Character vectors (or `NULL`) of unrecognised
#'   and unspecified keys.
#' @param what Character; the key kind, `"channel"` or `"interval"`.
#' @param match_hint Character; what valid keys must match, may contain
#'   cli markup.
#' @inheritParams validate_mnirs
#'
#' @returns `invisible(NULL)`, invoked for its warning side effects.
#'
#' @keywords internal
warn_map_keys <- function(
    arg_nm,
    unknown,
    omitted,
    what,
    match_hint,
    env = rlang::caller_env()
) {
    if (length(unknown) > 0L) {
        cli_warn(c(
            "!" = paste0(
                "{.arg {arg_nm}}: ", what,
                "{?s} {.field {unknown}} not recognised."
            ),
            "i" = paste0(
                "Per-", what, " named argument lists must match ",
                match_hint, " exactly."
            )
        ), call = warn_call(env))
    }
    if (length(omitted) > 0L) {
        cli_warn(c(
            "i" = paste0(
                "{.arg {arg_nm}}: ", what,
                "{?s} {.field {omitted}} not specified."
            )
        ), call = warn_call(env))
    }
    return(invisible(NULL))
}


#' Validate and normalise channel grouping
#'
#' Converts the `group_channels` argument to a named list of channel-name
#' vectors. String shortcuts expand against `nirs_channels`: `"ensemble"`
#' places all channels in one group (preserving relative scaling) and
#' `"distinct"` places each channel in its own group. Custom `list()`
#' groupings may use bare symbols or character names. Groups must be
#' non-empty and their resulting names must be unique. Channels omitted
#' from a custom grouping are processed independently, matching
#' `group_intervals` behaviour in [extract_intervals()].
#'
#' @param nirs_channels Character vector of resolved channel names.
#' @param group_channels A quosure from `rlang::enquo()`, a character
#'   string (`"ensemble"` or `"distinct"`), or a `list()` of (optionally named)
#'   channel-name vectors.
#' @param data A data frame for parsing bare-symbol group members.
#' @param env Environment for symbol evaluation.
#'
#' @returns A uniquely named list of non-empty character vectors covering
#'   all `nirs_channels`, each channel appearing in exactly one group.
#'
#' @keywords internal
validate_group_channels <- function(
    nirs_channels,
    group_channels,
    data = NULL,
    env = rlang::caller_env()
) {
    ## parse tidy eval input; parse_channel_name() drops list() names,
    ## so restore group names from the original call
    if (rlang::is_quosure(group_channels)) {
        expr <- rlang::quo_get_expr(group_channels)
        quo_env <- rlang::quo_get_env(group_channels)
        group_channels <- parse_channel_name(group_channels, data, quo_env)
        if (rlang::is_call(expr, "list")) {
            names(group_channels) <- names(rlang::call_args(expr))
        }
    }

    ## string shortcuts expand against nirs_channels
    if (is.character(group_channels)) {
        shortcut <- rlang::arg_match0(
            arg = group_channels[[1L]],
            values = c("ensemble", "distinct"),
            arg_nm = "group_channels",
            error_call = env
        )
        group_channels <- if (shortcut == "ensemble") {
            list(nirs_channels)
        } else {
            as.list(nirs_channels)
        }
    }

    if (!is.list(group_channels)) {
        cli_abort(c(
            "x" = "{.arg group_channels} must be {.val ensemble}, \\
            {.val distinct}, or a {.cls list} of channel names."
        ), call = env)
    }

    ## empty groups cannot produce a channel mapping or derived name
    empty <- lengths(group_channels) == 0L
    if (any(empty)) {
        group_labels <- names(group_channels) %||%
            rep("", length(group_channels))
        unnamed <- !nzchar(group_labels)
        group_labels[unnamed] <- paste("position", which(unnamed))
        empty_groups <- group_labels[empty]
        cli_abort(c(
            "x" = "{.arg group_channels}: empty group{?s} \\
            {.field {empty_groups}}.",
            "i" = "Each group must contain at least one channel."
        ), call = env)
    }

    ## group members must be known channels
    members <- unlist(group_channels, use.names = FALSE)
    unknown <- setdiff(members, nirs_channels)
    if (length(unknown) > 0L) {
        cli_abort(c(
            "x" = "{.arg group_channels}: channel{?s} {.field {unknown}} \\
            not recognised.",
            "i" = "Grouped channel names must match {.arg nirs_channels} \\
            exactly."
        ), call = env)
    }

    ## each channel may belong to one group only
    if (anyDuplicated(members) > 0L) {
        dupes <- unique(members[duplicated(members)])
        cli_abort(c(
            "x" = "{.arg group_channels}: channel{?s} {.field {dupes}} \\
            assigned to more than one group.",
            "i" = "Each channel may belong to one group only."
        ), call = env)
    }

    ## channels omitted from custom groups are processed independently,
    ## matching `group_intervals` behaviour for unspecified intervals
    group_channels <- c(
        group_channels,
        as.list(setdiff(nirs_channels, members))
    )

    ## name unnamed groups by their first member
    names <- names(group_channels) %||% rep("", length(group_channels))
    unnamed <- !nzchar(names)
    names[unnamed] <- vapply(group_channels[unnamed], `[[`, "", 1L)

    ## group names are argument lookup keys and must be unambiguous
    if (anyDuplicated(names) > 0L) {
        dupes <- unique(names[duplicated(names)])
        cli_abort(c(
            "x" = "{.arg group_channels}: duplicated group name{?s} \\
            {.field {dupes}}.",
            "i" = "Group names must be unique."
        ), call = env)
    }

    return(setNames(group_channels, names))
}

Try the mnirs package in your browser

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

mnirs documentation built on Sept. 13, 2026, 1:06 a.m.