R/util.R

Defines functions loaded_via_devtools .onLoad document load_all get_worker_logs mcmapply transp timestamp pop set tree du calc_B empty_df get_args called_from_globalenv is_list_of_nums all_identical is_equal is_existing_dirpath is_existing_path is_float_str is_int_str is_str_or_null is_bool_or_null is_char_or_null is_int_or_null is_num_or_null is_str is_bool is_char is_int is_num type human_readable get_logv logf collapse str2 str0 dput2 capture.output2 `%notin` `%!==%` `%===%` `%!=%` `%==%` `%&&%` `%||%` get_yn_input get_str_input get_int_input get_num_input readline store pkg_file norm_path clear mkdirs checksum sfr_in_sdp_bwc sfr_in_ppm_bwc in_hz width convert_width convert_pos

Documented in convert_pos convert_width transp tree width

# Convert between Units (Public) ###############################################

#' @export
#' @title Convert from unit A to unit B
#'
#' @description
#' Converts positions/widths from unit A to unit B. If the direction of units  A
#' and B is reversed, the width's sign will be reversed as well. To keep  widths
#' strictly positive, wrap the result with `abs()`.
#'
#' @param xa
#' A numeric vector specifying widths/positions in unit A.
#'
#' @param ya,yb
#' A numeric vector specifying the positions of at least two points in unit A /
#' unit B.
#'
#' @return
#' A numeric vector of values converted from unit A to unit B.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' ya <- c(244, 246, 248, 250, 252)
#' yb <- c(15, 10, 5, 0, -5)
#' convert_width(c(2, 4, 8), ya, yb)
#' convert_pos(c(247, 249), ya, yb)
convert_pos <- function(xa, ya, yb) {
    # Example:
    #     |-------------w----------|
    #     |----d----|              |
    # xa  |        247             |
    # ya  244   246 | 248   250   252
    # yb  15    10  | 5     0     -5
    # ==>
    # wa  =  ya[n] - ya[1] = 252 - 244   = 8
    # wb  =  yb[n] - yb[1] = (-5) - 15   = 20
    # da  =  xa    - ya[1] = 247 - 244   = 3
    # db  =  da/wa * wb    = (3/8) * -20 = -7.5 (because da/wa == db/wb)
    # xb  =  yb[1] + db    = 15 + (-7.5) = 7.5
    if (length(ya) != length(yb)) stop("ya and yb must have the same length.")
    n <- length(ya)
    wa <- ya[n] - ya[1]
    wb <- yb[n] - yb[1]
    da <- xa - ya[1]
    db <- (da / wa) * wb
    xb <- yb[1] + db
    xb
}

#' @export
#' @rdname convert_pos
convert_width <- function(xa, ya, yb) {
    n <- length(ya)
    wa <- ya[n] - ya[1]
    wb <- yb[n] - yb[1]
    xa * (wb / wa)
}

#' @export
#' @title Calculate the Width of a Numeric Vector
#'
#' @description
#' Calculates the width of a numeric vector by computing the difference between
#' the maximum and minimum values in the vector.
#'
#' @param x
#' A numeric vector.
#'
#' @return
#' The width of the vector, calculated as the difference between its maximum and
#' minimum values.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' vec <- c(1, 3, 5, 7, 9)
#' width(vec)
width <- function(x) {
    diff(range(x))
}

# Convert between Units (Private) #############################################

#' @noRd
#' @description
#' Converts a vector of chemical shifts given in ppm to a vector of frequencies
#' in Hz.
#' @param cs Vector of chemical shifts in ppm.
#' @param fqref Frequency of the reference molecule in Hz.
#' @author 2024-2025 Tobias Schmidt: initial version.
in_hz <- function(cs, fqref) {
    fqmax <- fqref - (min(cs) * 1e-6 * fqref) # Highest frequency in Hz
    fqmin <- fqref - (max(cs) * 1e-6 * fqref) # Lowest frequency in Hz
    fq <- seq(fqmin, fqmax, length.out = length(cs))
    fq <- fq[rev(order(cs))] # (1)
    # (1) Sort frequencies in descending order of chemical shifts, as the
    #     highest chemical shift corresponds to the lowest frequency.
    fq
}

#' @noRd
#' @description
#' Convert the signal free region border (SFR) from scaled data point numbers
#' (SDP) to parts per million (PPM) in a backwards compatible way, i.e. this
#' function does the same mistakes and numerical errors as the original
#' conversion in MetaboDecon1D.
#' @param sfr_sdp SFR in SDP.
#' @param sdp All datapoints in SDP.
#' @param ppm All datapoints in ppm.
#' @details
#' See 'CHECK-2: Signal free region (SFR) calculation' in TODOS.md. (Update
#' 2025-09-14: TODOS are no longer tracked in TODOS.md, but outside of the
#' repository. To retrieve the last actively maintained version of TODOS.md,
#' checkout commit 8b1f61b, i.e., v1.5.0.)
#' @author 2024-2025 Tobias Schmidt: initial version.
sfr_in_ppm_bwc <- function(sfr_sdp, sdp, ppm) {
    assert(
        is.numeric(sfr_sdp), length(sfr_sdp) == 2,
        is.numeric(sdp), length(sdp) >= 5,
        is.numeric(ppm), length(ppm) == length(sdp)
    )
    n <- length(sdp)
    sdp_step <- diff(range(sdp)) / (n - 1)
    ppm_nstep <- diff(range(ppm)) / n
    sfr_dp <- sfr_sdp / sdp_step
    sfr_ppm <- max(ppm) - (n + 1 - sfr_dp) * ppm_nstep
    sfr_ppm
}

