SmartCane/r_app/experiments/executive_summary/executive_report_utils.R
Timon 6efcc8cfec Fix CI report pipeline: update tmap v4 syntax, add continuous color scales, fix formatting
- Updated all CI maps to use tm_scale_continuous() for proper tmap v4 compatibility
- Added fixed color scale limits (1-8 for CI, -3 to +3 for differences) for consistent field comparison
- Fixed YAML header formatting issues in CI_report_dashboard_planet.Rmd
- Positioned RGB map before CI overview map as requested
- Removed all obsolete use_breaks parameter references
- Enhanced error handling and logging throughout the pipeline
- Added new experimental analysis scripts and improvements to mosaic creation
2025-06-19 20:37:20 +02:00

437 lines
17 KiB
R

# EXECUTIVE REPORT UTILITIES
# This file contains functions for creating advanced visualizations for the executive summary report
#' Create a velocity and acceleration indicator for CI change
#'
#' @param health_data Current farm health data
#' @param ci_current Current CI raster
#' @param ci_prev1 CI raster from 1 week ago
#' @param ci_prev2 CI raster from 2 weeks ago
#' @param ci_prev3 CI raster from 3 weeks ago
#' @param field_boundaries Field boundaries spatial data (sf object)
#' @return A ggplot2 object with velocity and acceleration gauges
#'
create_velocity_acceleration_indicator <- function(health_data, ci_current, ci_prev1, ci_prev2, ci_prev3, field_boundaries) {
tryCatch({
# Calculate farm-wide metrics for multiple weeks
mean_ci_current <- mean(health_data$mean_ci, na.rm = TRUE)
# Calculate previous week metrics
# Extract CI values for previous weeks
field_ci_metrics <- data.frame(field = character(),
week_current = numeric(),
week_minus_1 = numeric(),
week_minus_2 = numeric(),
week_minus_3 = numeric(),
stringsAsFactors = FALSE)
# Process each field
fields <- unique(field_boundaries$field)
for (field_name in fields) {
tryCatch({
# Get field boundary
field_shape <- field_boundaries %>% dplyr::filter(field == field_name)
if (nrow(field_shape) == 0) next
# Extract CI values for all weeks
ci_curr_values <- terra::extract(ci_current, field_shape)
ci_prev1_values <- terra::extract(ci_prev1, field_shape)
ci_prev2_values <- terra::extract(ci_prev2, field_shape)
ci_prev3_values <- terra::extract(ci_prev3, field_shape)
# Calculate mean CI for each week
mean_ci_curr <- mean(ci_curr_values$CI, na.rm = TRUE)
mean_ci_prev1 <- mean(ci_prev1_values$CI, na.rm = TRUE)
mean_ci_prev2 <- mean(ci_prev2_values$CI, na.rm = TRUE)
mean_ci_prev3 <- mean(ci_prev3_values$CI, na.rm = TRUE)
# Add to metrics table
field_ci_metrics <- rbind(field_ci_metrics, data.frame(
field = field_name,
week_current = mean_ci_curr,
week_minus_1 = mean_ci_prev1,
week_minus_2 = mean_ci_prev2,
week_minus_3 = mean_ci_prev3,
stringsAsFactors = FALSE
))
}, error = function(e) {
message(paste("Error processing field", field_name, "for velocity indicator:", e$message))
})
}
# Calculate farm-wide averages
farm_avg <- colMeans(field_ci_metrics[, c("week_current", "week_minus_1", "week_minus_2", "week_minus_3")], na.rm = TRUE)
# Calculate velocity (rate of change) - current week compared to last week
velocity <- farm_avg["week_current"] - farm_avg["week_minus_1"]
# Calculate previous velocity (last week compared to two weeks ago)
prev_velocity <- farm_avg["week_minus_1"] - farm_avg["week_minus_2"]
# Calculate acceleration (change in velocity)
acceleration <- velocity - prev_velocity
# Prepare data for velocity gauge
velocity_data <- data.frame(
label = "Weekly CI Change",
value = velocity
)
# Prepare data for acceleration gauge
acceleration_data <- data.frame(
label = "Change Acceleration",
value = acceleration
)
# Create velocity trend data
trend_data <- data.frame(
week = c(-3, -2, -1, 0),
ci_value = c(farm_avg["week_minus_3"], farm_avg["week_minus_2"],
farm_avg["week_minus_1"], farm_avg["week_current"])
)
# Create layout grid for the visualizations
layout_matrix <- matrix(c(1, 1, 2, 2, 3, 3), nrow = 2, byrow = TRUE)
# Create velocity gauge
velocity_gauge <- ggplot2::ggplot(velocity_data, ggplot2::aes(x = 0, y = 0)) +
ggplot2::geom_arc_bar(ggplot2::aes(
x0 = 0, y0 = 0,
r0 = 0.5, r = 1,
start = -pi/2, end = pi/2,
fill = "background"
), fill = "#f0f0f0") +
ggplot2::geom_arc_bar(ggplot2::aes(
x0 = 0, y0 = 0,
r0 = 0.5, r = 1,
start = -pi/2,
end = -pi/2 + (pi * (0.5 + (velocity / 2))), # Scale to range -1 to +1
fill = "velocity"
), fill = ifelse(velocity >= 0, "#1a9850", "#d73027")) +
ggplot2::geom_text(ggplot2::aes(label = sprintf("%.2f", velocity)),
size = 8, fontface = "bold") +
ggplot2::geom_text(ggplot2::aes(label = "Velocity"), y = -0.3, size = 4) +
ggplot2::coord_fixed() +
ggplot2::theme_void() +
ggplot2::scale_fill_manual(values = c("background" = "#f0f0f0", "velocity" = "steelblue"),
guide = "none") +
ggplot2::annotate("text", x = -0.85, y = 0, label = "Declining",
angle = 90, size = 3.5) +
ggplot2::annotate("text", x = 0.85, y = 0, label = "Improving",
angle = -90, size = 3.5) +
ggplot2::labs(title = "Farm Health Velocity",
subtitle = "CI change per week") +
ggplot2::theme(plot.title = ggplot2::element_text(hjust = 0.5, size = 14, face = "bold"),
plot.subtitle = ggplot2::element_text(hjust = 0.5, size = 12))
# Create acceleration gauge
acceleration_gauge <- ggplot2::ggplot(acceleration_data, ggplot2::aes(x = 0, y = 0)) +
ggplot2::geom_arc_bar(ggplot2::aes(
x0 = 0, y0 = 0,
r0 = 0.5, r = 1,
start = -pi/2, end = pi/2,
fill = "background"
), fill = "#f0f0f0") +
ggplot2::geom_arc_bar(ggplot2::aes(
x0 = 0, y0 = 0,
r0 = 0.5, r = 1,
start = -pi/2,
end = -pi/2 + (pi * (0.5 + (acceleration / 1))), # Scale to range -0.5 to +0.5
fill = "acceleration"
), fill = ifelse(acceleration >= 0, "#1a9850", "#d73027")) +
ggplot2::geom_text(ggplot2::aes(label = sprintf("%.2f", acceleration)),
size = 8, fontface = "bold") +
ggplot2::geom_text(ggplot2::aes(label = "Acceleration"), y = -0.3, size = 4) +
ggplot2::coord_fixed() +
ggplot2::theme_void() +
ggplot2::scale_fill_manual(values = c("background" = "#f0f0f0", "acceleration" = "steelblue"),
guide = "none") +
ggplot2::annotate("text", x = -0.85, y = 0, label = "Slowing",
angle = 90, size = 3.5) +
ggplot2::annotate("text", x = 0.85, y = 0, label = "Accelerating",
angle = -90, size = 3.5) +
ggplot2::labs(title = "Change Acceleration",
subtitle = "Increasing or decreasing trend") +
ggplot2::theme(plot.title = ggplot2::element_text(hjust = 0.5, size = 14, face = "bold"),
plot.subtitle = ggplot2::element_text(hjust = 0.5, size = 12))
# Create trend chart
trend_chart <- ggplot2::ggplot(trend_data, ggplot2::aes(x = week, y = ci_value)) +
ggplot2::geom_line(size = 1.5, color = "steelblue") +
ggplot2::geom_point(size = 3, color = "steelblue") +
ggplot2::geom_hline(yintercept = trend_data$ci_value[1], linetype = "dashed", color = "gray50") +
ggplot2::labs(
title = "4-Week CI Trend",
x = "Weeks from current",
y = "Average CI Value"
) +
ggplot2::theme_minimal() +
ggplot2::scale_x_continuous(breaks = c(-3, -2, -1, 0))
# Create table of top velocity changes
field_ci_metrics$velocity <- field_ci_metrics$week_current - field_ci_metrics$week_minus_1
top_velocity_fields <- field_ci_metrics %>%
dplyr::arrange(desc(abs(velocity))) %>%
dplyr::slice_head(n = 5) %>%
dplyr::select(field, velocity) %>%
dplyr::mutate(direction = ifelse(velocity >= 0, "Improving", "Declining"))
# Combine into multi-panel figure
main_plot <- gridExtra::grid.arrange(
gridExtra::grid.arrange(velocity_gauge, acceleration_gauge, ncol = 2),
trend_chart,
heights = c(1.5, 1),
nrow = 2
)
return(main_plot)
}, error = function(e) {
message(paste("Error in create_velocity_acceleration_indicator:", e$message))
return(ggplot2::ggplot() +
ggplot2::annotate("text", x = 0, y = 0, label = paste("Error creating velocity indicator:", e$message)) +
ggplot2::theme_void())
})
}
#' Generate a field health score based on CI values and trends
#'
#' @param ci_current Current CI raster
#' @param ci_change CI change raster
#' @param field_age_weeks Field age in weeks
#' @return List containing score, status, and component scores
#'
generate_field_health_score <- function(ci_current, ci_change, field_age_weeks) {
# Get mean CI value for the field
mean_ci <- terra::global(ci_current, "mean", na.rm=TRUE)[[1]]
# Get mean CI change
mean_change <- terra::global(ci_change, "mean", na.rm=TRUE)[[1]]
# Get CI uniformity (coefficient of variation)
ci_sd <- terra::global(ci_current, "sd", na.rm=TRUE)[[1]]
ci_uniformity <- ifelse(mean_ci > 0, ci_sd / mean_ci, 1)
# Calculate base score from current CI (scale 0-5)
# Adjusted for crop age - expectations increase with age
expected_ci <- min(5, field_age_weeks / 10) # Simple linear model
ci_score <- max(0, min(5, 5 - 2 * abs(mean_ci - expected_ci)))
# Add points for positive change (scale 0-3)
change_score <- max(0, min(3, 1 + mean_change))
# Add points for uniformity (scale 0-2)
uniformity_score <- max(0, min(2, 2 * (1 - ci_uniformity)))
# Calculate total score (0-10)
total_score <- ci_score + change_score + uniformity_score
# Create status label
status <- dplyr::case_when(
total_score >= 8 ~ "Excellent",
total_score >= 6 ~ "Good",
total_score >= 4 ~ "Fair",
total_score >= 2 ~ "Needs Attention",
TRUE ~ "Critical"
)
# Return results
return(list(
score = round(total_score, 1),
status = status,
components = list(
ci = round(ci_score, 1),
change = round(change_score, 1),
uniformity = round(uniformity_score, 1)
)
))
}
#' Create an irrigation recommendation map
#'
#' @param ci_current Current CI raster
#' @param ci_change CI change raster
#' @param field_shape Field boundary shape
#' @param title Map title
#' @return A tmap object with irrigation recommendations
#'
create_irrigation_map <- function(ci_current, ci_change, field_shape, title = "Irrigation Priority Zones") {
# Create a new raster for irrigation recommendations
irrigation_priority <- ci_current * 0
# Extract values for processing
ci_values <- terra::values(ci_current)
change_values <- terra::values(ci_change)
# Create priority zones:
# 3 = High priority (low CI, negative trend)
# 2 = Medium priority (low CI but stable, or good CI with negative trend)
# 1 = Low priority (watch, good CI with slight decline)
# 0 = No action needed (good CI, stable/positive trend)
priority_values <- rep(NA, length(ci_values))
# High priority: Low CI (< 2) and negative change (< 0)
high_priority <- which(ci_values < 2 & change_values < 0 & !is.na(ci_values) & !is.na(change_values))
priority_values[high_priority] <- 3
# Medium priority: Low CI (< 2) with stable/positive change, or moderate CI (2-4) with significant negative change (< -1)
medium_priority <- which(
(ci_values < 2 & change_values >= 0 & !is.na(ci_values) & !is.na(change_values)) |
(ci_values >= 2 & ci_values < 4 & change_values < -1 & !is.na(ci_values) & !is.na(change_values))
)
priority_values[medium_priority] <- 2
# Low priority (watch): Moderate/good CI (>= 2) with mild negative change (-1 to 0)
low_priority <- which(
ci_values >= 2 & change_values < 0 & change_values >= -1 & !is.na(ci_values) & !is.na(change_values)
)
priority_values[low_priority] <- 1
# No action needed: Good CI (>= 2) with stable/positive change (>= 0)
no_action <- which(ci_values >= 2 & change_values >= 0 & !is.na(ci_values) & !is.na(change_values))
priority_values[no_action] <- 0
# Set values in the irrigation priority raster
terra::values(irrigation_priority) <- priority_values
# Create the map
tm_shape(irrigation_priority) +
tm_raster(
style = "cat",
palette = c("#1a9850", "#91cf60", "#fc8d59", "#d73027"),
labels = c("No Action", "Watch", "Medium Priority", "High Priority"),
title = "Irrigation Need"
) +
tm_shape(field_shape) +
tm_borders(lwd = 2) +
tm_layout(
main.title = title,
legend.outside = FALSE,
legend.position = c("left", "bottom")
)
}
#' Simple mock function to get weather data for a field
#' In a real implementation, this would fetch data from a weather API
#'
#' @param start_date Start date for weather data
#' @param end_date End date for weather data
#' @param lat Latitude of the field center
#' @param lon Longitude of the field center
#' @return A data frame of weather data
#'
get_weather_data <- function(start_date, end_date, lat = -16.1, lon = 34.7) {
# This is a mock implementation - in production, you'd replace with actual API call
# to a service like OpenWeatherMap, NOAA, or other weather data provider
# Create date sequence
dates <- seq.Date(from = as.Date(start_date), to = as.Date(end_date), by = "day")
n_days <- length(dates)
# Generate some random but realistic weather data with seasonal patterns
# More rain in summer, less in winter (Southern hemisphere)
month_nums <- as.numeric(format(dates, "%m"))
# Simplified seasonal patterns - adjust for your local climate
is_rainy_season <- month_nums %in% c(11, 12, 1, 2, 3, 4)
# Generate rainfall - more in rainy season, occasional heavy rainfall
rainfall <- numeric(n_days)
rainfall[is_rainy_season] <- pmax(0, rnorm(sum(is_rainy_season), mean = 4, sd = 8))
rainfall[!is_rainy_season] <- pmax(0, rnorm(sum(!is_rainy_season), mean = 0.5, sd = 2))
# Add some rare heavy rainfall events
heavy_rain_days <- sample(which(is_rainy_season), size = max(1, round(sum(is_rainy_season) * 0.1)))
rainfall[heavy_rain_days] <- rainfall[heavy_rain_days] + runif(length(heavy_rain_days), 20, 50)
# Generate temperatures - seasonal variation
temp_mean <- 18 + 8 * sin((month_nums - 1) * pi/6) # Peak in January (month 1)
temp_max <- temp_mean + rnorm(n_days, mean = 5, sd = 1)
temp_min <- temp_mean - rnorm(n_days, mean = 5, sd = 1)
# Create weather data frame
weather_data <- data.frame(
date = dates,
rainfall_mm = round(rainfall, 1),
temp_max_c = round(temp_max, 1),
temp_min_c = round(temp_min, 1),
temp_mean_c = round((temp_max + temp_min) / 2, 1)
)
return(weather_data)
}
#' Creates a weather summary visualization integrated with CI data
#'
#' @param pivotName Name of the pivot field
#' @param ci_data CI quadrant data
#' @param days_to_show Number of days of weather to show
#' @return ggplot object
#'
create_weather_ci_plot <- function(pivotName, ci_data = CI_quadrant, days_to_show = 30) {
# Get field data
field_data <- ci_data %>%
dplyr::filter(field == pivotName) %>%
dplyr::arrange(Date) %>%
dplyr::filter(!is.na(value))
if (nrow(field_data) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "No data available") +
theme_void())
}
# Get the latest date and 30 days before
latest_date <- max(field_data$Date, na.rm = TRUE)
start_date <- latest_date - days_to_show
# Filter for recent data only
recent_field_data <- field_data %>%
dplyr::filter(Date >= start_date)
# Get center point coordinates for the field (would be calculated from geometry in production)
# This is mocked for simplicity
lat <- -16.1 # Mock latitude
lon <- 34.7 # Mock longitude
# Get weather data
weather_data <- get_weather_data(start_date, latest_date, lat, lon)
# Aggregate CI data to daily mean across subfields if needed
daily_ci <- recent_field_data %>%
dplyr::group_by(Date) %>%
dplyr::summarize(mean_ci = mean(value, na.rm = TRUE))
# Create combined plot with dual y-axis
g <- ggplot() +
# Rainfall as bars
geom_col(data = weather_data, aes(x = date, y = rainfall_mm),
fill = "#1565C0", alpha = 0.7, width = 0.7) +
# CI as a line
geom_line(data = daily_ci, aes(x = Date, y = mean_ci * 10),
color = "#2E7D32", size = 1) +
geom_point(data = daily_ci, aes(x = Date, y = mean_ci * 10),
color = "#2E7D32", size = 2) +
# Temperature range as ribbon
geom_ribbon(data = weather_data,
aes(x = date, ymin = temp_min_c, ymax = temp_max_c),
fill = "#FF9800", alpha = 0.2) +
# Primary y-axis (rainfall)
scale_y_continuous(
name = "Rainfall (mm)",
sec.axis = sec_axis(~./10, name = "Chlorophyll Index & Temperature (°C)")
) +
labs(
title = paste("Field", pivotName, "- Weather and CI Relationship"),
subtitle = paste("Last", days_to_show, "days"),
x = "Date"
) +
theme_minimal() +
theme(
axis.title.y.left = element_text(color = "#1565C0"),
axis.title.y.right = element_text(color = "#2E7D32"),
legend.position = "bottom"
)
return(g)
}