dwd / fun /plot_weather_dwd.R
alexdum's picture
feat: Add 10-minute DWD data resolution and parallelize granular metadata updates.
1957ae8
# funs/plot_weather_dwd.R
#' Create a placeholder plot for missing data
#' @param message Message to display
#' @return A plotly object
create_empty_plot <- function(message = "Data not available for this parameter") {
plotly::plot_ly() %>%
plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), showlegend = FALSE) %>%
plotly::add_annotations(
text = message,
showarrow = FALSE,
xref = "paper", yref = "paper",
x = 0.5, y = 0.5,
font = list(size = 16, color = "#666")
) %>%
plotly::layout(
xaxis = list(visible = FALSE),
yaxis = list(visible = FALSE),
margin = list(t = 50, b = 50, l = 50, r = 50)
) %>%
plotly::config(displaylogo = FALSE)
}
#' Ensure Temporal Continuity
#'
#' Fills missing time steps in the dataframe with explicit NA rows.
#' Prevents Plotly from linking gaps across missing periods.
#'
#' @param df Data frame with a 'datetime' column
#' @return Data frame with complete time sequence
#' @export
ensure_temporal_continuity <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) < 2) {
return(df)
}
seq_by <- NULL
if (!is.null(resolution)) {
seq_by <- switch(tolower(resolution),
"hourly" = "hour",
"daily" = "day",
"monthly" = "month",
NULL
)
}
if (is.null(seq_by)) {
# Detect resolution using minimum non-zero difference (base resolution)
# This ensures that even if there are many gaps (raising median), the presence of *any* consecutive points proves the resolution.
n_check <- min(nrow(df), 200) # Check more points to find at least one consecutive pair
dt_vals <- as.numeric(difftime(df$datetime[2:n_check], df$datetime[1:(n_check - 1)], units = "hours"))
# Filter out 0 (duplicates) and NAs
dt_vals <- dt_vals[!is.na(dt_vals) & dt_vals > 0]
if (length(dt_vals) == 0) {
return(df)
}
dt <- min(dt_vals)
seq_by <- "hour"
if (dt >= 600) {
seq_by <- "month"
} else if (dt >= 23) {
seq_by <- "day"
}
}
# Generate complete sequence
full_dates <- data.frame(
datetime = seq(
from = min(df$datetime),
to = max(df$datetime),
by = seq_by
)
)
# Merge to fill gaps (right join to keep the sequence)
# Using base merge to avoid extra dependency if possible, but full_join is safer if dplyr loaded
df_complete <- dplyr::full_join(full_dates, df, by = "datetime") %>%
dplyr::arrange(datetime)
return(df_complete)
}
#' Split Dataframe into Continuous Chunks
#'
#' @param df Dataframe with datetime and value columns
#' @param value_col Column name to check for NAs to define breaks
#' @return List of dataframes
split_into_chunks <- function(df, value_col) {
# If no data, return empty list
if (nrow(df) == 0) {
return(list())
}
# Identify runs of non-NA values
# rle is perfect for this
# We check if value IS NA.
is_na_vec <- is.na(df[[value_col]])
# If all NA, return list
if (all(is_na_vec)) {
return(list())
}
rle_res <- rle(is_na_vec)
# Create a vector of Chunk IDs
# Start ID at 1
# We want to increment ID for every change in status, effectively.
# Actually, we just need to group "FALSE" (Not NA) blocks.
# Expand rle to get group IDs for each row is silly, use cumsum change
# Wait, simple split:
# 1. Create a grouping index that changes every time we hit an NA block
# Actually, simplest approach:
# Find start and end indices of !is.na blocks
# Let's stick to the rle expansion, it's robust
chunk_ids <- rep(seq_along(rle_res$values), rle_res$lengths)
df$chunk_id <- chunk_ids
# Filter only the chunks where value is NOT NA
valid_chunks <- df[!is_na_vec, ]
if (nrow(valid_chunks) == 0) {
return(list())
}
# Split by chunk_id
split(valid_chunks, valid_chunks$chunk_id)
}
#' Centralized Cleaning for DWD Data
#'
#' Ensures all weather columns are numeric and that DWD-specific
#' flag values (8000, 9999, -999) are converted to NA.
#'
#' @param df Data frame to clean
#' @return Cleaned data frame
clean_dwd_data <- function(df) {
if (is.null(df) || nrow(df) == 0) {
return(df)
}
# Weather columns to check
weather_cols <- c(
"temp", "dew_point", "rh", "abs_humidity", "vapor_pressure", "wet_bulb_temp",
"precip", "wind_speed", "wind_dir", "pressure", "station_pressure", "cloud_cover", "wind_gust_max", "solar_global", "sunshine_duration",
"visibility",
"soil_temp_2cm", "soil_temp_5cm", "soil_temp_10cm", "soil_temp_20cm", "soil_temp_50cm", "soil_temp_100cm",
"snow_depth", "snow_water_equiv", "snow_fresh_sum", "snow_depth_sum",
"temp_max", "temp_min", "temp_max_avg", "temp_min_avg"
)
# Aggressive base R filtering for 8000 and other flags
for (col in weather_cols) {
if (col %in% names(df)) {
# Convert to numeric if not already
if (!is.numeric(df[[col]])) {
df[[col]] <- suppressWarnings(as.numeric(as.character(df[[col]])))
}
# Catch all variants of 8000, 9999, -999
bad_idx <- is.na(df[[col]]) | df[[col]] >= 7999 | df[[col]] <= -998
df[[col]][bad_idx] <- NA_real_
}
}
# Parameter-specific safety bounds (Physical sanity checks)
if ("temp" %in% names(df)) df$temp[df$temp < -90 | df$temp > 60] <- NA_real_
if ("dew_point" %in% names(df)) df$dew_point[df$dew_point < -90 | df$dew_point > 60] <- NA_real_
if ("wet_bulb_temp" %in% names(df)) df$wet_bulb_temp[df$wet_bulb_temp < -90 | df$wet_bulb_temp > 60] <- NA_real_
if ("rh" %in% names(df)) df$rh[df$rh < 0 | df$rh > 100] <- NA_real_
if ("abs_humidity" %in% names(df)) df$abs_humidity[df$abs_humidity < 0] <- NA_real_
if ("vapor_pressure" %in% names(df)) df$vapor_pressure[df$vapor_pressure < 0] <- NA_real_
if ("precip" %in% names(df)) df$precip[df$precip < 0 | df$precip > 3000] <- NA_real_
if ("pressure" %in% names(df)) df$pressure[df$pressure < 800 | df$pressure > 1100] <- NA_real_
if ("station_pressure" %in% names(df)) df$station_pressure[df$station_pressure < 500 | df$station_pressure > 1100] <- NA_real_
if ("solar_global" %in% names(df)) df$solar_global[df$solar_global < 0 | df$solar_global > 3500] <- NA_real_
if ("visibility" %in% names(df)) df$visibility[df$visibility < 0] <- NA_real_
if ("cloud_cover" %in% names(df)) df$cloud_cover[df$cloud_cover < 0 | df$cloud_cover > 9] <- NA_real_
return(df)
}
#' Calculate Dew Point
#' Approximation using Magnus formula
calculate_dew_point <- function(temp, rh) {
if (is.null(temp) | is.null(rh)) {
return(NA)
}
# Constants
a <- 17.625
b <- 243.04
alpha <- log(rh / 100) + (a * temp) / (b + temp)
dp <- (b * alpha) / (a - alpha)
return(dp)
}
#' Create Daily Summary Helper
create_daily_summary <- function(df) {
if (is.null(df) || nrow(df) == 0) {
return(NULL)
}
# Centralized cleaning
df <- clean_dwd_data(df)
# Check if we already have daily min/max (from daily DWD data)
has_daily_minmax <- "temp_min" %in% names(df) && "temp_max" %in% names(df)
if (has_daily_minmax) {
# Use existing daily min/max columns directly (DWD daily data: TNK, TXK)
df %>%
mutate(date = as.Date(datetime)) %>%
group_by(date) %>%
summarise(
Tmax = if (all(is.na(temp_max))) NA else max(temp_max, na.rm = TRUE),
Tmin = if (all(is.na(temp_min))) NA else min(temp_min, na.rm = TRUE),
.groups = "drop"
)
} else {
# Calculate from hourly temp data (for hourly datasets)
df %>%
mutate(date = as.Date(datetime)) %>%
group_by(date) %>%
summarise(
Tmax = if (all(is.na(temp))) NA else max(temp, na.rm = TRUE),
Tmin = if (all(is.na(temp))) NA else min(temp, na.rm = TRUE),
.groups = "drop"
)
}
}
#' Create Weather Trends Multi-Panel Plot
#'
#' @param df Data frame with parsed GHCNh data
#' @return A plotly subplot object
#' @export
create_weather_trends_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for the selected period"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
# Enhancement: Calculate Dew Point if not present
if (!"dew_point" %in% names(df) && "rh" %in% names(df)) {
df$dew_point <- calculate_dew_point(df$temp, df$rh)
# Clean dew point too
if (!is.null(df$dew_point)) {
df$dew_point[is.na(df$dew_point) | df$dew_point < -90 | df$dew_point > 60] <- NA_real_
}
}
# Ensure temporal continuity for gaps
df <- ensure_temporal_continuity(df, resolution = resolution)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
date_range_vals <- c(min(df$datetime), max(df$datetime))
# Panel 1: Temperature and Dew Point
has_temp <- "temp" %in% names(df) && any(!is.na(df$temp))
has_dew <- "dew_point" %in% names(df) && any(!is.na(df$dew_point))
if (has_temp || has_dew) {
p1 <- plotly::plot_ly(type = "scatter", mode = "none")
if (has_temp) {
p1 <- p1 %>% plotly::add_lines(
data = df, # Pass full data with NAs
x = ~datetime,
y = ~temp, name = "Temperature",
line = list(color = "#e53935", width = 2),
hovertemplate = "Temp: %{y:.1f}°C<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
if (has_dew) {
p1 <- p1 %>% plotly::add_lines(
data = df, # Pass full data with NAs
x = ~datetime,
y = ~dew_point, name = "Dew Point",
line = list(color = "#1e88e5", width = 1.5, dash = "dot"),
hovertemplate = "Dew Pt: %{y:.1f}°C<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
p1 <- p1 %>% plotly::layout(yaxis = list(title = "Temp/Dew (°C)"))
} else {
p1 <- plotly::plot_ly(type = "scatter", mode = "none") %>%
plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), name = "N/A", showlegend = FALSE) %>%
plotly::add_annotations(text = "Temperature Not Available", showarrow = FALSE, font = list(color = "#999")) %>%
plotly::layout(xaxis = list(title = "", type = "date"), yaxis = list(visible = FALSE, title = "Temp"))
}
# Panel 2: Wind Speed and Gusts
has_wind <- "wind_speed" %in% names(df) && any(!is.na(df$wind_speed))
has_gust <- "wind_gust_max" %in% names(df) && any(!is.na(df$wind_gust_max))
if (has_wind || has_gust) {
p2 <- plotly::plot_ly(type = "scatter", mode = "none")
if (has_wind) {
chunks <- split_into_chunks(df, "wind_speed")
for (i in seq_along(chunks)) {
chunk <- chunks[[i]]
if (nrow(chunk) < 1) next
p2 <- p2 %>% plotly::add_lines(
data = chunk,
x = ~datetime,
y = ~wind_speed, name = "Wind Speed", fill = "tozeroy",
line = list(color = "#43a047", width = 1),
fillcolor = "rgba(67, 160, 71, 0.5)",
hovertemplate = "Wind: %{y:.1f} m/s<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = (i == 1),
legendgroup = "wind_trend"
)
}
}
if (has_gust) {
p2 <- p2 %>% plotly::add_markers(
data = df %>% dplyr::filter(!is.na(wind_gust_max)),
x = ~datetime,
y = ~wind_gust_max, name = "Gust (Max)",
marker = list(color = "#2e7d32", size = 4),
hovertemplate = "Gust: %{y:.1f} m/s<extra></extra>",
type = "scatter", mode = "markers",
showlegend = TRUE
)
}
p2 <- p2 %>% plotly::layout(yaxis = list(title = "Wind (m/s)"))
} else {
p2 <- plotly::plot_ly(type = "scatter", mode = "none") %>%
plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), name = "N/A", showlegend = FALSE) %>%
plotly::add_annotations(text = "Wind Not Available", showarrow = FALSE, font = list(color = "#999")) %>%
plotly::layout(xaxis = list(title = "", type = "date"), yaxis = list(visible = FALSE, title = "Wind"))
}
# Panel 3: Pressure
has_msl <- "pressure" %in% names(df) && any(!is.na(df$pressure))
has_station_p <- "station_pressure" %in% names(df) && any(!is.na(df$station_pressure))
if (has_msl || has_station_p) {
p3 <- plotly::plot_ly(type = "scatter", mode = "none")
if (has_msl) {
p3 <- p3 %>%
plotly::add_lines(
data = df, # Pass full data
x = ~datetime,
y = ~pressure, name = "Sea Level",
line = list(color = "#7b1fa2", width = 1.5),
hovertemplate = "MSL: %{y:.1f} hPa<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
if (has_station_p) {
p3 <- p3 %>%
plotly::add_lines(
data = df, # Pass full data
x = ~datetime,
y = ~station_pressure, name = "Pressure (Station)",
line = list(color = "#AB47BC", width = 1.5, dash = "dot"),
hovertemplate = "Stn: %{y:.1f} hPa<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
p3 <- p3 %>%
plotly::add_lines(
x = date_range_vals,
y = c(1013.25, 1013.25),
name = "Standard (1013.25)",
line = list(color = "rgba(100, 100, 100, 0.5)", width = 1, dash = "dash"),
showlegend = TRUE,
hoverinfo = "skip",
inherit = FALSE,
type = "scatter", mode = "lines"
) %>%
plotly::layout(yaxis = list(title = "Pressure (hPa)"))
} else {
p3 <- plotly::plot_ly(type = "scatter", mode = "none") %>%
plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), name = "N/A", showlegend = FALSE) %>%
plotly::add_annotations(text = "Pressure Not Available", showarrow = FALSE, font = list(color = "#999")) %>%
plotly::layout(xaxis = list(title = "", type = "date"), yaxis = list(visible = FALSE, title = "Pressure"))
}
# Panel 4: Cloud Cover
has_cloud <- "cloud_cover" %in% names(df) && any(!is.na(df$cloud_cover))
if (has_cloud) {
p4 <- plotly::plot_ly(type = "scatter", mode = "none") %>%
plotly::add_lines(
data = df, # Pass full data
x = ~datetime,
y = ~cloud_cover, name = "Cloud Cover",
line = list(color = "#757575", width = 1.5),
hovertemplate = "Cloud: %{y} /8 octas<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
) %>%
plotly::layout(yaxis = list(title = "Cloud (octas)", range = c(0, 8)))
} else {
p4 <- plotly::plot_ly(type = "scatter", mode = "none") %>%
plotly::add_trace(type = "scatter", mode = "markers", marker = list(opacity = 0), name = "N/A", showlegend = FALSE) %>%
plotly::add_annotations(text = "Cloud Cover Not Available", showarrow = FALSE, font = list(color = "#999")) %>%
plotly::layout(xaxis = list(title = "", type = "date"), yaxis = list(visible = FALSE, title = "Cloud"))
}
# Combine all panels
plotly::subplot(p1, p2, p3, p4, nrows = 4, shareX = TRUE, titleY = TRUE, margin = 0.04) %>%
plotly::layout(
title = list(text = paste("Weather Overview:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date", range = date_range_vals),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.05),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Temperature Plot (Single Panel)
#'
#' Creates a standalone temperature plot for the overview section.
#' For daily data, includes Tmin and Tmax.
#'
#' @param df Data frame with parsed weather data
#' @return A plotly object
#' @export
create_temperature_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for Temperature"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
# Ensure continuity
df <- ensure_temporal_continuity(df, resolution = resolution)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
# Detect if daily data (has temp_min/temp_max) or monthly (has avg variants too)
has_temp <- "temp" %in% names(df) && any(!is.na(df$temp))
has_tmin <- "temp_min" %in% names(df) && any(!is.na(df$temp_min))
has_tmax <- "temp_max" %in% names(df) && any(!is.na(df$temp_max))
has_tmin_avg <- "temp_min_avg" %in% names(df) && any(!is.na(df$temp_min_avg))
has_tmax_avg <- "temp_max_avg" %in% names(df) && any(!is.na(df$temp_max_avg))
if (!has_temp && !has_tmin && !has_tmax) {
return(create_empty_plot("No data available for Temperature"))
}
p <- plotly::plot_ly(type = "scatter", mode = "lines")
# --- Monthly Resolution Tiered Visualisation ---
if (has_tmin_avg && has_tmax_avg) {
# 1. Outer Band (Absolute Extremes) - Lightest
if (has_tmin && has_tmax) {
# Lower bound of outer ribbon (Absolute Min)
p <- p %>% plotly::add_trace(
data = df %>% dplyr::filter(!is.na(temp_min)),
x = ~datetime,
y = ~temp_min,
type = "scatter", mode = "lines",
name = "Abs Min",
line = list(color = "rgba(211, 47, 47, 0.3)", width = 1),
hovertemplate = "Abs Min: %{y:.1f}°C<extra></extra>",
showlegend = FALSE
)
# Upper bound of outer ribbon (Absolute Max) - filled to previous line
p <- p %>% plotly::add_trace(
data = df %>% dplyr::filter(!is.na(temp_max)),
x = ~datetime,
y = ~temp_max,
type = "scatter", mode = "lines",
fill = "tonexty",
name = "Absolute Range",
line = list(color = "rgba(211, 47, 47, 0.3)", width = 1),
fillcolor = "rgba(211, 47, 47, 0.1)",
hovertemplate = "Abs Max: %{y:.1f}°C<extra></extra>",
showlegend = TRUE
)
}
# 2. Inner Band (Average Daily Extremes) - Darker
# Lower bound
p <- p %>% plotly::add_trace(
data = df %>% dplyr::filter(!is.na(temp_min_avg)),
x = ~datetime,
y = ~temp_min_avg,
type = "scatter", mode = "lines",
name = "Avg Min",
line = list(color = "rgba(211, 47, 47, 0.5)", width = 1),
hovertemplate = "Avg Daily Min: %{y:.1f}°C<extra></extra>",
showlegend = FALSE
)
# Upper bound - filled to previous
p <- p %>% plotly::add_trace(
data = df %>% dplyr::filter(!is.na(temp_max_avg)),
x = ~datetime,
y = ~temp_max_avg,
type = "scatter", mode = "lines",
fill = "tonexty",
name = "Avg Daily Range",
line = list(color = "rgba(211, 47, 47, 0.5)", width = 1),
fillcolor = "rgba(211, 47, 47, 0.25)",
hovertemplate = "Avg Daily Max: %{y:.1f}°C<extra></extra>",
showlegend = TRUE
)
} else {
# --- Standard Daily/Hourly Visualisation ---
# If both Tmax and Tmin are available (typical for Daily resolution), render as Ribbon
# If both Tmax and Tmin are available
if (has_tmin && has_tmax) {
# Split data into continuous chunks based on Tmin (assuming Tmax/Tmin availability is correlated)
# This works around Plotly's fill='tonexty' connecting across gaps
# Helper to add trace for a chunk
chunks <- split_into_chunks(df, "temp_min")
for (i in seq_along(chunks)) {
chunk <- chunks[[i]]
if (nrow(chunk) < 1) next
# Only show legend for the first chunk to avoid spam
show_leg <- (i == 1)
# Lower bound (Tmin)
p <- p %>% plotly::add_trace(
data = chunk,
x = ~datetime,
y = ~temp_min,
type = "scatter", mode = "lines",
name = "Daily Min",
line = list(color = "rgba(211, 47, 47, 0.4)", width = 1),
hovertemplate = "Tmin: %{y:.1f}°C<extra></extra>",
showlegend = FALSE,
legendgroup = "daily_range"
)
# Upper bound (Tmax) - filled to previous
p <- p %>% plotly::add_trace(
data = chunk,
x = ~datetime,
y = ~temp_max,
type = "scatter", mode = "lines",
fill = "tonexty",
name = "Daily Range",
line = list(color = "rgba(211, 47, 47, 0.4)", width = 1),
fillcolor = "rgba(211, 47, 47, 0.2)",
hovertemplate = "Tmax: %{y:.1f}°C<extra></extra>",
showlegend = show_leg,
legendgroup = "daily_range"
)
}
} else {
# Fallback for when only one is available (e.g. hourly with missing data)
if (has_tmax) {
p <- p %>% plotly::add_lines(
data = df,
x = ~datetime,
y = ~temp_max, name = "Tmax",
line = list(color = "#d32f2f", width = 2),
hovertemplate = "Tmax: %{y:.1f}°C<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
if (has_tmin) {
p <- p %>% plotly::add_lines(
data = df,
x = ~datetime,
y = ~temp_min, name = "Tmin",
line = list(color = "#1976d2", width = 2),
hovertemplate = "Tmin: %{y:.1f}°C<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
}
}
# Mean temperature (Always on top)
if (has_temp) {
p <- p %>% plotly::add_lines(
data = df,
x = ~datetime,
y = ~temp, name = if (has_tmin || has_tmax) "Tmean" else "Temperature",
line = list(
color = "#b71c1c", # Darker red for contrast
width = 2,
dash = "solid"
),
hovertemplate = paste0(if (has_tmin || has_tmax) "Tmean" else "Temp", ": %{y:.1f}°C<extra></extra>"),
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
p %>%
plotly::layout(
title = list(text = paste("Temperature:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date"),
yaxis = list(title = "Temperature (°C)"),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Humidity Plot (Single Panel)
#'
#' Creates a standalone plot for dew point and relative humidity.
#'
#' @param df Data frame with parsed weather data
#' @return A plotly object
#' @export
create_humidity_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for Humidity"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
# Ensure continuity
df <- ensure_temporal_continuity(df, resolution = resolution)
# Calculate Dew Point if not present
if (!"dew_point" %in% names(df) && "rh" %in% names(df) && "temp" %in% names(df)) {
df$dew_point <- calculate_dew_point(df$temp, df$rh)
if (!is.null(df$dew_point)) {
df$dew_point[is.na(df$dew_point) | df$dew_point < -90 | df$dew_point > 60] <- NA_real_
}
}
df <- ensure_temporal_continuity(df)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
has_dew <- "dew_point" %in% names(df) && any(!is.na(df$dew_point))
has_rh <- "rh" %in% names(df) && any(!is.na(df$rh))
if (!has_dew && !has_rh) {
return(create_empty_plot("No data available for Humidity"))
}
p <- plotly::plot_ly(type = "scatter", mode = "none")
if (has_dew) {
p <- p %>% plotly::add_lines(
data = df,
x = ~datetime,
y = ~dew_point, name = "Dew Point",
line = list(color = "#1e88e5", width = 2),
hovertemplate = "Dew Pt: %{y:.1f}°C<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
if (has_rh) {
p <- p %>% plotly::add_lines(
data = df,
x = ~datetime,
y = ~rh, name = "Relative Humidity",
line = list(color = "#43a047", width = 1.5, dash = "dot"),
hovertemplate = "RH: %{y:.0f}%<extra></extra>",
type = "scatter", mode = "lines",
yaxis = "y2",
connectgaps = FALSE,
showlegend = TRUE
)
}
# Layout with dual y-axis if both are present
layout_args <- list(
title = list(text = paste("Humidity:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date"),
yaxis = list(title = "Dew Point (°C)"),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 60),
modebar = list(orientation = "h")
)
if (has_rh) {
layout_args$yaxis2 <- list(
title = "Relative Humidity (%)",
overlaying = "y",
side = "right",
range = c(0, 100)
)
}
do.call(plotly::layout, c(list(p), layout_args)) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Wind Overview Plot (Single Panel)
#'
#' Creates a standalone wind speed and gusts plot for the overview section.
#'
#' @param df Data frame with parsed weather data
#' @return A plotly object
#' @export
create_wind_overview_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for Wind"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
df <- ensure_temporal_continuity(df, resolution = resolution)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
has_wind <- "wind_speed" %in% names(df) && any(!is.na(df$wind_speed))
has_gust <- "wind_gust_max" %in% names(df) && any(!is.na(df$wind_gust_max))
if (!has_wind && !has_gust) {
return(create_empty_plot("No data available for Wind"))
}
p <- plotly::plot_ly(type = "scatter", mode = "none")
if (has_wind) {
chunks <- split_into_chunks(df, "wind_speed")
for (i in seq_along(chunks)) {
chunk <- chunks[[i]]
if (nrow(chunk) < 1) next
p <- p %>% plotly::add_lines(
data = chunk,
x = ~datetime,
y = ~wind_speed, name = "Wind Speed", fill = "tozeroy",
line = list(color = "#43a047", width = 1),
fillcolor = "rgba(67, 160, 71, 0.5)",
hovertemplate = "Wind: %{y:.1f} m/s<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = (i == 1),
legendgroup = "wind_overview"
)
}
}
if (has_gust) {
p <- p %>% plotly::add_markers(
data = df %>% dplyr::filter(!is.na(wind_gust_max)),
x = ~datetime,
y = ~wind_gust_max, name = "Gust (Max)",
marker = list(color = "#2e7d32", size = 4),
hovertemplate = "Gust: %{y:.1f} m/s<extra></extra>",
type = "scatter", mode = "markers",
showlegend = TRUE
)
}
p %>%
plotly::layout(
title = list(text = paste("Wind:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date"),
yaxis = list(title = "Wind Speed (m/s)"),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Pressure Plot (Single Panel)
#'
#' Creates a standalone pressure plot for the overview section.
#'
#' @param df Data frame with parsed weather data
#' @return A plotly object
#' @export
create_pressure_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for Pressure"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
df <- ensure_temporal_continuity(df, resolution = resolution)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
date_range_vals <- c(min(df$datetime), max(df$datetime))
has_msl <- "pressure" %in% names(df) && any(!is.na(df$pressure))
has_station_p <- "station_pressure" %in% names(df) && any(!is.na(df$station_pressure))
if (!has_msl && !has_station_p) {
return(create_empty_plot("No data available for Pressure"))
}
p <- plotly::plot_ly(type = "scatter", mode = "none")
if (has_msl) {
p <- p %>%
plotly::add_lines(
data = df,
x = ~datetime,
y = ~pressure, name = "Sea Level",
line = list(color = "#7b1fa2", width = 1.5),
hovertemplate = "MSL: %{y:.1f} hPa<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
if (has_station_p) {
p <- p %>%
plotly::add_lines(
data = df,
x = ~datetime,
y = ~station_pressure, name = "Pressure (Station)",
line = list(color = "#AB47BC", width = 1.5, dash = "dot"),
hovertemplate = "Stn: %{y:.1f} hPa<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
)
}
# Add reference line for standard pressure
p <- p %>%
plotly::add_lines(
x = date_range_vals,
y = c(1013.25, 1013.25),
name = "Standard (1013.25)",
line = list(color = "rgba(100, 100, 100, 0.5)", width = 1, dash = "dash"),
showlegend = TRUE,
hoverinfo = "skip",
inherit = FALSE,
type = "scatter", mode = "lines"
)
p %>%
plotly::layout(
title = list(text = paste("Pressure:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date"),
yaxis = list(title = "Pressure (hPa)"),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Cloud Cover Plot (Single Panel)
#'
#' Creates a standalone cloud cover plot for the overview section.
#'
#' @param df Data frame with parsed weather data
#' @return A plotly object
#' @export
create_cloud_cover_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for Cloud Cover"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
df <- ensure_temporal_continuity(df, resolution = resolution)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
has_cloud <- "cloud_cover" %in% names(df) && any(!is.na(df$cloud_cover))
if (!has_cloud) {
return(create_empty_plot("No data available for Cloud Cover"))
}
plotly::plot_ly(type = "scatter", mode = "none") %>%
plotly::add_lines(
data = df,
x = ~datetime,
y = ~cloud_cover, name = "Cloud Cover",
line = list(color = "#757575", width = 1.5),
hovertemplate = "Cloud: %{y} /8 octas<extra></extra>",
type = "scatter", mode = "lines",
connectgaps = FALSE,
showlegend = TRUE
) %>%
plotly::layout(
title = list(text = paste("Cloud Cover:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date"),
yaxis = list(title = "Cloud Cover (octas)", range = c(0, 8)),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Precipitation Plot
#'
#' Creates a stacked subplot for all available precipitation intervals.
#'
#' @param df Data frame with parsed GHCNh data
#' @return A plotly subplot object
#' @export
create_precipitation_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for the selected period"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
df <- ensure_temporal_continuity(df, resolution = resolution)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
# Determine resolution based on time difference between first two rows
# Determine resolution based on time difference between first two rows
resolution_label <- "1h"
if (nrow(df) > 1) {
dt <- as.numeric(difftime(df$datetime[2], df$datetime[1], units = "hours"))
if (!is.na(dt)) {
if (dt >= 600) {
resolution_label <- "Monthly"
} else if (dt >= 23) {
resolution_label <- "Daily"
} else if (dt < 0.9) {
resolution_label <- "10 min"
}
}
}
# Valid precipitation columns (smallest interval to largest)
precip_cols <- c(
"precip" = resolution_label
)
plot_list <- list()
# Iterate through columns and create a plot if data exists
for (col_name in names(precip_cols)) {
if (col_name %in% names(df) && any(!is.na(df[[col_name]]))) {
label <- precip_cols[[col_name]]
# Prepare data
p_data <- df
if (nrow(p_data) > 0) {
# Keep non-zero for bar visibility, or all for timeline?
# User code kept all.
# Check rlang dependency for !!sym. If not available, use dynamic formula or base.
# Simplest: use plot_ly(x=~datetime, y=as.formula(paste0("~", col_name)))
# Initialize empty plot to avoid inheriting 'mode' attributes for bar plots
p <- plotly::plot_ly(data = p_data, x = ~datetime) %>%
plotly::add_bars(
y = as.formula(paste0("~", col_name)),
name = paste("Precip", label),
marker = list(color = "#0277bd"),
hovertemplate = paste0(label, ": %{y:.1f} mm<extra></extra>"),
showlegend = TRUE
) %>%
plotly::layout(
yaxis = list(title = paste0(label, " (mm)"))
)
plot_list[[length(plot_list) + 1]] <- p
}
}
}
if (length(plot_list) == 0) {
return(create_empty_plot("No precipitation data available"))
}
# Combine plots vertically
plotly::subplot(plot_list, nrows = length(plot_list), shareX = TRUE, titleY = TRUE, margin = 0.04) %>%
plotly::layout(
title = list(text = paste("Precipitation:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date"),
hovermode = "x unified",
hoverlabel = list(),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Wind Rose Plot
#'
#' Creates a polar bar chart showing wind direction and speed distribution.
#'
#' @param df Data frame with parsed GHCNh data
#' @return A plotly polar bar chart
#' @export
create_wind_rose_plot <- function(df) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for the selected period"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
if (!all(c("wind_speed", "wind_dir") %in% names(df))) {
return(create_empty_plot("Wind data not available for this station"))
}
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
wind_df <- df %>%
dplyr::filter(!is.na(wind_speed), !is.na(wind_dir)) %>%
dplyr::mutate(
dir_bin = round(wind_dir / 22.5) * 22.5,
dir_bin = ifelse(dir_bin == 360, 0, dir_bin),
speed_cat = cut(
wind_speed,
breaks = c(-0.1, 2, 5, 10, 20, Inf),
labels = c("0-2", "2-5", "5-10", "10-20", "20+")
)
) %>%
dplyr::group_by(dir_bin, speed_cat) %>%
dplyr::summarise(count = dplyr::n(), .groups = "drop")
total_obs <- sum(wind_df$count)
wind_df <- wind_df %>%
dplyr::mutate(percentage = count / total_obs * 100)
if (nrow(wind_df) == 0) {
return(create_empty_plot("No wind data available for the selected period"))
}
compass <- data.frame(
dir_bin = seq(0, 337.5, by = 22.5),
label = c(
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
)
)
p <- plotly::plot_ly(type = "barpolar")
speed_levels <- levels(wind_df$speed_cat)
colors <- c("#2196F3", "#4CAF50", "#FFC107", "#FF9800", "#F44336")
for (i in seq_along(speed_levels)) {
lvl <- speed_levels[i]
lvl_df <- wind_df %>% dplyr::filter(speed_cat == lvl)
if (nrow(lvl_df) > 0) {
p <- p %>%
plotly::add_trace(
data = lvl_df, type = "barpolar",
r = ~percentage, theta = ~dir_bin,
name = lvl, marker = list(color = colors[i])
)
}
}
p %>%
plotly::layout(
title = list(text = paste("Wind Rose:", date_range_str), font = list(size = 14)),
polar = list(
angularaxis = list(
rotation = 90, direction = "clockwise",
tickmode = "array", tickvals = compass$dir_bin, ticktext = compass$label
),
radialaxis = list(ticksuffix = "%")
),
showlegend = TRUE,
hoverlabel = list(),
legend = list(title = list(text = "Wind Speed (m/s)"), orientation = "v"),
margin = list(t = 80, b = 80, l = 40, r = 40),
modebar = list(orientation = "h"),
annotations = list(
list(
x = 0, y = 1.1, xref = "paper", yref = "paper",
text = "&#9432;", # info symbol
showarrow = FALSE,
font = list(size = 18, color = "#666"),
xanchor = "left",
hovertext = "Wind rose shows the frequency distribution of wind direction and speed",
hoverinfo = "text"
)
)
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Weathergami Plot
#'
#' Creates a heatmap showing the frequency of daily max/min temperature
#' combinations. This visualization helps identify climate patterns.
#'
#' @param df Data frame with parsed GHCNh data
#' @return A plotly heatmap
#' @export
create_weathergami_plot <- function(df) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for the selected period"))
}
# Clean data first
df <- clean_dwd_data(df)
# Check if daily min/max columns exist (required for weathergami)
has_daily_minmax <- "temp_min" %in% names(df) && "temp_max" %in% names(df)
if (!has_daily_minmax) {
return(create_empty_plot("Weathergami requires daily data with Tmin/Tmax.<br>Please select daily resolution."))
}
# Work directly with temp_min and temp_max columns (no aggregation needed)
# Filter to valid data points
daily_temps <- df %>%
filter(!is.na(temp_min), !is.na(temp_max)) %>%
mutate(
date = as.Date(datetime),
tmax_bin = round(temp_max),
tmin_bin = round(temp_min)
)
if (nrow(daily_temps) == 0) {
return(create_empty_plot("No valid temperature min/max data available"))
}
date_range_str <- paste(
format(min(daily_temps$datetime), "%d %b %Y"), "-",
format(max(daily_temps$datetime), "%d %b %Y")
)
# Count occurrences of each max/min combination
freq_table <- daily_temps %>%
dplyr::group_by(tmax_bin, tmin_bin) %>%
dplyr::summarise(count = dplyr::n(), .groups = "drop")
# Create the heatmap using plotly - use add_heatmap for proper trace handling
p <- plotly::plot_ly(type = "heatmap") %>%
plotly::add_heatmap(
data = freq_table,
x = ~tmax_bin,
y = ~tmin_bin,
z = ~count,
colors = c("#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08519c", "#08306b"),
colorbar = list(title = "Days"),
hovertemplate = paste0(
"Tmax: %{x}°C<br>",
"Tmin: %{y}°C<br>",
"Days: %{z}<extra></extra>"
)
)
# Add diagonal
tmin_r <- range(freq_table$tmin_bin)
tmax_r <- range(freq_table$tmax_bin)
diag_range <- seq(min(tmin_r, tmax_r) - 2, max(tmin_r, tmax_r) + 2)
p <- p %>%
plotly::add_lines(
x = diag_range,
y = diag_range,
line = list(color = "rgba(150, 150, 150, 0.5)", width = 1, dash = "dash"),
name = "Tmax = Tmin",
showlegend = TRUE,
hoverinfo = "skip",
inherit = FALSE
)
p %>%
plotly::layout(
title = list(
text = paste("Weathergami:", date_range_str),
font = list(size = 14)
),
xaxis = list(
title = "Daily Maximum Temperature (°C)",
zeroline = FALSE
),
yaxis = list(
title = "Daily Minimum Temperature (°C)",
zeroline = FALSE,
scaleanchor = "x",
scaleratio = 1
),
hovermode = "closest",
hoverlabel = list()
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Diurnal Temperature Cycle Plot
#'
#' Creates a plot showing temperature variation by hour of day.
#'
#' @param df Data frame with parsed GHCNh data
#' @return A plotly line chart
#' @export
create_diurnal_plot <- function(df, offset_hours = 0) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for the selected period"))
}
# Basic Diurnal Plot (UTC for now, offset not fully piped in from UI yet)
df_diurnal <- df %>%
dplyr::filter(!is.na(temp)) %>%
dplyr::mutate(
local_time = datetime + (offset_hours * 3600),
date = as.Date(local_time),
hour_val = lubridate::hour(local_time)
)
if (nrow(df_diurnal) == 0) {
return(create_empty_plot("No temperature data available for diurnal plot"))
}
median_cycle <- df_diurnal %>%
dplyr::group_by(hour_val) %>%
dplyr::summarise(median_temp = median(temp, na.rm = TRUE), .groups = "drop")
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
# Sample background lines
unique_dates <- sort(unique(df_diurnal$date))
sampled_dates <- unique_dates[seq(1, length(unique_dates), length.out = min(length(unique_dates), 100))] # Capp at 100 lines for perf
df_bg <- df_diurnal %>% filter(date %in% sampled_dates)
plotly::plot_ly(type = "scatter", mode = "none", showlegend = FALSE, hoverinfo = "none") %>%
plotly::add_lines(
data = df_bg,
x = ~hour_val,
y = ~temp,
split = ~date,
line = list(color = "rgba(150, 150, 150, 0.2)", width = 0.5),
hoverinfo = "none",
showlegend = FALSE,
type = "scatter", mode = "lines"
) %>%
plotly::add_lines(
data = median_cycle,
x = ~hour_val,
y = ~median_temp,
name = "Median Cycle",
line = list(color = "#d32f2f", width = 4),
hovertemplate = "Hour: %{x}:00<br>Median: %{y:.1f}°C<extra></extra>",
type = "scatter", mode = "lines"
) %>%
plotly::layout(
title = list(text = paste("Diurnal Cycle:", date_range_str), font = list(size = 14)),
xaxis = list(title = "Hour (UTC+Offset)"),
yaxis = list(title = "Temperature (°C)"),
showlegend = TRUE,
hoverlabel = list()
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Solar Radiation Plot (Single Panel)
#'
#' Creates a standalone solar radiation plot for the solar section.
#'
#' @param df Data frame with parsed weather data
#' @return A plotly object
#' @export
create_solar_radiation_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for Solar Radiation"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
df <- ensure_temporal_continuity(df, resolution = resolution)
has_global <- "solar_global" %in% names(df) && any(!is.na(df$solar_global))
if (!has_global) {
return(create_empty_plot("No data available for Solar Radiation"))
}
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
# Detect resolution
resolution_label <- "Hourly"
if (nrow(df) > 1) {
dt <- as.numeric(difftime(df$datetime[2], df$datetime[1], units = "hours"))
if (!is.na(dt)) {
if (dt >= 23) {
resolution_label <- "Daily"
} else if (dt < 0.9) {
resolution_label <- "10 min"
}
}
}
p <- plotly::plot_ly()
chunks <- split_into_chunks(df, "solar_global")
for (i in seq_along(chunks)) {
chunk <- chunks[[i]]
if (nrow(chunk) < 1) next
p <- p %>% plotly::add_trace(
data = chunk,
x = ~datetime,
y = ~solar_global,
name = "Global Radiation",
type = "scatter",
mode = "lines",
fill = "tozeroy",
line = list(color = "rgba(255, 179, 0, 0.9)", width = 1),
fillcolor = "rgba(255, 179, 0, 0.3)",
hovertemplate = "Solar: %{y:,.0f} J/cm²<extra></extra>",
connectgaps = FALSE,
showlegend = (i == 1),
legendgroup = "solar_rad"
)
}
p %>%
plotly::layout(
title = list(text = paste(resolution_label, "Solar Radiation:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date"),
yaxis = list(title = "Radiation (J/cm²)"),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Sunshine Duration Plot (Single Panel)
#'
#' Creates a standalone sunshine duration plot for the solar section.
#'
#' @param df Data frame with parsed weather data
#' @return A plotly object
#' @export
create_sunshine_duration_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for Sunshine Duration"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
df <- ensure_temporal_continuity(df, resolution = resolution)
has_sun <- "sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration))
if (!has_sun) {
return(create_empty_plot("No data available for Sunshine Duration"))
}
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
# Use full dataframe range for x-axis consistency with other plots
date_range_vals <- c(min(df$datetime), max(df$datetime))
# Detect resolution
resolution_label <- "Hourly"
y_range <- c(0, 65)
y_title <- "Sunshine (min)"
if (nrow(df) > 1) {
dt <- as.numeric(difftime(df$datetime[2], df$datetime[1], units = "hours"))
if (!is.na(dt)) {
if (dt >= 600) {
resolution_label <- "Monthly"
y_range <- NULL # Autoscale for monthly
y_title <- "Sunshine (hours)"
} else if (dt >= 23) {
resolution_label <- "Daily"
y_range <- c(0, 24)
y_title <- "Sunshine (hours)"
} else if (dt < 0.9) {
resolution_label <- "10 min"
y_range <- NULL # Autoscale
y_title <- "Sunshine (min)"
}
}
}
plotly::plot_ly(
data = df,
x = ~datetime,
y = ~sunshine_duration,
name = "Sunshine Duration",
type = "bar",
marker = list(color = "rgba(0, 0, 0, 0.3)", line = list(color = "#FFD700", width = 1.5)),
hovertemplate = paste0("Sunshine: %{y:.1f} ", if (resolution_label %in% c("Daily", "Monthly")) "h" else "min", "<extra></extra>")
) %>%
plotly::layout(
title = list(text = paste(resolution_label, "Sunshine Duration:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date", range = date_range_vals),
yaxis = list(title = y_title, range = y_range),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Solar Plot (Enhanced - Daily Aggregation)
#' Create Solar Plot (Hourly)
#'
#' Plots raw hourly solar radiation and sunshine duration.
#' @param df Cleaned data frame
create_solar_plot_hourly <- function(df, resolution = NULL) {
# Cleaning already done by caller
has_global <- "solar_global" %in% names(df) && any(!is.na(df$solar_global))
has_sun <- "sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration))
df <- ensure_temporal_continuity(df, resolution = resolution)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
date_range_vals <- c(min(df$datetime), max(df$datetime))
# Panel 1: Global Radiation (Filled Area)
if (has_global) {
# Using full df to preserve gaps
p1 <- plotly::plot_ly()
chunks <- split_into_chunks(df, "solar_global")
for (i in seq_along(chunks)) {
chunk <- chunks[[i]]
if (nrow(chunk) < 1) next
p1 <- p1 %>% plotly::add_trace(
data = chunk,
x = ~datetime,
y = ~solar_global,
name = "Global Radiation",
type = "scatter",
mode = "lines",
fill = "tozeroy",
line = list(color = "rgba(255, 179, 0, 0.9)", width = 1),
fillcolor = "rgba(255, 179, 0, 0.3)",
hovertemplate = "Solar: %{y:,.0f} J/cm²<extra></extra>",
connectgaps = FALSE,
showlegend = (i == 1),
legendgroup = "solar_hourly"
)
}
p1 <- p1 %>% plotly::layout(yaxis = list(title = "Radiation (J/cm²)"))
} else {
p1 <- create_empty_plot("No Radiation Data") %>%
plotly::layout(yaxis = list(title = "Radiation"))
}
# Panel 2: Sunshine Duration (Bars)
if (has_sun) {
# Using full df
p2 <- plotly::plot_ly(
data = df,
x = ~datetime,
y = ~sunshine_duration,
name = "Sunshine Duration",
type = "bar",
marker = list(color = "rgba(0, 0, 0, 0.3)", line = list(color = "#FFD700", width = 1.5)),
hovertemplate = "Sunshine: %{y:.0f} min<extra></extra>"
) %>%
plotly::layout(yaxis = list(title = "Sunshine (min)", range = c(0, 65)))
} else {
p2 <- create_empty_plot("No Sunshine Data") %>%
plotly::layout(yaxis = list(title = "Sunshine"))
}
# Combine
plotly::subplot(p1, p2, nrows = 2, shareX = TRUE, titleY = TRUE, margin = 0.04) %>%
plotly::layout(
title = list(text = paste("Hourly Solar & Sunshine:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date", range = date_range_vals),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 60, b = 60, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Solar Plot (Daily Aggregation)
#'
#' @param df Cleaned data frame
create_solar_plot_daily <- function(df, resolution = NULL) {
# Cleaning already done by caller
has_global <- "solar_global" %in% names(df) && any(!is.na(df$solar_global))
has_sun <- "sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration))
# --- Daily Aggregation ---
df_daily <- df %>%
dplyr::mutate(date = as.Date(datetime)) %>%
dplyr::group_by(date) %>%
dplyr::summarise(.groups = "drop")
if (has_global) {
solar_agg <- df %>%
dplyr::mutate(date = as.Date(datetime)) %>%
dplyr::group_by(date) %>%
dplyr::summarise(
daily_solar = sum(solar_global, na.rm = TRUE),
.groups = "drop"
)
df_daily <- dplyr::left_join(df_daily, solar_agg, by = "date")
}
if (has_sun) {
sun_agg <- df %>%
dplyr::mutate(date = as.Date(datetime)) %>%
dplyr::group_by(date) %>%
dplyr::summarise(
daily_sun_hours = sum(sunshine_duration, na.rm = TRUE),
.groups = "drop"
)
df_daily <- dplyr::left_join(df_daily, sun_agg, by = "date")
}
# Ensure continuity on daily aggregated data
# (Re-using helper with dummy datetime at midday to serve as date check, or simplier: use same helper since it handles dt >= 23h as daily)
# The helper expects 'datetime' column.
df_daily <- df_daily %>% dplyr::mutate(datetime = as.POSIXct(date)) # Dummy datetime for helper
df_daily <- ensure_temporal_continuity(df_daily, resolution = "daily")
df_daily$date <- as.Date(df_daily$datetime) # Sync back
date_range_str <- paste(
format(min(df_daily$date), "%d %b %Y"), "-",
format(max(df_daily$date), "%d %b %Y")
)
date_range_vals <- c(min(df_daily$date), max(df_daily$date))
# Panel 1: Global Radiation
if (has_global) {
p1 <- plotly::plot_ly()
chunks <- split_into_chunks(df_daily, "daily_solar")
for (i in seq_along(chunks)) {
chunk <- chunks[[i]]
if (nrow(chunk) < 1) next
p1 <- p1 %>% plotly::add_trace(
data = chunk,
x = ~date,
y = ~daily_solar,
name = "Daily Global Radiation",
type = "scatter",
mode = "lines",
fill = "tozeroy",
line = list(color = "rgba(255, 179, 0, 0.9)", width = 1), # Amber
fillcolor = "rgba(255, 179, 0, 0.3)",
hovertemplate = "Daily Rad: %{y:,.0f} J/cm²<extra></extra>",
connectgaps = FALSE,
showlegend = (i == 1),
legendgroup = "daily_solar"
)
}
p1 <- p1 %>% plotly::layout(yaxis = list(title = "Radiation (J/cm²)"))
} else {
p1 <- create_empty_plot("No Radiation Data") %>%
plotly::layout(yaxis = list(title = "Radiation"))
}
# Panel 2: Sunshine Duration
if (has_sun) {
p2 <- plotly::plot_ly(
data = df_daily,
x = ~date,
y = ~daily_sun_hours,
name = "Daily Sunshine",
type = "bar",
marker = list(color = "rgba(0, 0, 0, 0.3)", line = list(color = "#FFD700", width = 1.5)), # Gold/Yellow outline
hovertemplate = "Sunshine: %{y:.1f} h<extra></extra>"
) %>%
plotly::layout(yaxis = list(title = "Sunshine (Hours)", range = if (any(df_daily$daily_sun_hours > 24, na.rm = TRUE)) NULL else c(0, 24)))
} else {
p2 <- create_empty_plot("No Sunshine Data") %>%
plotly::layout(yaxis = list(title = "Sunshine"))
}
# Combine
plotly::subplot(p1, p2, nrows = 2, shareX = TRUE, titleY = TRUE, margin = 0.04) %>%
plotly::layout(
title = list(text = paste("Daily Solar Energy & Sunshine:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date", range = date_range_vals),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 60, b = 60, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Solar Plot (Dynamic)
#'
#' Automatically detects hourly vs daily data and dispatches plot.
#'
#' @param df Data frame with parsed GHCNh data
#' @return A plotly object
#' @export
create_solar_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for the selected period"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
has_global <- "solar_global" %in% names(df) && any(!is.na(df$solar_global))
has_sun <- "sunshine_duration" %in% names(df) && any(!is.na(df$sunshine_duration))
if (!has_global && !has_sun) {
return(create_empty_plot("No solar/sunshine data in selected time range"))
}
# Determine resolution
is_daily <- FALSE
if (!is.null(resolution)) {
if (tolower(resolution) == "daily") is_daily <- TRUE
} else {
# Fallback detection
if (nrow(df) > 1) {
dt <- as.numeric(difftime(df$datetime[2], df$datetime[1], units = "hours"))
if (!is.na(dt) && dt >= 23) {
is_daily <- TRUE
}
}
}
if (is_daily) {
# Already daily data, pass to daily plotter which will just use available cols
# Note: If input is already daily, creating summation again is fine (group size 1)
# OR we can assume input 'df' is daily and just plot it.
# However, our daily plotter assumes it needs to SUM hourly data.
# Let's adjust daily plotter to just use provided values if it IS daily already?
# Actually simplest is: if incoming IS daily, we don't need to summarize 'solar_global' as sum,
# but 'solar_global' in daily CSV is usually sum already.
# Let's check DWD logic:
# Hourly: solar_global sum over hour.
# Daily: solar_global sum over day.
# So aggregation logic works for both: sum(x) where x is already daily sum = x.
create_solar_plot_daily(df, resolution = resolution)
} else {
create_solar_plot_hourly(df, resolution = resolution)
}
}
#' Create Snow Depth and Water Equivalent Plot
#'
#' Creates a combined plot showing snow depth (cm) and snow water equivalent (mm).
#' Primarily useful for daily resolution data.
#'
#' @param df Data frame with parsed DWD data
#' @return A plotly object
#' @export
create_snow_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for the selected period"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
has_depth <- "snow_depth" %in% names(df) && any(!is.na(df$snow_depth))
has_swe <- "snow_water_equiv" %in% names(df) && any(!is.na(df$snow_water_equiv))
has_fresh_sum <- "snow_fresh_sum" %in% names(df) && any(!is.na(df$snow_fresh_sum))
has_depth_sum <- "snow_depth_sum" %in% names(df) && any(!is.na(df$snow_depth_sum))
if (!has_depth && !has_swe && !has_fresh_sum && !has_depth_sum) {
return(create_empty_plot("No Snow data in selected time range"))
}
df <- ensure_temporal_continuity(df, resolution = resolution)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
p <- plotly::plot_ly()
# --- Monthly Resolution (Sum data) ---
if (has_fresh_sum || has_depth_sum) {
# Snow Depth Sum (Bars) - Primary now
if (has_depth_sum) {
p <- p %>% plotly::add_bars(
data = df %>% dplyr::filter(!is.na(snow_depth_sum)),
x = ~datetime,
y = ~snow_depth_sum,
name = "Snow Depth Sum",
marker = list(color = "#1565c0"),
hovertemplate = "Depth Sum: %{y:.1f} cm<extra></extra>"
)
}
# Note: Fresh Snow Sum removed from monthly plot as per user request
layout_args <- list(
title = list(text = paste("Monthly Snow Summary:", date_range_str), font = list(size = 14)),
xaxis = list(title = list(text = ""), type = "date"),
yaxis = list(title = "Snow Depth Sum (cm)", min = 0),
hovermode = "x unified",
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 20)
)
p <- do.call(plotly::layout, c(list(p), layout_args))
return(p %>% plotly::config(displaylogo = FALSE))
}
# --- Daily/Hourly Resolution ---
# Snow Depth (Area)
if (has_depth) {
p <- p %>% plotly::add_trace(
data = df, # Full dataset
x = ~datetime,
y = ~snow_depth,
name = "Snow Depth",
type = "scatter",
mode = "lines",
fill = "tozeroy",
line = list(color = "rgba(144, 202, 249, 1)", width = 1),
fillcolor = "rgba(144, 202, 249, 0.4)",
hovertemplate = "Depth: %{y:.1f} cm<extra></extra>",
connectgaps = FALSE
)
}
# Snow Water Equivalent (Line)
if (has_swe) {
p <- p %>% plotly::add_lines(
data = df, # Full dataset
x = ~datetime,
y = ~snow_water_equiv,
name = "Water Equiv.",
line = list(color = "#1565c0", width = 2),
hovertemplate = "SWE: %{y:.1f} mm<extra></extra>",
connectgaps = FALSE
)
}
p %>%
plotly::layout(
title = list(text = paste("Snow Depth:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date"),
yaxis = list(title = "Snow Depth (cm) / SWE (mm)", min = 0),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}
#' Create Soil Temperature Profile Plot
#'
#' Creates a multi-line plot showing soil temperatures at different depths.
#' Available depths: 2cm, 5cm, 10cm, 20cm, 50cm.
#'
#' @param df Data frame with parsed DWD data
#' @return A plotly object
#' @export
create_soil_temp_plot <- function(df, resolution = NULL) {
if (is.null(df) || nrow(df) == 0) {
return(create_empty_plot("No data available for the selected period"))
}
# Centralized cleaning
df <- clean_dwd_data(df)
df <- ensure_temporal_continuity(df, resolution = resolution)
date_range_str <- paste(
format(min(df$datetime), "%d %b %Y"), "-",
format(max(df$datetime), "%d %b %Y")
)
# Define soil temp columns and their display properties
soil_cols <- list(
soil_temp_2cm = list(name = "2 cm", color = "#d32f2f"),
soil_temp_5cm = list(name = "5 cm", color = "#f57c00"),
soil_temp_10cm = list(name = "10 cm", color = "#fbc02d"),
soil_temp_20cm = list(name = "20 cm", color = "#388e3c"),
soil_temp_50cm = list(name = "50 cm", color = "#1976d2"),
soil_temp_100cm = list(name = "100 cm", color = "#6a1b9a")
)
# Check which columns have data
available_cols <- names(soil_cols)[sapply(names(soil_cols), function(col) {
col %in% names(df) && any(!is.na(df[[col]]))
})]
if (length(available_cols) == 0) {
return(create_empty_plot("No soil temperature data available for this station"))
}
p <- plotly::plot_ly(type = "scatter", mode = "none")
for (col in available_cols) {
col_info <- soil_cols[[col]]
# Use full data to preserve gaps
p <- p %>%
plotly::add_trace(
data = df,
x = ~datetime,
y = as.formula(paste0("~", col)),
name = col_info$name,
type = "scatter",
mode = "lines",
line = list(color = col_info$color, width = 1.5),
hovertemplate = paste0(col_info$name, ": %{y:.1f}°C<extra></extra>"),
connectgaps = FALSE,
showlegend = TRUE
)
}
# Add freezing line reference
date_range_vals <- c(min(df$datetime), max(df$datetime))
p <- p %>%
plotly::add_lines(
x = date_range_vals,
y = c(0, 0),
name = "Freezing",
line = list(color = "rgba(100, 100, 100, 0.5)", width = 1, dash = "dash"),
showlegend = TRUE,
hoverinfo = "skip",
inherit = FALSE,
type = "scatter", mode = "lines"
)
p %>%
plotly::layout(
title = list(text = paste("Soil Temperature Profile:", date_range_str), font = list(size = 14)),
xaxis = list(title = "", type = "date"),
yaxis = list(title = "Soil Temperature (°C)"),
hovermode = "x unified",
hoverlabel = list(),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.1),
margin = list(t = 50, b = 80, l = 60, r = 20),
modebar = list(orientation = "h")
) %>%
plotly::config(displaylogo = FALSE)
}