#' @noRd
#' @rdname sfr_in_ppm_bwc
#' @param sfr_ppm Signal free region borders (SFR) in parts per million (PPM).
#' @author 2024-2025 Tobias Schmidt: initial version.
sfr_in_sdp_bwc <- function(sfr_ppm, ppm, sf) {
    sfr_left  <- max(sfr_ppm)
    sfr_right <- min(sfr_ppm)
    ppm_left  <- max(ppm)
    ppm_right <- min(ppm)
    n <- length(ppm)
    ppm_nstep <- (ppm_left - ppm_right) / n
    dp_left   <- (n + 1) - (ppm_left - sfr_left)  / ppm_nstep
    dp_right  <- (n + 1) - (ppm_left - sfr_right) / ppm_nstep
    sdp_left  <- dp_left  / sf[1]
    sdp_right <- dp_right / sf[1]
    c(sdp_left, sdp_right)
}

# File Handling (Private) #####################################################

#' @noRd
#' @title Calculate a checksum for all files in a directory or a single file
#'
#' @description
#' Calculates a checksum for each file in a specified directory or a single
#' file. If the input is a directory, the checksums are calculated recursively,
#' meaning that it includes files in all subdirectories. The results are
#' returned as a named vector, where the names are the relative file paths and
#' the values are checksums.
#'
#' @param path The directory or file to calculate checksums for.
#'
#' @param method The method to use for calculating the checksum. Can be "size"
#' (default) or "md5". If "size", the function returns the file sizes. If "md5",
#' the function returns the MD5 hashes of the files.
#'
#' @param ignore A character vector of regular expressions. Files matching any
#' of these regular expressions will be ignored.
#'
#' @return A named vector with file paths as names and hashes as values. If the
#' input is a directory, the names will be the file paths relative to the
#' directory. If the input is a file, the name will be the file name.
#'
#' @details By default, the "checksum" calculated for each file is just its
#' size. This method was chosen because it is the fastest available and
#' typically sufficient for our needs. Traditional checksum methods, such as
#' MD5, can present issues. For instance, PDF files may yield different
#' checksums every time they are recreated, likely due to the inclusion of
#' timestamps or similar metadata within the file.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' checksum(pkg_file("R"))
#' checksum(pkg_file("R"), method = "md5")
checksum <- function(path, method = "size", ignore = c()) {
    calc_checksum <- switch(method,
        size = function(paths) file.info(paths)$size,
        md5 = function(paths) sapply(paths, digest::digest, algo = "md5")
    )
    if (isTRUE(file.info(path)$isdir)) {
        paths <- list.files(path, recursive = TRUE, full.names = TRUE)
        nams <- list.files(path, recursive = TRUE, full.names = FALSE)
        for (pattern in ignore) {
            paths <- paths[!grepl(pattern, paths)]
            nams <- nams[!grepl(pattern, nams)]
        }
    } else {
        paths <- path
        nams <- basename(path)
    }
    structure(calc_checksum(paths), names = nams)
}

