--- params: ref: "word-styles-reference-var1.docx" output_file: CI_report.docx report_date: "2024-07-18" data_dir: "chemba" mail_day: "Wednesday" borders: TRUE output: # html_document: # toc: yes # df_print: paged word_document: reference_docx: !expr file.path("word-styles-reference-var1.docx") toc: no editor_options: chunk_output_type: console --- ```{r setup_parameters, include=FALSE} # Set up basic report parameters from input values report_date <- params$report_date mail_day <- params$mail_day borders <- params$borders # Environment setup notes (commented out) # # Activeer de renv omgeving # renv::activate() # renv::deactivate() # # Optioneel: Herstel de omgeving als dat nodig is # # Je kunt dit commentaar geven als je het normaal niet wilt uitvoeren # renv::restore() ``` ```{r load_libraries, message=FALSE, warning=FALSE, include=FALSE} # Configure knitr options knitr::opts_chunk$set(warning = FALSE, message = FALSE) # Load all packages at once with suppressPackageStartupMessages suppressPackageStartupMessages({ library(here) library(sf) library(terra) library(exactextractr) library(tidyverse) library(tmap) library(lubridate) library(zoo) library(rsample) library(caret) library(randomForest) library(CAST) }) # Load custom utility functions tryCatch({ source("report_utils.R") }, error = function(e) { message(paste("Error loading report_utils.R:", e$message)) # Try alternative path if the first one fails tryCatch({ source(here::here("r_app", "report_utils.R")) }, error = function(e) { stop("Could not load report_utils.R from either location: ", e$message) }) }) ``` ```{r initialize_project_config, message=FALSE, warning=FALSE, include=FALSE} # Set the project directory from parameters project_dir <- params$data_dir # Source project parameters with error handling tryCatch({ source(here::here("r_app", "parameters_project.R")) }, error = function(e) { stop("Error loading parameters_project.R: ", e$message) }) # Log initial configuration safe_log("Starting the R Markdown script") safe_log(paste("mail_day params:", params$mail_day)) safe_log(paste("report_date params:", params$report_date)) safe_log(paste("mail_day variable:", mail_day)) ``` ```{r calculate_dates_and_weeks, message=FALSE, warning=FALSE, include=FALSE} # Set locale for consistent date formatting Sys.setlocale("LC_TIME", "C") # Initialize date variables from parameters today <- as.character(report_date) mail_day_as_character <- as.character(mail_day) # Calculate week days report_date_as_week_day <- weekdays(lubridate::ymd(today)) days_of_week <- c("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday") # Calculate initial week number week <- lubridate::week(today) safe_log(paste("Initial week calculation:", week, "today:", today)) # Calculate previous dates for comparisons today_minus_1 <- as.character(lubridate::ymd(today) - 7) today_minus_2 <- as.character(lubridate::ymd(today) - 14) today_minus_3 <- as.character(lubridate::ymd(today) - 21) # Log the weekday calculations for debugging safe_log(paste("Report date weekday:", report_date_as_week_day)) safe_log(paste("Weekday index:", which(days_of_week == report_date_as_week_day))) safe_log(paste("Mail day:", mail_day_as_character)) safe_log(paste("Mail day index:", which(days_of_week == mail_day_as_character))) # Adjust week calculation based on mail day if (which(days_of_week == report_date_as_week_day) > which(days_of_week == mail_day_as_character)) { safe_log("Adjusting weeks because of mail day") week <- lubridate::week(today) + 1 today_minus_1 <- as.character(lubridate::ymd(today)) today_minus_2 <- as.character(lubridate::ymd(today) - 7) today_minus_3 <- as.character(lubridate::ymd(today) - 14) } # Calculate week numbers for previous weeks week_minus_1 <- week - 1 week_minus_2 <- week - 2 week_minus_3 <- week - 3 # Format current week with leading zeros week <- sprintf("%02d", week) # Get years for each date year <- lubridate::year(today) year_1 <- lubridate::year(today_minus_1) year_2 <- lubridate::year(today_minus_2) year_3 <- lubridate::year(today_minus_3) ``` ```{r data, message=TRUE, warning=TRUE, include=FALSE} # Load CI index data with error handling tryCatch({ CI_quadrant <- readRDS(here::here(cumulative_CI_vals_dir, "All_pivots_Cumulative_CI_quadrant_year_v2.rds")) safe_log("Successfully loaded CI quadrant data") }, error = function(e) { stop("Error loading CI quadrant data: ", e$message) }) # Get file paths for different weeks using the utility function tryCatch({ path_to_week_current = get_week_path(weekly_CI_mosaic, today, 0) path_to_week_minus_1 = get_week_path(weekly_CI_mosaic, today, -1) path_to_week_minus_2 = get_week_path(weekly_CI_mosaic, today, -2) path_to_week_minus_3 = get_week_path(weekly_CI_mosaic, today, -3) # Log the calculated paths safe_log("Required mosaic paths:") safe_log(paste("Path to current week:", path_to_week_current)) safe_log(paste("Path to week minus 1:", path_to_week_minus_1)) safe_log(paste("Path to week minus 2:", path_to_week_minus_2)) safe_log(paste("Path to week minus 3:", path_to_week_minus_3)) # Validate that files exist if (!file.exists(path_to_week_current)) warning("Current week mosaic file does not exist: ", path_to_week_current) if (!file.exists(path_to_week_minus_1)) warning("Week minus 1 mosaic file does not exist: ", path_to_week_minus_1) if (!file.exists(path_to_week_minus_2)) warning("Week minus 2 mosaic file does not exist: ", path_to_week_minus_2) if (!file.exists(path_to_week_minus_3)) warning("Week minus 3 mosaic file does not exist: ", path_to_week_minus_3) # Load raster data with terra functions CI <- terra::rast(path_to_week_current)$CI CI_m1 <- terra::rast(path_to_week_minus_1)$CI CI_m2 <- terra::rast(path_to_week_minus_2)$CI CI_m3 <- terra::rast(path_to_week_minus_3)$CI }, error = function(e) { stop("Error loading raster data: ", e$message) }) ``` ```{r calculate_difference_rasters, message=TRUE, warning=TRUE, include=FALSE} # Calculate difference rasters for comparisons tryCatch({ # Calculate weekly difference last_week_dif_raster_abs <- (CI - CI_m1) safe_log("Calculated weekly difference raster") # Calculate three-week difference three_week_dif_raster_abs <- (CI - CI_m3) safe_log("Calculated three-week difference raster") }, error = function(e) { safe_log(paste("Error calculating difference rasters:", e$message), "ERROR") # Create placeholder rasters if calculations fail if (!exists("last_week_dif_raster_abs")) { last_week_dif_raster_abs <- CI * 0 } if (!exists("three_week_dif_raster_abs")) { three_week_dif_raster_abs <- CI * 0 } }) ``` ```{r load_field_boundaries, message=TRUE, warning=TRUE, include=FALSE} # Load field boundaries from parameters tryCatch({ AllPivots0 <- field_boundaries_sf %>% dplyr::filter(!is.na(field), !is.na(sub_field)) # Filter out NA field names safe_log("Successfully loaded field boundaries") }, error = function(e) { stop("Error loading field boundaries: ", e$message) }) ``` ```{r create_front_page_variables, include=FALSE} # Create variables for the front page farm_name <- stringr::str_to_title(gsub("_", " ", project_dir)) # Format dates for display report_date_formatted <- format(as.Date(report_date), "%B %d, %Y") current_year <- format(Sys.Date(), "%Y") # Get total field count and area if available tryCatch({ total_fields <- length(unique(AllPivots0$field)) total_area_ha <- round(sum(sf::st_area(AllPivots0)) / 10000, 1) # Convert to hectares }, error = function(e) { total_fields <- "N/A" total_area_ha <- "N/A" }) ``` --- title: "" --- ```{=openxml} SUGARCANE CROP MONITORING REPORT ```
**`r farm_name`** **Chlorophyll Index Analysis** Report Date: **`r report_date_formatted`** ---
## Report Summary **Farm Location:** `r farm_name` **Report Period:** Week `r week` of `r current_year` **Data Source:** Planet Labs Satellite Imagery **Analysis Type:** Chlorophyll Index (CI) Monitoring **Field Coverage:** - Total Fields Monitored: `r total_fields` - Total Area: `r total_area_ha` hectares **Report Generated:** `r format(Sys.Date(), "%B %d, %Y")` --- ## About This Report This automated report provides weekly analysis of sugarcane crop health using satellite-derived Chlorophyll Index (CI) measurements. The analysis helps identify: - Field-level crop health variations - Weekly changes in crop vigor - Areas requiring agricultural attention - Growth patterns across different field sections **Key Features:** - High-resolution satellite imagery analysis - Week-over-week change detection - Individual field performance metrics - Actionable insights for crop management
\newpage ```{=openxml} TABLE OF CONTENTS ``` ```{=openxml} Update this field to generate table of contents ``` \newpage # Explanation of the Report This report provides a detailed analysis of your sugarcane fields based on satellite imagery, helping you monitor crop health and development throughout the growing season. The data is processed weekly to give you timely insights for optimal farm management decisions. ## What is the Chlorophyll Index (CI)? The **Chlorophyll Index (CI)** is a vegetation index that measures the relative amount of chlorophyll in plant leaves. Chlorophyll is the green pigment responsible for photosynthesis in plants. Higher CI values indicate: * Greater photosynthetic activity * Healthier plant tissue * Better nitrogen uptake * More vigorous crop growth CI values typically range from 0 (bare soil or severely stressed vegetation) to 7+ (very healthy, dense vegetation). For sugarcane, values between 3-7 generally indicate good crop health, depending on the growth stage. ## What You'll Find in This Report: 1. **Chlorophyll Index Overview Map**: A comprehensive view of all your fields showing current CI values. This helps identify which fields are performing well and which might need attention. 2. **Weekly Difference Map**: Shows changes in CI values over the past week. Positive values (green) indicate improving crop health, while negative values (red) may signal stress or decline. 3. **Field-by-Field Analysis**: Detailed maps for each field showing: * CI values for the current week and two previous weeks * Week-to-week changes in CI values * Three-week change in CI values to track longer-term trends 4. **Growth Trend Graphs**: Time-series visualizations showing how CI values have changed throughout the growing season for each section of your fields. 5. **Yield Prediction**: For mature crops (over 300 days), we provide estimated yield predictions based on historical data and current CI measurements. Use these insights to identify areas that may need irrigation, fertilization, or other interventions, and to track the effectiveness of your management practices over time. \newpage # RGB Satellite Image - Current Week (if available) ```{r render_rgb_map, echo=FALSE, fig.height=7, fig.width=10, message=FALSE, warning=FALSE} # Check if RGB bands are available and create RGB map tryCatch({ # Load the full raster to check available bands full_raster <- terra::rast(path_to_week_current) available_bands <- names(full_raster) # Check if RGB bands are available (look for red, green, blue or similar naming) rgb_bands_available <- any(grepl("red|Red|RED", available_bands, ignore.case = TRUE)) && any(grepl("green|Green|GREEN", available_bands, ignore.case = TRUE)) && any(grepl("blue|Blue|BLUE", available_bands, ignore.case = TRUE)) # Alternative check for numbered bands that might be RGB (e.g., band_1, band_2, band_3) if (!rgb_bands_available && length(available_bands) >= 3) { # Check if we have at least 3 bands that could potentially be RGB potential_rgb_bands <- grep("band_[1-3]|B[1-3]|[1-3]", available_bands, ignore.case = TRUE) rgb_bands_available <- length(potential_rgb_bands) >= 3 } if (rgb_bands_available) { safe_log("RGB bands detected - creating RGB visualization") # Try to extract RGB bands (prioritize named bands first) red_band <- NULL green_band <- NULL blue_band <- NULL # Look for named RGB bands first red_candidates <- grep("red|Red|RED", available_bands, ignore.case = TRUE, value = TRUE) green_candidates <- grep("green|Green|GREEN", available_bands, ignore.case = TRUE, value = TRUE) blue_candidates <- grep("blue|Blue|BLUE", available_bands, ignore.case = TRUE, value = TRUE) if (length(red_candidates) > 0) red_band <- red_candidates[1] if (length(green_candidates) > 0) green_band <- green_candidates[1] if (length(blue_candidates) > 0) blue_band <- blue_candidates[1] # Fallback to numbered bands if named bands not found if (is.null(red_band) || is.null(green_band) || is.null(blue_band)) { if (length(available_bands) >= 3) { # Assume first 3 bands are RGB (common convention) red_band <- available_bands[1] green_band <- available_bands[2] blue_band <- available_bands[3] } } if (!is.null(red_band) && !is.null(green_band) && !is.null(blue_band)) { # Extract RGB bands rgb_raster <- c(full_raster[[red_band]], full_raster[[green_band]], full_raster[[blue_band]]) names(rgb_raster) <- c("red", "green", "blue") # Create RGB map map <- tmap::tm_shape(rgb_raster, unit = "m") + tmap::tm_rgb() + tmap::tm_scalebar(position = tm_pos_out("right", "bottom"), text.color = "black") + tmap::tm_compass(position = tm_pos_out("right", "bottom"), text.color = "black") + tmap::tm_shape(AllPivots0) + tmap::tm_borders(col = "white", lwd = 2) + tmap::tm_text("sub_field", size = 0.6, col = "white") + tmap::tm_layout(main.title = paste0("RGB Satellite Image - Week ", week), main.title.size = 0.8, main.title.color = "black") # Print the map print(map) safe_log("RGB map created successfully") } else { safe_log("Could not identify RGB bands despite detection", "WARNING") cat("RGB bands detected but could not be properly identified. Skipping RGB visualization.\n") } } else { safe_log("No RGB bands available in the current week mosaic") cat("**Note:** RGB satellite imagery is not available for this week. Only spectral index data is available.\n\n") } }, error = function(e) { safe_log(paste("Error creating RGB map:", e$message), "ERROR") cat("**Note:** Could not create RGB visualization for this week.\n\n") }) ``` # Chlorophyll Index (CI) Overview Map - Current Week ```{r render_ci_overview_map, echo=FALSE, fig.height=7, fig.width=10, message=FALSE, warning=FALSE} # Create overview chlorophyll index map tryCatch({ # Base shape map <- tmap::tm_shape(CI, unit = "m") # Add raster layer with continuous spectrum (fixed scale 1-8 for consistent comparison) map <- map + tmap::tm_raster(col.scale = tm_scale_continuous(values = "brewer.rd_yl_gn", limits = c(1, 8)), col.legend = tm_legend(title = "Chlorophyll Index (CI)", orientation = "landscape", position = tm_pos_out("center", "bottom"))) # Complete the map with layout and other elements map <- map + tmap::tm_scalebar(position = tm_pos_out("right", "bottom"), text.color = "black") + tmap::tm_compass(position = tm_pos_out("right", "bottom"), text.color = "black") + tmap::tm_shape(AllPivots0) + tmap::tm_borders(col = "black") + tmap::tm_text("sub_field", size = 0.6, col = "black") # Print the map print(map) }, error = function(e) { safe_log(paste("Error creating CI overview map:", e$message), "ERROR") plot(1, type="n", axes=FALSE, xlab="", ylab="") text(1, 1, "Error creating CI overview map", cex=1.5) }) ``` # Weekly Chlorophyll Index Difference Map ```{r render_ci_difference_map, echo=FALSE, fig.height=7, fig.width=10, message=FALSE, warning=FALSE} # Create chlorophyll index difference map tryCatch({ # Base shape map <- tmap::tm_shape(last_week_dif_raster_abs, unit = "m") # Add raster layer with continuous spectrum (centered at 0 for difference maps, fixed scale) map <- map + tmap::tm_raster(col.scale = tm_scale_continuous(values = "brewer.rd_yl_gn", midpoint = 0, limits = c(-3, 3)), col.legend = tm_legend(title = "Chlorophyll Index (CI) Change", orientation = "landscape", position = tm_pos_out("center", "bottom"))) # Complete the map with layout and other elements map <- map + tmap::tm_scalebar(position = tm_pos_out("right", "bottom"), text.color = "black") + tmap::tm_compass(position = tm_pos_out("right", "bottom"), text.color = "black") + tmap::tm_shape(AllPivots0) + tmap::tm_borders(col = "black") + tmap::tm_text("sub_field", size = 0.6, col = "black") # Print the map print(map) }, error = function(e) { safe_log(paste("Error creating CI difference map:", e$message), "ERROR") plot(1, type="n", axes=FALSE, xlab="", ylab="") text(1, 1, "Error creating CI difference map", cex=1.5) }) ``` \newpage ```{r generate_field_visualizations, eval=TRUE, fig.height=3.8, fig.width=10, message=FALSE,echo=FALSE, warning=FALSE, include=TRUE, results='asis'} # Generate detailed visualizations for each field tryCatch({ # Merge field polygons for processing and filter out NA field names AllPivots_merged <- AllPivots0 %>% dplyr::filter(!is.na(field), !is.na(sub_field)) %>% # Filter out NA fields dplyr::group_by(field) %>% dplyr::summarise(.groups = 'drop') # Generate plots for each field for(i in seq_along(AllPivots_merged$field)) { field_name <- AllPivots_merged$field[i] # Skip if field_name is still NA (double check) if(is.na(field_name)) { next } tryCatch({ # Add page break before each field (except the first one) if(i > 1) { cat("\\newpage\n\n") } # Call ci_plot with explicit parameters (ci_plot will generate its own header) ci_plot( pivotName = field_name, field_boundaries = AllPivots0, current_ci = CI, ci_minus_1 = CI_m1, ci_minus_2 = CI_m2, last_week_diff = last_week_dif_raster_abs, three_week_diff = three_week_dif_raster_abs, harvesting_data = harvesting_data, week = week, week_minus_1 = week_minus_1, week_minus_2 = week_minus_2, week_minus_3 = week_minus_3, borders = borders ) cat("\n\n") # Call cum_ci_plot with explicit parameters cum_ci_plot( pivotName = field_name, ci_quadrant_data = CI_quadrant, plot_type = "value", facet_on = FALSE ) cat("\n\n") }, error = function(e) { safe_log(paste("Error generating plots for field", field_name, ":", e$message), "ERROR") cat("\\newpage\n\n") cat("# Error generating plots for field ", field_name, "\n\n") cat(e$message, "\n\n") }) } }, error = function(e) { safe_log(paste("Error in field visualization section:", e$message), "ERROR") cat("Error generating field plots. See log for details.\n\n") }) ``` ```{r generate_subarea_visualizations, echo=FALSE, fig.height=3.8, fig.width=10, message=FALSE, warning=FALSE, results='asis', eval=FALSE} # Alternative visualization grouped by sub-area (disabled by default) tryCatch({ # Group pivots by sub-area pivots_grouped <- AllPivots0 # Iterate over each subgroup for (subgroup in unique(pivots_grouped$sub_area)) { # Add subgroup heading cat("\n") cat("## Subgroup: ", subgroup, "\n") # Filter data for current subgroup subset_data <- dplyr::filter(pivots_grouped, sub_area == subgroup) # Generate visualizations for each field in the subgroup purrr::walk(subset_data$field, function(field_name) { cat("\n") ci_plot(field_name) cat("\n") cum_ci_plot(field_name) cat("\n") }) # Add page break after each subgroup cat("\\newpage\n") } }, error = function(e) { safe_log(paste("Error in subarea visualization section:", e$message), "ERROR") cat("Error generating subarea plots. See log for details.\n") }) ``` # Yield prediction The below table shows estimates of the biomass if you would harvest them now. ```{r yield_data_training, message=FALSE, warning=FALSE, include=FALSE} # Load and prepare yield prediction data with error handling tryCatch({ # Load CI quadrant data and fill missing values CI_quadrant <- readRDS(here::here(cumulative_CI_vals_dir, "All_pivots_Cumulative_CI_quadrant_year_v2.rds")) %>% dplyr::group_by(model) %>% tidyr::fill(field, sub_field, .direction = "downup") %>% dplyr::ungroup() # Check if tonnage_ha is empty if (all(is.na(harvesting_data$tonnage_ha))) { safe_log("Lacking historic harvest data, please provide for yield prediction calculation", "WARNING") knitr::knit_exit() # Exit the chunk if tonnage_ha is empty } # Rename year column to season for consistency harvesting_data <- harvesting_data %>% dplyr::rename(season = year) # Join CI and yield data CI_and_yield <- dplyr::left_join(CI_quadrant, harvesting_data, by = c("field", "sub_field", "season")) %>% dplyr::group_by(sub_field, season) %>% dplyr::slice(which.max(DOY)) %>% dplyr::select(field, sub_field, tonnage_ha, cumulative_CI, DOY, season, sub_area) %>% dplyr::mutate(CI_per_day = cumulative_CI / DOY) # Define predictors and response variables predictors <- c("cumulative_CI", "DOY", "CI_per_day") response <- "tonnage_ha" # Prepare test and validation datasets CI_and_yield_test <- CI_and_yield %>% as.data.frame() %>% dplyr::filter(!is.na(tonnage_ha)) CI_and_yield_validation <- CI_and_yield_test # Prepare prediction dataset (fields without harvest data) prediction_yields <- CI_and_yield %>% as.data.frame() %>% dplyr::filter(is.na(tonnage_ha)) %>% dplyr::filter(age > 300) # Only predict on fields older than 300 days # Configure model training parameters ctrl <- caret::trainControl( method = "cv", savePredictions = TRUE, allowParallel = TRUE, number = 5, verboseIter = TRUE ) # Train the model with feature selection set.seed(202) # For reproducibility model_ffs_rf <- CAST::ffs( CI_and_yield_test[, predictors], CI_and_yield_test[, response], method = "rf", trControl = ctrl, importance = TRUE, withinSE = TRUE, tuneLength = 5, na.rm = TRUE ) # Function to prepare predictions with consistent naming and formatting prepare_predictions <- function(predictions, newdata) { return(predictions %>% as.data.frame() %>% dplyr::rename(predicted_Tcha = ".") %>% dplyr::mutate( sub_field = newdata$sub_field, field = newdata$field, Age_days = newdata$DOY, total_CI = round(newdata$cumulative_CI, 0), predicted_Tcha = round(predicted_Tcha, 0), season = newdata$season ) %>% dplyr::select(field, sub_field, Age_days, total_CI, predicted_Tcha, season) %>% dplyr::left_join(., newdata, by = c("field", "sub_field", "season")) ) } # Predict yields for the validation dataset pred_ffs_rf <- prepare_predictions(stats::predict(model_ffs_rf, newdata = CI_and_yield_validation), CI_and_yield_validation) # Predict yields for the current season (focus on mature fields over 300 days) pred_rf_current_season <- prepare_predictions(stats::predict(model_ffs_rf, newdata = prediction_yields), prediction_yields) %>% dplyr::filter(Age_days > 1) %>% dplyr::mutate(CI_per_day = round(total_CI / Age_days, 1)) safe_log("Successfully completed yield prediction calculations") }, error = function(e) { safe_log(paste("Error in yield prediction:", e$message), "ERROR") # Create empty dataframes to prevent errors in subsequent chunks pred_ffs_rf <- data.frame() pred_rf_current_season <- data.frame() }) ``` ```{r plotting_yield_data, echo=FALSE, fig.height=5, fig.width=8, message=FALSE, warning=FALSE} # Display yield prediction visualizations with error handling tryCatch({ if (nrow(pred_ffs_rf) > 0) { # Plot model performance (predicted vs actual) ggplot2::ggplot(pred_ffs_rf, ggplot2::aes(y = predicted_Tcha, x = tonnage_ha)) + ggplot2::geom_point(size = 2, alpha = 0.6) + ggplot2::geom_abline(intercept = 0, slope = 1, linetype = "dashed", color = "red") + ggplot2::scale_x_continuous(limits = c(0, 200)) + ggplot2::scale_y_continuous(limits = c(0, 200)) + ggplot2::labs(title = "Model Performance: \nPredicted vs Actual Tonnage/ha", x = "Actual tonnage/ha (Tcha)", y = "Predicted tonnage/ha (Tcha)") + ggplot2::theme_minimal() } if (nrow(pred_rf_current_season) > 0) { # Plot predicted yields by age ggplot2::ggplot(pred_rf_current_season, ggplot2::aes(x = Age_days, y = predicted_Tcha)) + ggplot2::geom_point(size = 2, alpha = 0.6) + ggplot2::labs(title = "Predicted Yields for Fields Over 300 Days \nOld Yet to Be Harvested", x = "Age (days)", y = "Predicted tonnage/ha (Tcha)") + ggplot2::scale_y_continuous(limits = c(0, 200)) + ggplot2::theme_minimal() # Display prediction table knitr::kable(pred_rf_current_season, digits = 0, caption = "Predicted Tonnage/ha for Fields Over 300 Days Old") } else { cat("No fields over 300 days old without harvest data available for yield prediction.") } }, error = function(e) { safe_log(paste("Error in yield prediction visualization:", e$message), "ERROR") cat("Error generating yield prediction visualizations. See log for details.") }) ```