Nothing
#' Wavelet analysis of dendrometer series
#'
#' @description
#' Performs continuous wavelet analysis on dendrometer time series.
#'
#' The function can work with:
#' \itemize{
#' \item raw dendrometer series from a data frame,
#' \item detrended dendrometer series from \code{dm.detrend.fit()} output,
#' \item first differences of either raw or detrended series.
#' }
#'
#' It automatically:
#' \itemize{
#' \item identifies the temporal resolution of the input series,
#' \item regularizes the series to a complete time grid,
#' \item optionally interpolates missing values,
#' \item performs wavelet analysis separately for each selected tree/series,
#' \item converts wavelet periods to hours for summary and plotting.
#' }
#'
#' @param x Input data. Either:
#' \describe{
#' \item{data.frame}{First column must be time, remaining selected numeric
#' columns are dendrometer series.}
#' \item{dm_detrended object}{Uses \code{x$detrended_data} as input.}
#' }
#' @param TreeNum Either \code{"all"} to use all available dendrometer series,
#' a numeric vector selecting series by position, or a character vector of
#' series names.
#' @param source Which series to analyze. One of:
#' \describe{
#' \item{`"auto"`}{Uses raw series for a data frame input and detrended
#' series for a \code{dm_detrended} object.}
#' \item{`"raw"`}{Use raw dendrometer series from a data frame input.}
#' \item{`"detrended"`}{Use detrended series from a \code{dm_detrended} object.}
#' \item{`"first_diff"`}{Use first differences of the available input series.}
#' }
#' @param detrended_col For \code{dm_detrended} input, which table to use:
#' \code{"detrended_data"}.
#' @param na_action How to handle missing values after completing the regular time
#' grid. One of:
#' \describe{
#' \item{`"interpolate"`}{Linearly interpolate internal gaps and carry ends
#' forward/backward.}
#' \item{`"fail"`}{Stop if missing values are found.}
#' }
#' @param loess_span Smoothing span passed to \code{WaveletComp::analyze.wavelet()}.
#' @param dj Frequency resolution parameter passed to
#' \code{WaveletComp::analyze.wavelet()}.
#' @param lowerPeriod Optional lower period bound in native time units of the
#' detected input resolution. If \code{NULL}, the default from
#' \code{WaveletComp::analyze.wavelet()} is used.
#' @param upperPeriod Optional upper period bound in native time units of the
#' detected input resolution. If \code{NULL}, the default from
#' \code{WaveletComp::analyze.wavelet()} is used.
#' @param make_pval Logical; if \code{TRUE}, compute Monte Carlo significance.
#' @param n_sim Number of simulations used when \code{make_pval = TRUE}.
#' @param verbose Logical; if \code{TRUE}, prints a completion message.
#'
#' @return
#' An object of class \code{"dm_wavelet"} with elements:
#' \describe{
#' \item{call}{The matched function call.}
#' \item{input_type}{Either \code{"raw"} or \code{"dm_detrended"}.}
#' \item{source}{Series source actually used.}
#' \item{series}{Character vector of analyzed series names.}
#' \item{time_unit}{Detected time unit of the input series.}
#' \item{dt}{Detected time step in native units.}
#' \item{dt_hours}{Detected time step expressed in hours.}
#' \item{resolution_seconds}{Detected time step in seconds.}
#' \item{data_used}{Regularized time series used for analysis.}
#' \item{results}{Named list of wavelet results, one per series. Each entry
#' contains the original \code{WaveletComp} object plus time and
#' period-in-hours information.}
#' \item{settings}{Analysis settings.}
#' }
#'
#' @importFrom dplyr left_join arrange %>%
#' @importFrom tibble as_tibble tibble
#' @importFrom lubridate ymd_hms parse_date_time
#' @importFrom zoo na.approx na.locf
#' @export
dm_wavelet <- function(x,
TreeNum = "all",
source = c("auto", "raw", "detrended", "first_diff"),
detrended_col = c("detrended_data"),
na_action = c("interpolate", "fail"),
loess_span = 0.75,
dj = 1 / 20,
lowerPeriod = NULL,
upperPeriod = NULL,
make_pval = TRUE,
n_sim = 10,
verbose = TRUE) {
source <- match.arg(source)
detrended_col <- match.arg(detrended_col)
na_action <- match.arg(na_action)
if (!requireNamespace("WaveletComp", quietly = TRUE)) {
stop("Package 'WaveletComp' is required for dm_wavelet().")
}
cl <- match.call()
dmw_parse_time <- function(tt) {
if (inherits(tt, "POSIXct") || inherits(tt, "POSIXt")) return(as.POSIXct(tt))
if (inherits(tt, "Date")) return(as.POSIXct(tt))
out <- suppressWarnings(lubridate::ymd_hms(tt, quiet = TRUE))
if (all(is.na(out))) {
out <- suppressWarnings(
lubridate::parse_date_time(
tt,
orders = c("ymd HMS", "ymd HM", "ymd",
"dmy HMS", "dmy HM", "dmy",
"mdy HMS", "mdy HM", "mdy"),
quiet = TRUE
)
)
}
out
}
dmw_select_cols <- function(dat, TreeNum, meta_cols = character(0)) {
series_cols <- setdiff(names(dat), meta_cols)
series_cols <- series_cols[vapply(dat[series_cols], is.numeric, logical(1))]
if (length(series_cols) == 0) {
stop("No numeric dendrometer series available.")
}
if (is.character(TreeNum) && length(TreeNum) == 1 && tolower(TreeNum) == "all") {
keep <- series_cols
} else if (is.numeric(TreeNum)) {
idx <- as.integer(TreeNum)
if (any(is.na(idx)) || any(idx < 1) || any(idx > length(series_cols))) {
stop("'TreeNum' numeric values are out of range.")
}
keep <- series_cols[idx]
} else if (is.character(TreeNum)) {
miss <- setdiff(TreeNum, series_cols)
if (length(miss) > 0) {
warning("These requested series were not found and were ignored: ",
paste(miss, collapse = ", "))
}
keep <- intersect(TreeNum, series_cols)
if (length(keep) == 0) {
stop("None of the requested TreeNum names were found.")
}
} else {
stop("'TreeNum' must be 'all', numeric indices, or character names.")
}
keep
}
dmw_infer_dt <- function(time_vec) {
dsec <- as.numeric(diff(time_vec), units = "secs")
dsec <- dsec[is.finite(dsec) & dsec > 0]
if (length(dsec) == 0) {
stop("Could not infer time step from TIME column.")
}
med <- stats::median(dsec, na.rm = TRUE)
if (med >= 86400 && abs(med / 86400 - round(med / 86400)) < 1e-6) {
return(list(dt = med / 86400, unit = "days", sec = med, hours = med / 3600))
}
if (med >= 3600 && abs(med / 3600 - round(med / 3600)) < 1e-6) {
return(list(dt = med / 3600, unit = "hours", sec = med, hours = med / 3600))
}
if (med >= 60 && abs(med / 60 - round(med / 60)) < 1e-6) {
return(list(dt = med / 60, unit = "mins", sec = med, hours = med / 3600))
}
list(dt = med, unit = "secs", sec = med, hours = med / 3600)
}
dmw_period_to_hours <- function(period, time_unit) {
if (time_unit == "days") return(period * 24)
if (time_unit == "hours") return(period)
if (time_unit == "mins") return(period / 60)
if (time_unit == "secs") return(period / 3600)
period
}
dmw_complete_regular <- function(dat, dt_sec) {
tz_use <- attr(dat$TIME, "tzone")
if (is.null(tz_use) || length(tz_use) == 0) tz_use <- "UTC"
full_time <- seq(
from = min(dat$TIME, na.rm = TRUE),
to = max(dat$TIME, na.rm = TRUE),
by = dt_sec
)
tibble::tibble(TIME = as.POSIXct(full_time, origin = "1970-01-01", tz = tz_use)) %>%
dplyr::left_join(dat, by = "TIME") %>%
dplyr::arrange(.data$TIME)
}
dmw_fill_na <- function(z) {
z2 <- zoo::na.approx(z, na.rm = FALSE)
z2 <- zoo::na.locf(z2, na.rm = FALSE)
z2 <- zoo::na.locf(z2, fromLast = TRUE, na.rm = FALSE)
z2
}
if (inherits(x, "dm_detrended")) {
input_type <- "dm_detrended"
if (source == "raw") {
stop("source = 'raw' is not valid for a dm_detrended object.")
}
dat0 <- tibble::as_tibble(x[[detrended_col]])
meta_cols <- intersect(
c("TIME", "doy", "season_label", "season_start", "season_end", "season_day"),
names(dat0)
)
keep_cols <- dmw_select_cols(dat0, TreeNum = TreeNum, meta_cols = meta_cols)
dat <- dat0[, c("TIME", keep_cols), drop = FALSE]
source_used <- if (source == "auto") "detrended" else source
} else if (is.data.frame(x)) {
input_type <- "raw"
if (source == "detrended") {
stop("source = 'detrended' requires a dm_detrended object.")
}
dat0 <- tibble::as_tibble(x)
names(dat0)[1] <- "TIME"
keep_cols <- dmw_select_cols(dat0, TreeNum = TreeNum, meta_cols = "TIME")
dat <- dat0[, c("TIME", keep_cols), drop = FALSE]
source_used <- if (source == "auto") "raw" else source
} else {
stop("'x' must be either a data.frame or an object of class 'dm_detrended'.")
}
dat$TIME <- dmw_parse_time(dat$TIME)
if (any(is.na(dat$TIME))) {
stop("Some timestamps in the first column could not be parsed.")
}
dat <- dat[order(dat$TIME), , drop = FALSE]
dt_info <- dmw_infer_dt(dat$TIME)
dat_reg <- dmw_complete_regular(dat, dt_sec = dt_info$sec)
series_names <- setdiff(names(dat_reg), "TIME")
for (ss in series_names) {
if (na_action == "interpolate") {
dat_reg[[ss]] <- dmw_fill_na(dat_reg[[ss]])
} else {
if (any(is.na(dat_reg[[ss]]))) {
stop("Missing values found after regularization. Use na_action = 'interpolate' or pre-clean the series.")
}
}
}
if (source_used == "first_diff") {
for (ss in series_names) {
dat_reg[[ss]] <- c(NA_real_, diff(dat_reg[[ss]]))
}
dat_reg <- dat_reg[-1, , drop = FALSE]
}
for (ss in series_names) {
if (any(is.na(dat_reg[[ss]]))) {
stop("Series '", ss, "' still contains missing values after preprocessing.")
}
if (sum(is.finite(dat_reg[[ss]])) < 8) {
stop("Series '", ss, "' is too short for wavelet analysis after preprocessing.")
}
}
res_list <- vector("list", length(series_names))
names(res_list) <- series_names
for (ss in series_names) {
tmp <- data.frame(series = as.numeric(dat_reg[[ss]]))
wave_args <- list(
my.data = tmp,
my.series = "series",
loess.span = loess_span,
dt = dt_info$dt,
dj = dj,
make.pval = make_pval,
n.sim = n_sim
)
if (!is.null(lowerPeriod)) wave_args$lowerPeriod <- lowerPeriod
if (!is.null(upperPeriod)) wave_args$upperPeriod <- upperPeriod
wt <- do.call(WaveletComp::analyze.wavelet, wave_args)
period_hours <- dmw_period_to_hours(wt$Period, dt_info$unit)
res_list[[ss]] <- list(
wavelet = wt,
time = dat_reg$TIME,
series = dat_reg[[ss]],
period_native = wt$Period,
period_hours = period_hours,
avg_power = if (!is.null(wt$Power.avg)) {
as.numeric(wt$Power.avg)
} else {
rowMeans(wt$Power, na.rm = TRUE)
}
)
}
out <- list(
call = cl,
input_type = input_type,
source = source_used,
series = series_names,
time_unit = dt_info$unit,
dt = dt_info$dt,
dt_hours = dt_info$hours,
resolution_seconds = dt_info$sec,
data_used = dat_reg,
results = res_list,
settings = list(
TreeNum = TreeNum,
loess_span = loess_span,
dj = dj,
lowerPeriod = lowerPeriod,
upperPeriod = upperPeriod,
make_pval = make_pval,
n_sim = n_sim,
na_action = na_action
)
)
class(out) <- "dm_wavelet"
if (isTRUE(verbose)) {
message(
"dm_wavelet completed for ", length(series_names),
" series. Detected resolution: ", dt_info$dt, " ", dt_info$unit,
" (", round(dt_info$hours, 4), " hours)."
)
}
out
}
#' Summarize a dm_wavelet object
#'
#' @description
#' Summarizes wavelet-analysis results produced by \code{dm_wavelet()}.
#'
#' For each analyzed series, the summary reports:
#' \itemize{
#' \item the dominant period in hours,
#' \item the corresponding maximum average wavelet power,
#' \item the analyzed period range in hours,
#' \item the detected temporal resolution.
#' }
#'
#' @param object An object of class \code{"dm_wavelet"}.
#' @param top_n Integer. Number of strongest periods to report per series.
#' @param ... Further arguments passed to or from other methods.
#'
#' @return An object of class \code{"summary.dm_wavelet"}.
#' @method summary dm_wavelet
#' @export
summary.dm_wavelet <- function(object, top_n = 3, ...) {
if (!inherits(object, "dm_wavelet")) {
stop("'object' must be an object of class 'dm_wavelet'.")
}
if (!is.numeric(top_n) || length(top_n) != 1 || is.na(top_n) || top_n < 1) {
stop("'top_n' must be a positive integer.")
}
top_n <- as.integer(top_n)
series_rows <- vector("list", length(object$results))
top_rows <- vector("list", length(object$results))
nm <- names(object$results)
for (i in seq_along(object$results)) {
ss <- nm[i]
res <- object$results[[i]]
per_h <- as.numeric(res$period_hours)
avg_pow <- as.numeric(res$avg_power)
ok <- is.finite(per_h) & is.finite(avg_pow)
if (sum(ok) == 0) {
series_rows[[i]] <- tibble::tibble(
series = ss,
n_obs = length(res$series),
dominant_period_hours = NA_real_,
max_average_power = NA_real_,
min_period_hours = NA_real_,
max_period_hours = NA_real_,
dt = object$dt,
dt_hours = object$dt_hours,
time_unit = object$time_unit
)
top_rows[[i]] <- tibble::tibble(
series = ss,
rank = integer(0),
period_hours = numeric(0),
average_power = numeric(0)
)
next
}
per_h <- per_h[ok]
avg_pow <- avg_pow[ok]
ord <- order(avg_pow, decreasing = TRUE)
ord_top <- head(ord, top_n)
series_rows[[i]] <- tibble::tibble(
series = ss,
n_obs = length(res$series),
dominant_period_hours = per_h[ord[1]],
max_average_power = avg_pow[ord[1]],
min_period_hours = min(per_h, na.rm = TRUE),
max_period_hours = max(per_h, na.rm = TRUE),
dt = object$dt,
dt_hours = object$dt_hours,
time_unit = object$time_unit
)
top_rows[[i]] <- tibble::tibble(
series = ss,
rank = seq_along(ord_top),
period_hours = per_h[ord_top],
average_power = avg_pow[ord_top]
)
}
out <- list(
overview = tibble::tibble(
input_type = object$input_type,
source = object$source,
n_series = length(object$series),
dt = object$dt,
dt_hours = object$dt_hours,
time_unit = object$time_unit
),
series_summary = dplyr::bind_rows(series_rows),
top_periods = dplyr::bind_rows(top_rows),
settings = object$settings
)
class(out) <- "summary.dm_wavelet"
out
}
#' @method print summary.dm_wavelet
#' @export
print.summary.dm_wavelet <- function(x, ...) {
cat("dm_wavelet summary\n")
cat("------------------\n")
print(x$overview)
cat("\nSeries summary:\n")
print(x$series_summary)
cat("\nTop periods (hours):\n")
print(x$top_periods)
invisible(x)
}
#' @title Plot method for wavelet analysis output
#'
#' @description
#' S3 plotting method for objects returned by \code{dm_wavelet()}.
#'
#' The function provides three main visualization types:
#' \itemize{
#' \item \code{"series"}: plots the analyzed input dendrometer series over time,
#' \item \code{"average"}: plots the average wavelet power spectrum against period,
#' \item \code{"power"}: plots the full wavelet power spectrum as a time-period image.
#' }
#'
#' Periods are shown in \strong{hours}, regardless of the original temporal
#' resolution of the input series. For power plots, the x-axis shows the actual
#' time series timestamps and the y-axis shows the wavelet period in hours.
#'
#' @param x An object of class \code{"dm_wavelet"} returned by
#' \code{dm_wavelet()}.
#' @param y Unused.
#' @param series Optional character vector giving one or more series names to
#' plot. If \code{NULL}, the first available series is used.
#' @param type Character string specifying the plot type. One of:
#' \describe{
#' \item{`"power"`}{Wavelet power spectrum as a raster image.}
#' \item{`"average"`}{Average wavelet power spectrum across the full time series.}
#' \item{`"series"`}{Original analyzed series over time.}
#' }
#' @param facet Logical. If \code{TRUE} and more than one series is selected,
#' panels are faceted by series.
#' @param log_period Logical. If \code{TRUE}, the period axis is shown on a
#' log10 scale where applicable.
#' @param log_power Logical. If \code{TRUE} and \code{type = "power"}, the
#' plotted wavelet power is transformed using \code{log10(power + eps)} to
#' improve contrast.
#' @param clip_quantile Optional numeric vector of length 2 giving lower and
#' upper quantiles used to clip wavelet power before plotting, for example
#' \code{c(0.01, 0.99)}. This is useful when a few extreme values dominate the
#' colour scale. Use \code{NULL} to disable clipping.
#' @param show_sig Logical. If \code{TRUE}, overlays significance information
#' when available. For \code{type = "average"}, significant periods are marked
#' as points. For \code{type = "power"}, significant regions are shown as
#' contour lines.
#' @param show_coi Logical. If \code{TRUE} and \code{type = "power"}, the cone of
#' influence (COI) is added as a shaded overlay.
#' @param coi_fill Fill colour used for the cone of influence shading.
#' @param coi_alpha Numeric transparency of the cone of influence shading.
#' @param siglvl Numeric significance threshold between 0 and 1. Default is
#' \code{0.05}.
#' @param sig_color Colour used for significance overlays.
#' @param sig_size Line width or point size used for significance overlays.
#' @param main Optional plot title. If \code{NULL}, a default title is used.
#' @param ... Further arguments passed to or from other methods. Currently unused.
#'
#' @details
#' For \code{type = "power"}, the function uses the wavelet power matrix stored
#' in the \code{"dm_wavelet"} object and converts the Fourier periods to hours.
#' The power plot may also show:
#' \itemize{
#' \item significance contours, when p-values are available,
#' \item the cone of influence (COI), when COI information is available.
#' }
#'
#' For \code{type = "average"}, the function plots the average wavelet power
#' spectrum and may optionally indicate significant periods when average-spectrum
#' p-values are available.
#'
#' For \code{type = "series"}, the original analyzed series are plotted without
#' any wavelet transformation.
#'
#' @return
#' A \code{ggplot2} object.
#'
#' @seealso
#' \code{\link{dm_wavelet}}, \code{\link[ggplot2]{ggplot}}
#'
#' @examples
#' \donttest{
#' wv <- dm_wavelet(
#' x = gf_nepa17,
#' TreeNum = 1:2,
#' source = "raw",
#' make_pval = TRUE,
#' verbose = FALSE
#' )
#'
#' # original series
#' plot(wv, type = "series")
#'
#' # average wavelet power
#' plot(wv, type = "average")
#'
#' # full power spectrum
#' plot(wv, type = "power")
#'
#' # one selected series
#' plot(wv, series = names(wv$results)[1], type = "power")
#'
#' # stronger contrast in the power plot
#' plot(
#' wv,
#' type = "power",
#' log_power = TRUE,
#' clip_quantile = c(0.05, 0.95)
#' )
#' }
#'
#' @method plot dm_wavelet
#' @importFrom dplyr bind_rows
#' @importFrom tibble tibble
#' @importFrom ggplot2 ggplot aes geom_raster geom_line geom_point geom_contour
#' geom_polygon facet_wrap theme_bw theme element_text labs scale_y_log10
#' scale_x_log10 scale_fill_viridis_c coord_cartesian
#' @export
plot.dm_wavelet <- function(x,
y = NULL,
series = NULL,
type = c("power", "average", "series"),
facet = TRUE,
log_period = TRUE,
log_power = TRUE,
clip_quantile = c(0.01, 0.99),
show_sig = TRUE,
show_coi = TRUE,
coi_fill = "white",
coi_alpha = 0.45,
siglvl = 0.05,
sig_color = "black",
sig_size = 0.4,
main = NULL,
...) {
TIME <- value <- period_hours <- average_power <- power_plot <- pval <- NULL
if (!inherits(x, "dm_wavelet")) {
stop("'x' must be an object of class 'dm_wavelet'.")
}
if (!requireNamespace("ggplot2", quietly = TRUE)) {
stop("Package 'ggplot2' is required for plot.dm_wavelet().")
}
type <- match.arg(type)
if (is.null(series)) {
series <- x$series[1]
}
miss <- setdiff(series, x$series)
if (length(miss) > 0) {
stop("These requested series were not found: ", paste(miss, collapse = ", "))
}
if (!is.numeric(siglvl) || length(siglvl) != 1 || is.na(siglvl) ||
siglvl <= 0 || siglvl >= 1) {
stop("'siglvl' must be a single number between 0 and 1.")
}
dmw_period_to_hours <- function(period, time_unit) {
if (time_unit == "days") return(period * 24)
if (time_unit == "hours") return(period)
if (time_unit == "mins") return(period / 60)
if (time_unit == "secs") return(period / 3600)
period
}
if (type == "series") {
df_list <- lapply(series, function(ss) {
res <- x$results[[ss]]
tibble::tibble(
TIME = res$time,
value = as.numeric(res$series),
series = ss
)
})
dat <- dplyr::bind_rows(df_list)
p <- ggplot2::ggplot(dat, ggplot2::aes(x = TIME, y = value, colour = series)) +
ggplot2::geom_line() +
ggplot2::theme_bw() +
ggplot2::theme(
legend.position = if (length(series) > 1) "right" else "none",
axis.title = ggplot2::element_text(size = 14),
axis.text = ggplot2::element_text(size = 11)
) +
ggplot2::labs(
x = "Time",
y = "Series value",
colour = "Series",
title = if (is.null(main)) "Input series used for wavelet analysis" else main
)
if (length(series) > 1 && isTRUE(facet)) {
p <- p + ggplot2::facet_wrap(stats::as.formula("~ series"), scales = "free_y", ncol = 1)
}
return(p)
}
if (type == "average") {
df_list <- lapply(series, function(ss) {
res <- x$results[[ss]]
wt <- res$wavelet
out <- tibble::tibble(
period_hours = as.numeric(res$period_hours),
average_power = as.numeric(res$avg_power),
series = ss
)
if (!is.null(wt$Power.avg.pval)) {
pavg <- as.numeric(wt$Power.avg.pval)
n <- min(length(pavg), nrow(out))
out$avg_pval <- NA_real_
out$avg_pval[seq_len(n)] <- pavg[seq_len(n)]
} else {
out$avg_pval <- NA_real_
}
out
})
dat <- dplyr::bind_rows(df_list)
p <- ggplot2::ggplot(
dat,
ggplot2::aes(x = period_hours, y = average_power, colour = series)
) +
ggplot2::geom_line() +
ggplot2::theme_bw() +
ggplot2::theme(
legend.position = if (length(series) > 1) "right" else "none",
axis.title = ggplot2::element_text(size = 14),
axis.text = ggplot2::element_text(size = 11)
) +
ggplot2::labs(
x = "Period (hours)",
y = "Average wavelet power",
colour = "Series",
title = if (is.null(main)) "Average wavelet power spectrum" else main
)
if (isTRUE(log_period) && all(dat$period_hours > 0, na.rm = TRUE)) {
p <- p + ggplot2::scale_x_log10()
}
if (isTRUE(show_sig) && "avg_pval" %in% names(dat) && any(is.finite(dat$avg_pval))) {
sig_dat <- dat[is.finite(dat$avg_pval) & dat$avg_pval <= siglvl, , drop = FALSE]
if (nrow(sig_dat) > 0) {
p <- p + ggplot2::geom_point(
data = sig_dat,
mapping = ggplot2::aes(x = period_hours, y = average_power),
inherit.aes = FALSE,
colour = sig_color,
size = max(1, sig_size * 2)
)
}
}
if (length(series) > 1 && isTRUE(facet)) {
p <- p + ggplot2::facet_wrap(stats::as.formula("~ series"), scales = "free_y", ncol = 1)
}
return(p)
}
if (type == "power") {
df_list <- lapply(series, function(ss) {
res <- x$results[[ss]]
wt <- res$wavelet
pow <- as.matrix(wt$Power)
n_p <- min(nrow(pow), length(res$period_hours))
n_t <- min(ncol(pow), length(res$time))
pow <- pow[seq_len(n_p), seq_len(n_t), drop = FALSE]
per <- as.numeric(res$period_hours[seq_len(n_p)])
tt <- res$time[seq_len(n_t)]
df <- expand.grid(
period_hours = per,
TIME = tt,
KEEP.OUT.ATTRS = FALSE,
stringsAsFactors = FALSE
)
df$power <- c(pow)
df$series <- ss
if (!is.null(wt$Power.pval)) {
pmat <- as.matrix(wt$Power.pval)
pmat <- pmat[seq_len(n_p), seq_len(n_t), drop = FALSE]
df$pval <- c(pmat)
} else {
df$pval <- NA_real_
}
df
})
dat <- dplyr::bind_rows(df_list)
# keep only valid positive periods
dat <- dat[is.finite(dat$period_hours) & dat$period_hours > 0, , drop = FALSE]
# fixed y range from actual wavelet periods only
y_rng <- range(dat$period_hours, na.rm = TRUE)
if (!is.null(clip_quantile)) {
if (!is.numeric(clip_quantile) || length(clip_quantile) != 2 ||
any(is.na(clip_quantile)) ||
clip_quantile[1] < 0 || clip_quantile[2] > 1 ||
clip_quantile[1] >= clip_quantile[2]) {
stop("'clip_quantile' must be NULL or a numeric vector like c(0.01, 0.99).")
}
qv <- stats::quantile(dat$power, probs = clip_quantile, na.rm = TRUE, names = FALSE)
dat$power_plot <- pmin(pmax(dat$power, qv[1]), qv[2])
} else {
dat$power_plot <- dat$power
}
if (isTRUE(log_power)) {
eps <- max(min(dat$power_plot[dat$power_plot > 0], na.rm = TRUE) / 10, 1e-12)
if (!is.finite(eps)) eps <- 1e-12
dat$power_plot <- log10(dat$power_plot + eps)
fill_lab <- "log10(Power)"
} else {
fill_lab <- "Power"
}
p <- ggplot2::ggplot(
dat,
ggplot2::aes(x = TIME, y = period_hours, fill = power_plot)
) +
ggplot2::geom_raster() +
ggplot2::scale_fill_viridis_c(option = "D", na.value = "grey85") +
ggplot2::theme_bw() +
ggplot2::theme(
legend.position = "right",
axis.title = ggplot2::element_text(size = 14),
axis.text = ggplot2::element_text(size = 11)
) +
ggplot2::labs(
x = "Time",
y = "Period (hours)",
fill = fill_lab,
title = if (is.null(main)) "Wavelet power spectrum" else main
)
if (isTRUE(show_coi)) {
coi_list <- lapply(series, function(ss) {
res <- x$results[[ss]]
wt <- res$wavelet
if (is.null(wt$coi.1) || is.null(wt$coi.2) ||
is.null(wt$axis.1) || is.null(wt$axis.2)) {
return(NULL)
}
tz_use <- attr(res$time, "tzone")
if (is.null(tz_use) || length(tz_use) == 0) tz_use <- "UTC"
x_num <- stats::approx(
x = as.numeric(wt$axis.1),
y = as.numeric(res$time),
xout = as.numeric(wt$coi.1),
rule = 2
)$y
period_native <- 2^as.numeric(wt$coi.2)
period_hours <- dmw_period_to_hours(period_native, x$time_unit)
# clip COI to actual plotted y range
period_hours <- pmin(pmax(period_hours, y_rng[1]), y_rng[2])
tibble::tibble(
TIME = as.POSIXct(x_num, origin = "1970-01-01", tz = tz_use),
period_hours = period_hours,
series = ss
)
})
coi_dat <- dplyr::bind_rows(coi_list)
if (nrow(coi_dat) > 0) {
p <- p + ggplot2::geom_polygon(
data = coi_dat,
mapping = ggplot2::aes(x = TIME, y = period_hours, group = series),
inherit.aes = FALSE,
fill = coi_fill,
alpha = coi_alpha,
colour = NA
)
}
}
if (isTRUE(show_sig) && "pval" %in% names(dat) && any(is.finite(dat$pval))) {
sig_dat <- dat[is.finite(dat$pval), , drop = FALSE]
if (nrow(sig_dat) > 0) {
p <- p + ggplot2::geom_contour(
data = sig_dat,
mapping = ggplot2::aes(
x = TIME,
y = period_hours,
z = pval
),
breaks = siglvl,
colour = sig_color,
linewidth = sig_size,
inherit.aes = FALSE
)
}
}
# force the y axis to the real period range
if (isTRUE(log_period) && all(y_rng > 0)) {
p <- p + ggplot2::scale_y_log10(limits = y_rng)
} else {
p <- p + ggplot2::coord_cartesian(ylim = y_rng, expand = FALSE)
}
if (length(series) > 1 && isTRUE(facet)) {
p <- p + ggplot2::facet_wrap(stats::as.formula("~ series"), scales = "free_x", ncol = 1)
}
return(p)
}
stop("Unknown plot type.")
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.