#' @noRd
#' @description
#' Recursively create a dirctory without warnings and return its path.
#' @author 2024-2025 Tobias Schmidt: initial version.
mkdirs <- function(path) {
    if (!dir.exists(path)) {
        dir.create(path, showWarnings = FALSE, recursive = TRUE)
    }
    path
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
clear <- function(dir) {
    unlink(dir, recursive = TRUE, force = TRUE)
    mkdirs(dir)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
norm_path <- function(path, winslash = "/", mustWork = FALSE) {
    normalizePath(path, winslash = winslash, mustWork = mustWork)
}

#' @noRd
#' @title Return path to any file within this (installed) package
#' @param file (string) Relative path to file.
#' @param ... Arguments passed on to [system.file()].
#' @return Absolute path to `file` with '/' as file separator.
#' @author 2024-2025 Tobias Schmidt: initial version.
#' @examples
#' pkg_file("DESCRIPTION")
#' pkg_file()
pkg_file <- function(...) {
    system.file(..., package = "metabodecon")
}

#' @noRd
#' @title Store Object in File
#' @description Stores the object returned by the provided expression in the
#' provided path.
#' @param ... Additional arguments passed to the device function
#' @author 2024-2025 Tobias Schmidt: initial version.
#' @examples
#' tmp_png <- tempfile(fileext = ".png")
#' tmp_pdf <- tempfile(fileext = ".pdf")
#' store(plot(1:5), tmp_pdf)
#' store(plot(1:10), tmp_png, width = 10, units = "in", res= 72)
#' store(quote(plot(1:20)), quoted = TRUE)
store <- function(expr,
                  path = tempfile(fileext = ".pdf"),
                  verbose = TRUE,
                  quoted = FALSE,
                  format = tolower(tools::file_ext(path)),
                  ...) {
    path <- norm_path(path)
    devs <- c("png", "jpeg", "tiff", "bmp", "svg", "pdf")
    call <- if (quoted) eval else force
    if (verbose) logf("Writing %s", path)
    if (format == "jpg") format <- "jpeg"
    if (format == "rds") {
        saveRDS(call(expr), path)
    } else if (format %in% devs) {
        with_dev <- asNamespace("withr")[[paste0("with_", format)]]
        with_dev(path, call(expr), ...)
    } else {
        stop(sprintf("Unsupported format '%s'", format))
    }
}

# Reading User Input (Private) #####

#' @noRd
#' @description Copy of Readline
#' @details
#' We must have our own copy so we can replace if with mock functions during
#' testing. That's not (easily) possible for base functions.
#' @author 2024-2025 Tobias Schmidt: initial version.
readline <- function(...) {
    base::readline(...)
}

#' @noRd
#'
#' @title Get numeric input from user
#'
#' @description
#' Prompts the user for input and checks if the input is a number between a
#' minimum and maximum value. If the input is not valid, it keeps asking the
#' user for input until they provide a valid response.
#'
#' @param prompt The prompt to display to the user.
#' @param min The minimum valid value. Default is -Inf.
#' @param max The maximum valid value. Default is Inf.
#' @param int Whether the input should be an integer. Default is FALSE.
#'
#' @return The user's input as a numeric value.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' if (interactive()) {
#'      x <- get_num_input("Enter a number between 1 and 10: ", min = 1, max = 10)
#'      y <- get_num_input("Enter a number between 1 and 10: ", min = 1, max = 10)
#' }
get_num_input <- function(prompt, min = -Inf, max = Inf, int = FALSE) {
    pat <- if (int) "^[+-]?[ ]*[0-9]+$" else "^[+-]?[ ]*[0-9]*\\.?[0-9]+$"
    typ <- if (int) "number" else "value"
    if (!endsWith(prompt, " ")) prompt <- paste0(prompt, " ")
    x <- trimws(readline(prompt = prompt))
    while (!(grepl(pat, x) && as.numeric(x) >= min && as.numeric(x) <= max)) {
        message("Error. Please enter a ", typ, " between ", min, " and ", max, ".")
        x <- trimws(readline(prompt = prompt))
    }
    x <- as.numeric(x)
    return(x)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
get_int_input <- function(prompt, min = -Inf, max = Inf) {
    get_num_input(prompt, min, max, int = TRUE)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
get_str_input <- function(prompt, valid) {
    x <- readline(prompt = prompt)
    n <- length(valid)
    valid_str <- if (n == 1) valid else paste(collapse(valid[-n]), "or", valid[n])
    while (!(x %in% valid)) {
        message("Error. Please type either ", valid_str, ".")
        x <- readline(prompt = prompt)
    }
    x
}

#' @noRd
#'
#' @title Get yes/no input from user
#'
#' @description
#' Prompts the user for input until they enter either 'y' or no 'n'. Returns
#' TRUE if the user entered 'y' and FALSE if they entered 'n'.
#'
#' @param prompt The prompt to display to the user.
#'
#' @return
#' TRUE if the user entered 'y' and FALSE if they entered 'n'.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' if (interactive()) {
#'     show_dir <- get_yn_input("List dir content? (y/n) ")
#'     if (show_dir) print(dir())
#' }
get_yn_input <- function(prompt) {
    if (!grepl("(y/n)", prompt, fixed = TRUE)) {
        prompt <- paste0(prompt, " (y/n) ")
    }
    x <- get_str_input(prompt, c("y", "n"))
    if (x == "y") TRUE else FALSE
}

# Operators (Private) #########################################################

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
`%||%` <- function(x, y) {
    if (is.null(x)) y else x
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
`%&&%` <- function(x, y) {
    if (is.null(x)) x else y
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
`%==%` <- function(x, y) {
    isTRUE(all.equal(x, y))
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
`%!=%` <- function(x, y) {
    !isTRUE(all.equal(x, y))
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
`%===%` <- function(x, y) {
    identical(x, y)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
`%!==%` <- function(x, y) {
    !identical(x, y)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
`%notin` <- function(x, y) {
    !(x %in% y)
}


# Print Functions (Private) #####

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
esc <- list(
    # https://en.wikipedia.org/wiki/ANSI_escape_code
    reset = "\033[0m", # Reset all formatting
    bold = "\033[1m", # Bold text
    dim = "\033[2m", # Dim text
    italic = "\033[3m", # Italic text
    underline = "\033[4m", # Underlined text
    slow_blink = "\033[5m", # Blinking text
    rapid_blink = "\033[6m", # Rapid blinking text
    reverse = "\033[7m", # Reverse video (swap foreground and background color)
    hidden = "\033[8m", # Hidden text (useful for passwords)
    strikthrough = "\033[9m", # Strikethrough text
    cursor_up = "\033[A",
    cursor_down = "\033[B",
    cursor_forward = "\033[C",
    cursor_back = "\033[D",
    cursor_next_line = "\033[E",
    cursor_prev_line = "\033[F",
    cursor_start_of_line = "\033[G",
    cursor_pos_save = "\033[s",
    cursor_pos_popgs = "\033[u",
    black = "\033[30m",
    red = "\033[31m",
    green = "\033[32m",
    yellow = "\033[33m",
    blue = "\033[34m",
    magenta = "\033[35m",
    cyan = "\033[36m",
    white = "\033[37m",
    reset = "\033[0m",
    bright_black = "\033[90m",
    bright_red = "\033[91m",
    bright_green = "\033[92m",
    bright_yellow = "\033[93m",
    bright_blue = "\033[94m",
    bright_magenta = "\033[95m",
    bright_cyan = "\033[96m",
    bright_white = "\033[97m",
    bg_black = "\033[40m",
    bg_red = "\033[41m",
    bg_green = "\033[42m",
    bg_yellow = "\033[43m",
    bg_blue = "\033[44m",
    bg_magenta = "\033[45m",
    bg_cyan = "\033[46m",
    bg_white = "\033[47m",
    bg_bright_black = "\033[100m",
    bg_bright_red = "\033[101m",
    bg_bright_green = "\033[102m",
    bg_bright_yellow = "\033[103m",
    bg_bright_blue = "\033[104m",
    bg_bright_magenta = "\033[105m",
    bg_bright_cyan = "\033[106m",
    bg_bright_white = "\033[107m"
)

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
capture.output2 <- function(..., collapse = "\n", trim = FALSE) {
    x <- utils::capture.output(...)
    if (trim) {
        x <- sapply(x, trimws)
    }
    if (!(identical(collapse, FALSE))) {
        x <- paste(x, collapse = collapse)
    }
    return(x)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
dput2 <- function(..., collapse = " ", trim = TRUE) {
    x <- capture.output2(dput(...), collapse = collapse, trim = trim)
    return(x)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
str0 <- function(object,
                 max.level = 1,
                 give.attr = FALSE, ...) {
    str(
        object,
        max.level = max.level,
        give.attr = give.attr,
        ...
    )
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
str2 <- function(...) {
    capture.output2(str(...))
}

#' @noRd
#' @title Collapse a vector into a string
#' @description Collapses a vector into a single string, with elements separated by a specified separator. Essentially a shorthand for `paste(x, collapse = sep)`.
#' @param x A vector to collapse.
#' @param sep A string to use as the separator between elements. Default is ", ".
#' @return A single string with elements of x separated by sep.
#' @author 2024-2025 Tobias Schmidt: initial version.
#' @examples
#' collapse(c("a", "b", "c")) # "a, b, c"
#' collapse(1:5, sep = "-") # "1-2-3-4-5"
#' collapse(1:5, last = " and ") # "1, 2, 3, 4 and 5"
collapse <- function(x, sep = ", ", last = NULL) {
    if (is.null(last) || (n <- length(x)) == 1) {
        txt <- paste(x, collapse = sep)
    } else {
        txt <- paste(x[-n], collapse = sep)
        txt <- paste(txt, x[n], sep = last)
    }
    txt
}

#' @noRd
#' @description
#' Fixed copy of [toscutil::logf()]. Can be replaced with original after issue
#' [Fix: logf ignores file and append
#' arguments](https://github.com/toscm/toscutil/issues/10) has been fixed.
#' @author 2024-2025 Tobias Schmidt: initial version.
logf <- function(fmt,
                 ...,
                 file = getOption("toscutil.logf.file", ""),
                 append = TRUE,
                 prefix = function() now_ms(usetz = FALSE, color = "\033[1;30m"),
                 sep1 = " ",
                 sep2 = "",
                 end = "\n") {
    cat(prefix(), sep1, sprintf(fmt, ...), sep2, end, sep = "", file = file, append = append)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
get_logv <- function(verbose) {
    if (verbose) logf else function(...) NULL
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
human_readable <- function(x, unit, fmt = "%.1f") {
    m <- max(abs(x))
    # styler: off
    if      (m >= 1e+9) { prefix <- "G"; x <- x / 1e+9 }
    else if (m >= 1e+6) { prefix <- "M"; x <- x / 1e+6 }
    else if (m >= 1e+3) { prefix <- "k"; x <- x / 1e+3 }
    else if (m <= 1e-3) { prefix <- "m"; x <- x / 1e-3 }
    else if (m <= 1e-6) { prefix <- "u"; x <- x / 1e-6 }
    else if (m <= 1e-9) { prefix <- "n"; x <- x / 1e-9 }
    else                { prefix <- "";  x <- x / 1e+0 }
    # styler: off
    fmtstr <- paste0(fmt, " %s%s")
    sprintf(fmtstr, x, prefix, unit)
}

# Type Checking (Private) #####

#' @noRd
#'
#' @name Type Checking
#'
#' @description
#' Returns a string giving the type and length of the input object for logical,
#' integer, double, complex, character, and raw vectors. For other objects, the
#' class of the object is returned.
#'
#' @param x
#' The object to check.
#'
#' @return
#' Aa character string indicating the type of the object.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' obj <- structure(list(a="a", b=1:5), class = "abc")
#' type(obj)   # "abc"
#' type(1:5)   # "integer(5)"
#' type(NULL)  # "NULL(0)"
#' type("Hi")  # "character(1)"
#' type(1.0)   # "numeric(1)"
#' type(NA)    # "logical(1)"
#' type(NaN)   # "numeric(2)"
type <- function(x) {
    typ <- typeof(x)
    if (typ %in% c("logical", "integer", "double", "complex", "character", "raw")) {
        sprintf("%s(%d)", typ, length(x))
    } else {
        class(x)
    }
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_num <- function(x, n = NULL) {
    is.numeric(x) && (is.null(n) || length(x) == n)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_int <- function(x, n = NULL) {
    x_is_int <- is.integer(x) || (is.numeric(x) && all(abs(x - round(x)) < sqrt(.Machine$double.eps)))
    has_correct_length <- is.null(n) || length(x) == n
    x_is_int && has_correct_length
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_char <- function(x, n = NULL, pattern = NULL) {
    is.character(x) &&
        (is.null(n) || length(x) == n ) &&
        (is.null(pattern) || all(grepl(pattern, x)))
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_bool <- function(x, n = NULL) {
    is.logical(x) && (is.null(n) || length(x) == n)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_str <- function(x) {
    is.character(x) && length(x) == 1
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_num_or_null <- function(x, n = NULL) {
    is.null(x) || is_num(x, n)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_int_or_null <- function(x, n = NULL) {
    is.null(x) || is_int(x, n)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_char_or_null <- function(x, n = NULL, pattern = NULL) {
    is.null(x) || is_char(x, n, pattern)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_bool_or_null <- function(x, n = NULL) {
    is.null(x) || is_bool(x, n)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_str_or_null <- function(x) {
    is.null(x) || is_str(x)
}

#' @noRd
#'
#' @title Check if strings represent integer values
#'
#' @description
#' Tests each element of a character vector to determine if it represents an
#' integer value. It allows for optional leading and trailing whitespace, and
#' optional leading + or - signs.
#'
#' @param x
#' A character vector where each element is tested to determine if it represents
#' an integer.
#'
#' @return
#' A logical vector indicating TRUE for elements that represent integer values
#' and FALSE otherwise.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' fixed_point_notation <- c("2.0", "3.", ".4", "-5.", "-.6")
#' scientific_notation <- c("0.45e+04", "66e-05", "0.2e-3", "-33.e-1")
#' floats <- c(fixed_point_notation, scientific_notation)
#' ints <- c("5", "-5", "1234")
#' words <- c("Hello", "world", "!", "It was nice seeing you", ".")
#'
#' x <- is_float_str(floats)
#' y <- is_int_str(floats)
#' if (!all(x == TRUE) && all(y == FALSE)) stop("Test failed")
#'
#' x <- is_float_str(ints)
#' y <- is_int_str(ints)
#' if (!all(x == FALSE) && all(y = TRUE)) stop("Test failed")
#'
#' x <- is_float_str(words)
#' y <- is_int_str(words)
#' if (!all(x == FALSE) && all(y = FALSE)) stop("Test failed")
is_int_str <- function(x) {
    grepl("^\\s*[+-]?\\s*[0-9]+$", x, perl = TRUE)
}

#' @noRd
#' @inherit is_int_str
#'
#' @title Check if strings represent floating-point numbers
#'
#' @description
#' Tests each element of a character vector to determine if it represents a
#' floating-point number. It handles numbers in fixed-point notation (with or
#' without a decimal point) and scientific notation. Allows for optional leading
#' and trailing whitespace, and optional leading + or - signs.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
is_float_str <- function(x) {
    grepl(
        paste0(
            "^\\s*[+-]?", # Optional leading spaces and sign at start of string
            "(\\d+\\.\\d*([eE][+-]?\\d+)?", # 1.e-3,  1.0e-3, 1.0e+3
            "|\\.\\d+([eE][+-]?\\d+)?", # .1e-2, .1.0e-2,  .1e+4
            "|\\d+([eE][+-]?\\d+)", # 1e-3,   1.0e-3,   1e+3
            ")$" # End of string
        ),
        x,
        perl = TRUE
    )
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_existing_path <- function(x) {
    is_str(x) && file.exists(x)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_existing_dirpath <- function(x) {
    is_str(x) && dir.exists(x)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_equal <- function(x, y) {
    isTRUE(all.equal(x, y))
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
all_identical <- function(x) {
    all(sapply(x, identical, x[[1]]))
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
is_list_of_nums <- function(x, nl = NULL, nv = NULL) {
    if (!is.list(x)) return(FALSE)
    if (!(is.null(nl) || length(x) == nl)) return(FALSE)
    if (!(is.null(nv) || all(lengths(x) == nv))) return(FALSE)
    return(TRUE)
}

# Misc (Private) ##############################################################

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
called_from_globalenv <- function() {
    identical(parent.frame(), .GlobalEnv)
}

#' @noRd
#' @title Get Named Function Arguments
#'
#' @description
#' Extracts the **named** arguments of a function as a named list. Variadic
#' arguments, i.e. `...`, are not included in the list. Missing values are
#' provided as "empty symbols".
#'
#' @param func
#' If provided, only arguments of this function are extracted from the
#' environment. See 'Details'.
#'
#' @param ignore
#' A character vector of argument names to ignore.
#'
#' @param env
#' The environment to extract the arguments from.
#'
#' @return
#' A named list of arguments.
#'
#' @details
#' Calling `args <- get_args(f)` as first statement in a function `f` produces
#' the same as `args <- as.list(environment())` (assuming no values were
#' provided via `...`). The advantage of using `get_args()` is, that it allows
#' to exclude certain arguments from the list and that it can be used
#' interactively from the global environment during function development.
#' (Calling `as.list(environment())` from the global environment would convert
#' the complete global environment into a list, meaning it can be extremely
#' slow.)
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' f <- function(a, b = 1, c = NULL, ...) {
#'      args <- get_args(f, ignore = c("a"))
#'      # do some calculations
#'      args
#' }
#' f(10, 20) # list(b = 20, c = NULL)
#'
#' g <- function(a, b = 1, c = NULL, ...) {
#'      get_args()
#' }
#' g(0, 1, 2, 3, 4) # list(a = 0, b = 1, c = 2   )
#' xx <- g()        # list(a = ,  b = 1, c = NULL)
#' is.symbol(xx$a)  # TRUE
get_args <- function(func = NULL, ignore = character(), env = parent.frame())
{
    assert(
        is.null(ignore) || is.character(ignore),
        is.environment(env),
        is.null(func) || is.function(func)
    )
    if (is.null(func)) {
        args <- as.list(env)
        args[ignore] <- NULL
    }  else {
        argnames <- names(formals(func))
        argnames <- argnames[!argnames %in% ignore]
        args <- sapply(argnames, function(name) env[[name]], simplify = FALSE)
    }
    args
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
empty_df <- function(names) {
    df <- data.frame(matrix(ncol = length(names), nrow = 0))
    colnames(df) <- names
    df
}

#' @noRd
#'
#' @title Calculate Magnetic Field Strength
#'
#' @description
#' Calculates the magnetic field strength based on the measured frequencies and
#' chemical shifts of a spectrum as well as the gyromagnetic ratio for protons.
#'
#' The function makes the following assumptions:
#'
#' 1. The spectrum is a 1H spectrum
#' 2. The chemical shift of the reference is at 0.0 ppm
#' 3. The resonance frequency of the reference equals the resonance frequency of
#'    protons
#'
#' @param x
#' A `spectrum` object as described in [Metabodecon
#' Classes](https://spang-lab.github.io/metabodecon/articles/Classes.html).
#'
#' @return
#' The magnetic field strength in Tesla.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' x <- read_spectrum(pkg_file("example_datasets/bruker/urine/urine_1"))
#' calc_B(x)
calc_B <- function(x = read_spectrum()) {
    # Example:
    # |---------------------------|----------------------|
    # 14.8 ppm (max)            0.0 (ref)       -5.2 (min)
    # 600.243 MHz (min)       600.252 (ref)  600.255 (max)
    cs_maxmin <- max(x$cs) - min(x$cs)
    cs_refmin <- 0.0000000 - min(x$cs)
    ratio <- cs_refmin / cs_maxmin
    fq_maxmin <- max(x$fq) - min(x$fq)
    fq_refmax <- ratio * fq_maxmin
    fq_ref <- max(x$fq) - fq_refmax # Frequency of the reference (which is equal the frequency of a proton)
    gamma <- 2.675e8 # Gyromagnetic ratio for protons
    B <- (2 * pi * fq_ref) / gamma
    B
}

#' @noRd
#'
#' @title Recursive Object Size Printer
#'
#' @description
#' Prints the size of an object and its subcomponents recursively, similar to
#' the `du` command in Unix. This function is useful for understanding the
#' memory footprint of complex objects in R.
#'
#' @param obj
#' The object to analyze.
#'
#' @param pname
#' Internal parameter for recursive calls, representing the parent name of the
#' current object. Should not be used directly.
#'
#' @param level
#' Internal parameter for recursive calls, indicating the current depth of
#' recursion. Should not be used directly.
#'
#' @param max_level
#' The maximum depth of recursion. Default is 1. Increase this to explore deeper
#' into nested structures.
#'
#' @param max_len
#' The maximum number of elements in a list to recurse into. Default is 50.
#' Lists with more elements will not be explored further.
#'
#' @param unit
#' The unit of measurement for object sizes. Can be "GB", "MB", "KB", or "B".
#' Default is "MB".
#'
#' @return
#' The total size of the object, including its subcomponents, as a character
#' string with the specified unit.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' obj <- list(
#'     a = rnorm(1000),
#'     b = list(
#'         c = rnorm(2000),
#'         d = list(
#'             e = rnorm(3000),
#'             f = rnorm(4000)
#'         )
#'     )
#' )
#' du(obj)
#' du(obj, max_level = Inf, unit = "KB")
du <- function(obj, pname = "", level = 0, max_level = 1, max_len = 50, unit = "MB") {
    match.arg(unit, c("GB", "MB", "KB", "B"))
    denom <- switch(unit,
        "GB" = 1e9,
        "MB" = 1e6,
        "KB" = 1e3,
        "B" = 1
    )
    size <- function(x) paste(round(object.size(x) / denom, 2), unit)
    for (name in names(obj)) {
        x <- obj[[name]]
        indent <- collapse(rep("    ", level), "")
        cat2(indent, name, " ", size(x), sep = "")
        if (is.list(x) && length(x) < max_len && level < max_level) {
            du(x, name, level + 1, max_level, unit = unit)
        }
    }
    if (level == 0) {
        total <- size(obj)
        cat2("Total:", total)
        invisible(total)
    }
}

#' @export
#'
#' @title Print the Structure of a Directory Tree
#'
#' @description
#' Prints the structure of a directory tree up to a specified maximum level of
#' depth. It lists all files and directories under the specified path,
#' displaying them in a tree-like structure.
#'
#' @param path The root path from which to start listing the directory
#' structure.
#'
#' @param max.level The maximum depth of directories to list.
#'
#' @param level Internal parameter used for recursion, indicating the current
#' level of depth.
#'
#' @param prefix Internal parameter used for formatting the printed tree
#' structure.
#'
#' @return
#' NULL, called for its side effect of printing the directory structure.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' metabodecon_dir <- system.file(package = "metabodecon")
#' tree(metabodecon_dir, max.level = 1)
tree <- function(path, max.level = 2, level = 0, prefix = "") {
    if (level == max.level) {
        return()
    }
    entries <- list.files(path, full.names = TRUE)
    dirs <- entries[isdir <- file.info(entries)$isdir]
    files <- entries[!isdir]
    all_entries <- sort(c(dirs, files))
    num_entries <- length(all_entries)
    if (level == 0) cat(path, "\n", sep = "")
    for (i in seq_along(all_entries)) {
        entry <- all_entries[i]
        is_last <- i == num_entries
        prefix2 <- if (is_last) "\u2514\u2500\u2500 " else "\u251C\u2500\u2500 "
        cat(prefix, prefix2, basename(entry), ifelse(file.info(entry)$isdir, "/", ""), "\n", sep = "")
        new_prefix <- if (is_last) paste0(prefix, "    ") else paste0(prefix, "\u2502   ")
        if (file.info(entry)$isdir) tree(entry, max.level, level + 1, new_prefix)
    }
    invisible(NULL)
}

#' @noRd
#' @description Update values of a list and return the modified list.
#' @details
#' Setting a value to NULL will **not** remove the key from the list, but set
#' the value to NULL. I.e. setting values using `set` is closer to `[<-` than
#' to `[[<-`. Example:
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @examples
#' # Inputs
#' xx <- list(a=1, b=2:5, d="a")
#'
#' # Examples
#' yy <- set(xx, a=10, d=4, f=sqrt)  # Set a and d. Add f.
#' zz <- set(xx, a=NULL)             # Set a to NULL.
#'
#' # Outputs
#' stopifnot(identical(yy, list(a=10, b=2:5, d=4, f=sqrt)))
#' stopifnot(identical(zz, list(a=NULL, b=2:5, d="a")))
#'
#' # For comparison
#' xx$a <- NULL                      # Removes a
#' xx[["a"]] <- NULL                 # Removes a
#' xx["a"] <- list(NULL)             # Set a to NULL (like `set`)
#'
set <- function(...) {
    args <- list(...)
    obj <- args[[1]]
    vals <- args[-1]
    keys <- names(vals)
    mapply(function(k, v) obj[k] <<- list(v), keys, vals)
    obj
}

#' @noRd
#' @title Pop named Element from List
#' @description
#' Returns the value of `obj[[key]]` and sets `obj[[key]]` to NULL. This
#' function modifies the input list in-place, which is different from most other
#' R functions.
#' @param obj A list.
#' @param obj A list.
#' @param key The key to pop from the list.
#' @param default The default value to return if the key does not exist in the list.
#' @author 2024-2025 Tobias Schmidt: initial version.
#' @examples
#' tmp <- list(a = NULL, b = 2); pop(tmp, "a")    # Returns NULL
#' tmp <- list(a = NULL, b = 2); pop(tmp, "a", 5) # Returns NULL
#' tmp <- list(a = NULL, b = 2); pop(tmp, "b")    # Returns 2
#' tmp <- list(a = NULL, b = 2); pop(tmp, "b", 5) # Returns 2
#' tmp <- list(a = NULL, b = 2); pop(tmp, "z")    # Returns NULL
#' tmp <- list(a = NULL, b = 2); pop(tmp, "z", 5) # Returns 5
pop <- function(obj, key, default = NULL, env = parent.frame()) {
    val <- obj[[key]]
    if (!is.null(default) && is.null(val) && !exists(key, obj)) {
        # If the value of 'key' is null, it can have two reasons:
        # 1. The key does not exist in 'obj'. In this case we want to return the 'default' value.
        # 2. The key exists in 'obj', but is explicitly set to NULL. In this case we want to return NULL.
        # If 'default' is NULL, we can skip this check as we would return NULL in any case.
        val <- default
    }
    expr <- substitute(obj[[key]] <- NULL)
    eval(expr, env) # Remove key from obj in calling env
    val
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
timestamp <- function() {
    format(Sys.time(), "%Y-%m-%d_%H-%M-%S")
}

#' @export
#' @title Make transparent
#' @description Make a color transparent by adding an alpha channel.
#' @param col Character string specifying the color to make transparent.
#' @param alpha Numeric value between 0 and 1 specifying the transparency level.
#' @return A character string representing the color with an alpha channel.
#' @author 2024-2025 Tobias Schmidt: initial version.
#' @examples
#' transp("violet", 0.08)
#' transp("black", 0.5)
transp <- function(col = "violet", alpha = 0.08) {
    col <- col2rgb(col)[, 1] / 255
    rgb(col[1], col[2], col[3], alpha = alpha)
}

#' @noRd
#' @description
#' Multi core version of mapply with automatic logging of worker output.
#' For `nw == 1` normal `mapply` is used.
#' @author 2024-2025 Tobias Schmidt: initial version.
mcmapply <- function(nw, FUN, ..., loadpkg = TRUE, log = TRUE) {
    if (nw == 1) return(mapply(FUN, ..., SIMPLIFY = FALSE))
    logf("Creating pool of worker processes")
    cl <- makeCluster(nw)
    on.exit(stopCluster(cl), add = TRUE)
    if (loadpkg) {
        expr <- parse(text = 'attach(asNamespace("metabodecon"))')
        clusterCall(cl, eval, expr)
    }
    if (log) {
        logfiles <- get_worker_logs(nw)
        logf("For progress output see:")
        sapply(logfiles, logf)
        clusterApply(cl, logfiles, sink, append = TRUE, type = "output")
    }
    clusterMap(cl, FUN, ..., SIMPLIFY = FALSE)
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
get_worker_logs <- function(nw, create = TRUE) {
    timestamp <- format(Sys.time(), "%Y%m%d_%H%M%OS3")
    timestamp <- gsub(".", "_", timestamp, fixed = TRUE)
    logdirrel <- file.path("logs", timestamp)
    logdir <- tmpdir(subdir = logdirrel, create = TRUE)
    logfiles <- paste0("worker_", seq_len(nw), ".log")
    logpaths <- file.path(logdir, logfiles)
    if (create) file.create(logpaths)
    logpaths
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
load_all <- function() {
    x <- Sys.time()
    logf("Calling: pkgload::load_all(reset = TRUE)")
    pkgload::load_all(reset = TRUE, quiet = TRUE)
    logf("Calling: pkgload_env$insert_global_shims(force = TRUE)")
    pkgload_env <- environment(pkgload::load_all)
    pkgload_env$insert_global_shims(force = TRUE)
    diff <- Sys.time() - x
    logf("Elapsed: %s", format(diff))
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
document <- function() {
    x <- Sys.time()
    logf("Calling: devtools::document(quiet = TRUE)")
    devtools::document(quiet = TRUE)
    logf("Calling: pkgload_env$insert_global_shims(force = TRUE)")
    pkgload_env <- environment(pkgload::load_all)
    pkgload_env$insert_global_shims(force = TRUE)
    diff <- Sys.time() - x
    logf("Elapsed: %s", format(diff))
}

# On Load (Private) #####

#' @noRd
#'
#' @description
#' Acts like [stopifnot()] during development but does nothing in production.
#'
#' @author 2024-2025 Tobias Schmidt: initial version.
#'
#' @details
#' In  the  package  source  code,  this  function  is  defined  as  a  copy  of
#' [stopifnot()]. However, during package loading, it is replaced with an  empty
#' function unless the package is loaded via [devtools::load_all()]. The  actual
#' replacement is implemented in [.onLoad()].
#'
#' The idea is  that  exported  functions  should  use  plain  [stopifnot()]  to
#' validate their  inputs,  whereas  private  functions  should  use  [assert()]
#' instead. This approach  allows  us  to  use  rigorous  type  checking  during
#' development without impacting performance in production.
#'
#' If we need to keep assertions enabled in production, we can  set  the  option
#' `metabodecon.assert` to `stopifnot` before loading the package.
#'
#' If we want to disable assertions during development, e.g.  to  get  realistic
#' runtime  estimates,  we  can   set   the   option   `metabodecon.assert`   to
#' `function(...) {}` before calling `devtools::load_all()`.
#'
#' Example:
#'
#' ```r
#' # Steps:
#' # (1) Load metabodecon with assertions disabled
#  # (2) Unload metabodecon
#  # (3A) Configure stopifnot as the assertion function OR
#  # (3B) Configure empty function as assertion function
#  # (4) Reload metabodecon
#' library(metabodecon)                           # (1)
#' unloadNamespace("metabodecon")                 # (2)
#' options(metabodecon.assert = stopifnot)        # (3A)
#' options(metabodecon.assert = function(...) {}) # (3B)
#' library(metabodecon)                           # (4)
#' ```
assert <- stopifnot

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
.onLoad <- function(libname, pkgname) {
    pkgenv <- topenv()
    if (!loaded_via_devtools()) pkgenv$assert <- function(...) {}
    if (!is.null(x <- .Options$metabodecon.assert)) pkgenv$assert <- x
}

#' @noRd
#' @author 2024-2025 Tobias Schmidt: initial version.
loaded_via_devtools <- function() {
    pkg_dir <- dirname(system.file("DESCRIPTION", package = "metabodecon"))
    dir.exists(file.path(pkg_dir, "inst"))
}

Try the metabodecon package in your browser

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

metabodecon documentation built on Nov. 5, 2025, 7:12 p.m.