From dfa0aa900d152277b365a58d89bdb4d8bd4a5d76 Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 14 Jan 2026 11:02:45 +0100 Subject: [PATCH] updated website and small things --- .renvignore | 15 + r_app/02_ci_extraction.R | 35 +- r_app/09c_field_analysis_weekly.R | 1057 +++++++++++++++++ r_app/ANGATA_KPI_UPDATES.md | 155 --- r_app/ci_extraction_utils.R | 137 ++- webapps.zip | Bin 39213 -> 0 bytes webapps/geojson_viewer.html | 223 +++- webapps/login.html | 47 +- webapps/netlify.toml | 11 + webapps/netlify/functions/get-mills.js | 52 + webapps/netlify/functions/login.js | 66 + webapps/sugar_mill_locator/app.js | 28 +- .../google-sheets-config.js | 151 +++ webapps/sugar_mill_locator/index.html | 1 + 14 files changed, 1760 insertions(+), 218 deletions(-) create mode 100644 .renvignore create mode 100644 r_app/09c_field_analysis_weekly.R delete mode 100644 r_app/ANGATA_KPI_UPDATES.md delete mode 100644 webapps.zip create mode 100644 webapps/netlify.toml create mode 100644 webapps/netlify/functions/get-mills.js create mode 100644 webapps/netlify/functions/login.js create mode 100644 webapps/sugar_mill_locator/google-sheets-config.js diff --git a/.renvignore b/.renvignore new file mode 100644 index 0000000..d48919b --- /dev/null +++ b/.renvignore @@ -0,0 +1,15 @@ +# Ignore large Python experiment directories during renv dependency discovery +# These slow down startup and contain no R dependencies + +laravel_app/ +data_validation_tool/ +python_app/harvest_detection_experiments/ +python_app/experiments/ +phase2_refinement/ +webapps/ +tools/ +output/ +renv/ +*.py +*.ipynb +.git/ diff --git a/r_app/02_ci_extraction.R b/r_app/02_ci_extraction.R index b430c02..42cea28 100644 --- a/r_app/02_ci_extraction.R +++ b/r_app/02_ci_extraction.R @@ -31,6 +31,7 @@ suppressPackageStartupMessages({ library(readxl) library(here) library(furrr) + library(future) }) # 2. Process command line arguments @@ -118,19 +119,44 @@ main <- function() { stop(e) }) + # 4. Generate date list for processing # --------------------------------- dates <- date_list(end_date, 7) log_message(paste("Processing data for week", dates$week, "of", dates$year)) - # 5. Find and filter raster files by date + # 5. Find and filter raster files by date - with grid size detection # ----------------------------------- log_message("Searching for raster files") - # Check if tiles exist (Script 01 output) - tile_folder <- file.path("laravel_app", "storage", "app", project_dir, "daily_tiles_split") + # Check if tiles exist (Script 01 output) - detect grid size dynamically + tiles_split_base <- file.path("laravel_app", "storage", "app", project_dir, "daily_tiles_split") + + # Detect grid size from daily_tiles_split folder structure + # Expected structure: daily_tiles_split/5x5/ or daily_tiles_split/10x10/ etc. + grid_size <- NA + if (dir.exists(tiles_split_base)) { + subfolders <- list.dirs(tiles_split_base, full.names = FALSE, recursive = FALSE) + # Look for grid size patterns like "5x5", "10x10", "20x20" + grid_patterns <- grep("^\\d+x\\d+$", subfolders, value = TRUE) + if (length(grid_patterns) > 0) { + grid_size <- grid_patterns[1] # Use first grid size found + log_message(paste("Detected grid size:", grid_size)) + } + } + + # Construct tile folder path with grid size + if (!is.na(grid_size)) { + tile_folder <- file.path(tiles_split_base, grid_size) + } else { + tile_folder <- tiles_split_base + } + use_tiles <- dir.exists(tile_folder) + # Make grid_size available globally for other functions + assign("grid_size", grid_size, envir = .GlobalEnv) + tryCatch({ if (use_tiles) { # Use tile-based processing @@ -145,7 +171,8 @@ main <- function() { field_boundaries_sf = field_boundaries_sf, daily_CI_vals_dir = daily_CI_vals_dir, cumulative_CI_vals_dir = cumulative_CI_vals_dir, - merged_final_dir = merged_final + merged_final_dir = merged_final, + grid_size = grid_size ) } else { diff --git a/r_app/09c_field_analysis_weekly.R b/r_app/09c_field_analysis_weekly.R new file mode 100644 index 0000000..159245e --- /dev/null +++ b/r_app/09c_field_analysis_weekly.R @@ -0,0 +1,1057 @@ +# 09c_FIELD_ANALYSIS_WEEKLY.R (ENHANCED - SC-64 NEW COLUMNS) +# ============================================================================ +# Per-field weekly analysis with NEW columns for trend analysis and advanced metrics +# +# ENHANCEMENTS OVER 09b: +# - Four_week_trend: Smoothed CI trend categorization (strong growth, growth, no growth, decline, strong decline) +# - Last_harvest_or_planting_date: Date of most recent harvest/planting from harvesting_data +# - CI_Percentiles: 10th and 90th percentiles (robust to outliers from roads/trees) +# - CV_Trend_Short_Term: Week-over-week CV change (2-week comparison) +# - CV_Trend_Long_Term: Long-term CV trend (8-week comparison) +# - Cloud_pct_clear: Rounded to 5% intervals for client reporting +# +# Key improvement: All threshold values are MANUALLY DEFINED at the top of this script +# and can be easily updated. In future, these may be replaced with model-derived parameters. +# +# Usage: Rscript 09c_field_analysis_weekly.R [end_date] [project_dir] +# - end_date: End date for analysis (YYYY-MM-DD format), default: today +# - project_dir: Project directory name (e.g., "aura", "esa", "angata") +# +# Example: +# Rscript 09c_field_analysis_weekly.R 2026-01-08 angata +# + +# ============================================================================ +# *** CONFIGURATION SECTION - MANUALLY DEFINED THRESHOLDS *** +# *** These values define decision logic and can be easily updated or replaced +# by model-derived parameters in the future *** +# ============================================================================ + +# TEST MODE (for development with limited historical data) +# Set to TRUE to test with fewer historical weeks; set to FALSE for production +TEST_MODE <- TRUE +TEST_MODE_NUM_WEEKS <- 2 # Number of historical weeks to load in test mode + +# FOUR-WEEK TREND THRESHOLDS (for categorizing mean_CI trends) +# These define the boundaries for growth categorization based on weekly change rate +FOUR_WEEK_TREND_STRONG_GROWTH_MIN <- 0.5 # Average weekly increase >= 0.5 = "strong growth" +FOUR_WEEK_TREND_GROWTH_MIN <- 0.1 # Average weekly increase >= 0.1 = "growth" +FOUR_WEEK_TREND_GROWTH_MAX <- 0.5 # Average weekly increase < 0.5 +FOUR_WEEK_TREND_NO_GROWTH_RANGE <- 0.1 # ±0.1 around 0 = "no growth" +FOUR_WEEK_TREND_DECLINE_MAX <- -0.1 # Average weekly change > -0.1 = "no growth" +FOUR_WEEK_TREND_DECLINE_MIN <- -0.5 # Average weekly decrease >= -0.1 = "decline" +FOUR_WEEK_TREND_STRONG_DECLINE_MAX <- -0.5 # Average weekly decrease < -0.5 = "strong decline" + +# CV TREND THRESHOLDS (for categorizing field uniformity trends) +# These determine if CV change is significant enough to report +CV_TREND_THRESHOLD_SIGNIFICANT <- 0.05 # CV change >= 0.05 is considered significant + +# CLOUD COVER ROUNDING INTERVALS (for client-friendly reporting) +# Rounds cloud_pct_clear to 5% intervals to show impact while avoiding false precision +CLOUD_INTERVALS <- c(0, 50, 60, 70, 80, 90, 100) # Used for bucketing: <50%, 50-60%, 60-70%, etc. + +# PERCENTILE CALCULATIONS (for robust CI range estimation) +CI_PERCENTILE_LOW <- 0.10 # 10th percentile +CI_PERCENTILE_HIGH <- 0.90 # 90th percentile + +# HISTORICAL DATA LOOKBACK (for trend calculations) +WEEKS_FOR_FOUR_WEEK_TREND <- 4 # Use 4 weeks of data for trend +WEEKS_FOR_CV_TREND_SHORT <- 2 # Compare CV over 2 weeks +WEEKS_FOR_CV_TREND_LONG <- 8 # Compare CV over 8 weeks + +# ============================================================================ +# 1. Load required libraries +# ============================================================================ + +suppressPackageStartupMessages({ + library(here) + library(sf) + library(terra) + library(dplyr) + library(tidyr) + library(lubridate) + library(readr) + library(readxl) + library(writexl) + library(purrr) + library(furrr) + library(future) + tryCatch({ + library(torch) + }, error = function(e) { + message("Note: torch package not available - harvest model inference will be skipped") + }) +}) + +# ============================================================================ +# PHASE AND STATUS TRIGGER DEFINITIONS +# ============================================================================ + +PHASE_DEFINITIONS <- data.frame( + phase = c("Germination", "Tillering", "Grand Growth", "Maturation"), + age_start = c(0, 4, 17, 39), + age_end = c(6, 16, 39, 200), + stringsAsFactors = FALSE +) + +STATUS_TRIGGERS <- data.frame( + trigger = c( + "germination_started", + "germination_complete", + "stress_detected_whole_field", + "strong_recovery", + "growth_on_track", + "maturation_progressing", + "harvest_ready" + ), + age_min = c(0, 0, NA, NA, 4, 39, 45), + age_max = c(6, 6, NA, NA, 39, 200, 200), + description = c( + "10% of field CI > 2", + "70% of field CI >= 2", + "CI decline > -1.5 + low CV", + "CI increase > +1.5", + "CI increasing consistently", + "High CI, stable/declining", + "Age 45+ weeks (ready to harvest)" + ), + stringsAsFactors = FALSE +) + +# ============================================================================ +# TILE-AWARE HELPER FUNCTIONS +# ============================================================================ + +#' Get tile IDs that a field geometry intersects +#' +#' @param field_geom Single field geometry (sf or terra::vect) +#' @param tile_grid Data frame with tile extents (id, xmin, xmax, ymin, ymax) +#' @return Numeric vector of tile IDs that field intersects +#' +get_tile_ids_for_field <- function(field_geom, tile_grid) { + if (inherits(field_geom, "sf")) { + field_bbox <- sf::st_bbox(field_geom) + field_xmin <- field_bbox["xmin"] + field_xmax <- field_bbox["xmax"] + field_ymin <- field_bbox["ymin"] + field_ymax <- field_bbox["ymax"] + } else if (inherits(field_geom, "SpatVector")) { + field_bbox <- terra::ext(field_geom) + field_xmin <- field_bbox$xmin + field_xmax <- field_bbox$xmax + field_ymin <- field_bbox$ymin + field_ymax <- field_bbox$ymax + } else { + stop("field_geom must be sf or terra::vect object") + } + + intersecting_tiles <- tile_grid$id[ + !(tile_grid$xmax < field_xmin | + tile_grid$xmin > field_xmax | + tile_grid$ymax < field_ymin | + tile_grid$ymin > field_ymax) + ] + + return(as.numeric(intersecting_tiles)) +} + +#' Load CI tiles that a field intersects +#' +#' @param field_geom Single field geometry +#' @param tile_ids Numeric vector of tile IDs to load +#' @param week_num Week number +#' @param year Year +#' @param mosaic_dir Directory with weekly tiles +#' @return Single CI raster (merged if multiple tiles, or single tile) +#' +load_tiles_for_field <- function(field_geom, tile_ids, week_num, year, mosaic_dir) { + if (length(tile_ids) == 0) { + return(NULL) + } + + tiles_list <- list() + for (tile_id in sort(tile_ids)) { + tile_filename <- sprintf("week_%02d_%d_%02d.tif", week_num, year, tile_id) + tile_path <- file.path(mosaic_dir, tile_filename) + + if (file.exists(tile_path)) { + tryCatch({ + tile_rast <- terra::rast(tile_path) + ci_band <- terra::subset(tile_rast, 5) + tiles_list[[length(tiles_list) + 1]] <- ci_band + }, error = function(e) { + message(paste(" Warning: Could not load tile", tile_id, ":", e$message)) + }) + } + } + + if (length(tiles_list) == 0) { + return(NULL) + } + + if (length(tiles_list) == 1) { + return(tiles_list[[1]]) + } else { + tryCatch({ + rsrc <- terra::sprc(tiles_list) + merged <- terra::mosaic(rsrc, fun = "max") + return(merged) + }, error = function(e) { + message(paste(" Warning: Could not merge tiles:", e$message)) + return(tiles_list[[1]]) + }) + } +} + +#' Build tile grid from available weekly tile files +#' +#' @param mosaic_dir Directory with weekly tiles +#' @param week_num Week number to discover tiles +#' @param year Year to discover tiles +#' @return Data frame with columns: id, xmin, xmax, ymin, ymax +#' +build_tile_grid <- function(mosaic_dir, week_num, year) { + tile_pattern <- sprintf("week_%02d_%d_([0-9]{2})\\.tif", week_num, year) + tile_files <- list.files(mosaic_dir, pattern = tile_pattern, full.names = TRUE) + + if (length(tile_files) == 0) { + stop(paste("No tile files found for week", week_num, year, "in", mosaic_dir)) + } + + tile_grid <- data.frame( + id = integer(), + xmin = numeric(), + xmax = numeric(), + ymin = numeric(), + ymax = numeric(), + stringsAsFactors = FALSE + ) + + for (tile_file in tile_files) { + tryCatch({ + matches <- regmatches(basename(tile_file), regexpr("_([0-9]{2})\\.tif$", basename(tile_file))) + if (length(matches) > 0) { + tile_id <- as.integer(sub("_|\\.tif", "", matches[1])) + tile_rast <- terra::rast(tile_file) + tile_ext <- terra::ext(tile_rast) + tile_grid <- rbind(tile_grid, data.frame( + id = tile_id, + xmin = tile_ext$xmin, + xmax = tile_ext$xmax, + ymin = tile_ext$ymin, + ymax = tile_ext$ymax, + stringsAsFactors = FALSE + )) + } + }, error = function(e) { + message(paste(" Warning: Could not process tile", basename(tile_file), ":", e$message)) + }) + } + + if (nrow(tile_grid) == 0) { + stop("Could not extract extents from any tile files") + } + + return(tile_grid) +} + +# ============================================================================ +# HELPER FUNCTIONS FOR NEW COLUMNS (SC-64) +# ============================================================================ + +#' Categorize four-week trend based on average weekly CI change +#' +#' @param ci_values_list List of CI mean values (chronological order, oldest to newest) +#' @return Character: "strong growth", "growth", "no growth", "decline", "strong decline" +#' +categorize_four_week_trend <- function(ci_values_list) { + if (is.null(ci_values_list) || length(ci_values_list) < 2) { + return(NA_character_) + } + + ci_values_list <- ci_values_list[!is.na(ci_values_list)] + if (length(ci_values_list) < 2) { + return(NA_character_) + } + + # Calculate average weekly change + weekly_changes <- diff(ci_values_list) + avg_weekly_change <- mean(weekly_changes, na.rm = TRUE) + + # Categorize based on thresholds + if (avg_weekly_change >= FOUR_WEEK_TREND_STRONG_GROWTH_MIN) { + return("strong growth") + } else if (avg_weekly_change >= FOUR_WEEK_TREND_GROWTH_MIN && + avg_weekly_change < FOUR_WEEK_TREND_GROWTH_MAX) { + return("growth") + } else if (abs(avg_weekly_change) <= FOUR_WEEK_TREND_NO_GROWTH_RANGE) { + return("no growth") + } else if (avg_weekly_change <= FOUR_WEEK_TREND_DECLINE_MIN && + avg_weekly_change > FOUR_WEEK_TREND_STRONG_DECLINE_MAX) { + return("decline") + } else if (avg_weekly_change < FOUR_WEEK_TREND_STRONG_DECLINE_MAX) { + return("strong decline") + } else { + return("no growth") # Default fallback + } +} + +#' Round cloud percentage to 5% intervals for client reporting +#' +#' @param cloud_pct_clear Numeric cloud clear percentage (0-100) +#' @return Character representing the interval bucket +#' +round_cloud_to_intervals <- function(cloud_pct_clear) { + if (is.na(cloud_pct_clear)) { + return(NA_character_) + } + + if (cloud_pct_clear < 50) return("<50%") + if (cloud_pct_clear < 60) return("50-60%") + if (cloud_pct_clear < 70) return("60-70%") + if (cloud_pct_clear < 80) return("70-80%") + if (cloud_pct_clear < 90) return("80-90%") + return(">90%") +} + +#' Extract CI percentiles (10th and 90th) to avoid outlier distortion +#' +#' @param ci_values Numeric vector of CI values +#' @return Character string: "p10-p90" format +#' +get_ci_percentiles <- function(ci_values) { + if (is.null(ci_values) || length(ci_values) == 0) { + return(NA_character_) + } + + ci_values <- ci_values[!is.na(ci_values)] + if (length(ci_values) == 0) { + return(NA_character_) + } + + p10 <- quantile(ci_values, CI_PERCENTILE_LOW, na.rm = TRUE) + p90 <- quantile(ci_values, CI_PERCENTILE_HIGH, na.rm = TRUE) + + return(sprintf("%.1f-%.1f", p10, p90)) +} + +#' Calculate CV trend between two weeks +#' +#' @param cv_current Current week's CV +#' @param cv_previous Previous week's CV +#' @return Numeric: change in CV (positive = increased heterogeneity) +#' +calculate_cv_trend <- function(cv_current, cv_previous) { + if (is.na(cv_current) || is.na(cv_previous)) { + return(NA_real_) + } + return(round(cv_current - cv_previous, 4)) +} + +# ============================================================================ +# HELPER FUNCTIONS (FROM 09b) +# ============================================================================ + +get_phase_by_age <- function(age_weeks) { + if (is.na(age_weeks)) return(NA_character_) + for (i in seq_len(nrow(PHASE_DEFINITIONS))) { + if (age_weeks >= PHASE_DEFINITIONS$age_start[i] && + age_weeks <= PHASE_DEFINITIONS$age_end[i]) { + return(PHASE_DEFINITIONS$phase[i]) + } + } + return("Unknown") +} + +get_status_trigger <- function(ci_values, ci_change, age_weeks) { + if (is.na(age_weeks) || length(ci_values) == 0) return(NA_character_) + + ci_values <- ci_values[!is.na(ci_values)] + if (length(ci_values) == 0) return(NA_character_) + + pct_above_2 <- sum(ci_values > 2) / length(ci_values) * 100 + pct_at_or_above_2 <- sum(ci_values >= 2) / length(ci_values) * 100 + ci_cv <- if (mean(ci_values, na.rm = TRUE) > 0) sd(ci_values) / mean(ci_values, na.rm = TRUE) else 0 + mean_ci <- mean(ci_values, na.rm = TRUE) + + if (age_weeks >= 0 && age_weeks <= 6) { + if (pct_at_or_above_2 >= 70) { + return("germination_complete") + } else if (pct_above_2 > 10) { + return("germination_started") + } + } + + if (age_weeks >= 45) { + return("harvest_ready") + } + + if (age_weeks > 6 && !is.na(ci_change) && ci_change < -1.5 && ci_cv < 0.25) { + return("stress_detected_whole_field") + } + + if (age_weeks > 6 && !is.na(ci_change) && ci_change > 1.5) { + return("strong_recovery") + } + + if (age_weeks >= 4 && age_weeks < 39 && !is.na(ci_change) && ci_change > 0.2) { + return("growth_on_track") + } + + if (age_weeks >= 39 && age_weeks < 45 && mean_ci > 3.5) { + return("maturation_progressing") + } + + return(NA_character_) +} + +#' Load multiple weeks of CSV data for trend calculations +#' +#' @param project_dir Project name +#' @param current_week Current week number +#' @param reports_dir Reports directory +#' @param num_weeks Number of weeks to load (default 4) +#' @return List with data frames for each week, or NULL if not enough data +#' +load_historical_field_data <- function(project_dir, current_week, reports_dir, num_weeks = 4) { + historical_data <- list() + loaded_weeks <- c() + + for (lookback in 0:(num_weeks - 1)) { + target_week <- current_week - lookback + if (target_week < 1) target_week <- target_week + 52 + + csv_filename <- paste0(project_dir, "_field_analysis_week", sprintf("%02d", target_week), ".csv") + csv_path <- file.path(reports_dir, "kpis", "field_analysis", csv_filename) + + if (file.exists(csv_path)) { + tryCatch({ + data <- read_csv(csv_path, show_col_types = FALSE) + historical_data[[lookback + 1]] <- list( + week = target_week, + data = data + ) + loaded_weeks <- c(loaded_weeks, target_week) + }, error = function(e) { + message(paste(" Warning: Could not load week", target_week, ":", e$message)) + }) + } + } + + if (length(historical_data) == 0) { + message(paste("Warning: No historical field data found for trend calculations")) + return(NULL) + } + + message(paste("Loaded", length(historical_data), "weeks of historical data:", + paste(loaded_weeks, collapse = ", "))) + + return(historical_data) +} + +USE_UNIFORM_AGE <- TRUE +UNIFORM_PLANTING_DATE <- as.Date("2025-01-01") + +extract_planting_dates <- function(harvesting_data) { + if (USE_UNIFORM_AGE) { + message(paste("Using uniform planting date for all fields:", UNIFORM_PLANTING_DATE)) + return(data.frame( + field_id = character(), + planting_date = as.Date(character()), + stringsAsFactors = FALSE + )) + } + + if (is.null(harvesting_data) || nrow(harvesting_data) == 0) { + message("Warning: No harvesting data available.") + return(NULL) + } + + tryCatch({ + planting_dates <- harvesting_data %>% + arrange(field, desc(season_start)) %>% + distinct(field, .keep_all = TRUE) %>% + select(field, season_start) %>% + rename(field_id = field, planting_date = season_start) %>% + filter(!is.na(planting_date)) %>% + as.data.frame() + + message(paste("Extracted planting dates for", nrow(planting_dates), "fields")) + return(planting_dates) + }, error = function(e) { + message(paste("Error extracting planting dates:", e$message)) + return(NULL) + }) +} + +# ============================================================================ +# PARALLEL FIELD ANALYSIS FUNCTION +# ============================================================================ + +#' Analyze single field with SC-64 enhancements +#' +#' @param field_idx Index in field_boundaries_sf +#' @param field_boundaries_sf All field boundaries (sf object) +#' @param tile_grid Data frame with tile extents +#' @param week_num Current week number +#' @param year Current year +#' @param mosaic_dir Directory with weekly tiles +#' @param historical_data Historical weekly data for trend calculations +#' @param planting_dates Planting dates lookup +#' @param report_date Report date +#' @param harvest_imminence_data Harvest imminence predictions (optional) +#' +#' @return Single-row data frame with field analysis including new SC-64 columns +#' +analyze_single_field <- function(field_idx, field_boundaries_sf, tile_grid, week_num, year, + mosaic_dir, historical_data = NULL, planting_dates = NULL, + report_date = Sys.Date(), harvest_imminence_data = NULL) { + + tryCatch({ + # Get field info + field_id <- field_boundaries_sf$field[field_idx] + farm_section <- if ("sub_area" %in% names(field_boundaries_sf)) { + field_boundaries_sf$sub_area[field_idx] + } else { + NA_character_ + } + field_name <- field_id + + # Get field geometry and validate + field_sf <- field_boundaries_sf[field_idx, ] + if (sf::st_is_empty(field_sf) || any(is.na(sf::st_geometry(field_sf)))) { + return(data.frame( + Field_id = field_id, + error = "Empty or invalid geometry" + )) + } + + # Calculate field area + field_area_ha <- as.numeric(sf::st_area(field_sf)) / 10000 + field_area_acres <- field_area_ha / 0.404686 + + # Determine which tiles this field intersects + tile_ids <- get_tile_ids_for_field(field_sf, tile_grid) + + # Load current CI tiles for this field + current_ci <- load_tiles_for_field(field_sf, tile_ids, week_num, year, mosaic_dir) + + if (is.null(current_ci)) { + return(data.frame( + Field_id = field_id, + error = "No tile data available" + )) + } + + # Extract CI values for current field + field_vect <- terra::vect(sf::as_Spatial(field_sf)) + terra::crs(field_vect) <- terra::crs(current_ci) + + all_extracted <- terra::extract(current_ci, field_vect)[, 2] + current_ci_vals <- all_extracted[!is.na(all_extracted)] + + # Calculate cloud coverage + num_total <- length(all_extracted) + num_data <- sum(!is.na(all_extracted)) + pct_clear <- if (num_total > 0) round((num_data / num_total) * 100, 1) else 0 + + cloud_cat <- if (num_data == 0) "No image available" + else if (pct_clear >= 99.5) "Clear view" + else "Partial coverage" + cloud_pct <- 100 - pct_clear + cloud_interval <- round_cloud_to_intervals(pct_clear) # NEW: Rounded intervals + + if (length(current_ci_vals) == 0) { + return(data.frame( + Field_id = field_id, + error = "No CI values extracted" + )) + } + + # Calculate current CI statistics + mean_ci_current <- mean(current_ci_vals, na.rm = TRUE) + ci_std <- sd(current_ci_vals, na.rm = TRUE) + cv_current <- ci_std / mean_ci_current + range_min <- min(current_ci_vals, na.rm = TRUE) + range_max <- max(current_ci_vals, na.rm = TRUE) + range_str <- sprintf("%.1f-%.1f", range_min, range_max) + + # NEW: Get CI percentiles (10th-90th) + ci_percentiles_str <- get_ci_percentiles(current_ci_vals) + + # Calculate weekly CI change + weekly_ci_change <- NA + previous_ci_vals <- NULL + + tryCatch({ + previous_ci <- load_tiles_for_field(field_sf, tile_ids, week_num - 1, year, mosaic_dir) + if (!is.null(previous_ci)) { + prev_extracted <- terra::extract(previous_ci, field_vect)[, 2] + previous_ci_vals <- prev_extracted[!is.na(prev_extracted)] + if (length(previous_ci_vals) > 0) { + mean_ci_previous <- mean(previous_ci_vals, na.rm = TRUE) + weekly_ci_change <- mean_ci_current - mean_ci_previous + } + } + }, error = function(e) { + # Silent fail + }) + + if (is.na(weekly_ci_change)) { + weekly_ci_change_str <- sprintf("%.1f ± %.2f", mean_ci_current, ci_std) + } else { + weekly_ci_change_str <- sprintf("%.1f ± %.2f (Δ%.1f)", mean_ci_current, ci_std, weekly_ci_change) + } + + # Calculate age + age_weeks <- NA + if (!is.null(planting_dates) && nrow(planting_dates) > 0) { + field_planting <- planting_dates %>% + filter(field_id == !!field_id) %>% + pull(planting_date) + + if (length(field_planting) > 0) { + age_weeks <- as.numeric(difftime(report_date, field_planting[1], units = "weeks")) + } + } + + if (USE_UNIFORM_AGE) { + age_weeks <- as.numeric(difftime(report_date, UNIFORM_PLANTING_DATE, units = "weeks")) + } + + # Calculate germination progress + pct_ci_above_2 <- sum(current_ci_vals > 2) / length(current_ci_vals) * 100 + pct_ci_ge_2 <- sum(current_ci_vals >= 2) / length(current_ci_vals) * 100 + germination_progress_str <- NA_character_ + if (!is.na(age_weeks) && age_weeks >= 0 && age_weeks <= 6) { + germination_progress_str <- sprintf("%.0f%%", pct_ci_ge_2) + } + + # Assign phase and trigger + phase <- "Unknown" + imminent_prob_val <- NA + if (!is.null(harvest_imminence_data) && nrow(harvest_imminence_data) > 0) { + imminence_row <- harvest_imminence_data %>% + filter(field_id == !!field_id) + if (nrow(imminence_row) > 0) { + imminent_prob_val <- imminence_row$probability[1] + if (imminent_prob_val > 0.5) { + phase <- "Harvest Imminent (Model)" + } + } + } + + if (phase == "Unknown") { + phase <- get_phase_by_age(age_weeks) + } + + status_trigger <- get_status_trigger(current_ci_vals, weekly_ci_change, age_weeks) + + nmr_weeks_in_phase <- 1 + + # NEW: Load historical data to calculate four_week_trend + four_week_trend <- NA_character_ + ci_values_for_trend <- c(mean_ci_current) + + if (!is.null(historical_data) && length(historical_data) > 0) { + # Extract this field's CI mean values from historical weeks + for (hist in historical_data) { + hist_week <- hist$week + hist_data <- hist$data + + field_row <- hist_data %>% + filter(Field_id == !!field_id) + + if (nrow(field_row) > 0 && !is.na(field_row$Mean_CI[1])) { + ci_values_for_trend <- c(field_row$Mean_CI[1], ci_values_for_trend) + } + } + + if (length(ci_values_for_trend) >= 2) { + four_week_trend <- categorize_four_week_trend(ci_values_for_trend) + } + } + + # NEW: Load previous weeks for CV trends + cv_trend_short <- NA_real_ + cv_trend_long <- NA_real_ + + if (!is.null(historical_data) && length(historical_data) > 0) { + # CV from 2 weeks ago (short-term trend) + if (length(historical_data) >= 2) { + cv_2w <- historical_data[[2]]$data %>% + filter(Field_id == !!field_id) %>% + pull(CV) + if (length(cv_2w) > 0 && !is.na(cv_2w[1])) { + cv_trend_short <- calculate_cv_trend(cv_current, cv_2w[1]) + } + } + + # CV from 8 weeks ago (long-term trend) + if (length(historical_data) >= 8) { + cv_8w <- historical_data[[8]]$data %>% + filter(Field_id == !!field_id) %>% + pull(CV) + if (length(cv_8w) > 0 && !is.na(cv_8w[1])) { + cv_trend_long <- calculate_cv_trend(cv_current, cv_8w[1]) + } + } + } + + # NEW: Last harvest/planting date (from harvesting_data if available) + last_harvest_date <- NA_character_ + if (!is.null(harvesting_data) && nrow(harvesting_data) > 0) { + last_harvest_row <- harvesting_data %>% + filter(field == !!field_id) %>% + arrange(desc(season_start)) %>% + slice(1) + + if (nrow(last_harvest_row) > 0 && !is.na(last_harvest_row$season_start[1])) { + last_harvest_date <- as.character(last_harvest_row$season_start[1]) + } + } + + # Compile result with all SC-64 columns + result <- data.frame( + Field_id = field_id, + Farm_Section = farm_section, + Field_name = field_name, + Hectare = round(field_area_ha, 2), + Acreage = round(field_area_acres, 2), + Mean_CI = round(mean_ci_current, 2), + Weekly_ci_change = if (is.na(weekly_ci_change)) NA_real_ else round(weekly_ci_change, 2), + Weekly_ci_change_str = weekly_ci_change_str, + Four_week_trend = four_week_trend, # NEW + Last_harvest_or_planting_date = last_harvest_date, # NEW + Age_week = if (is.na(age_weeks)) NA_integer_ else as.integer(round(age_weeks)), + `Phase (age based)` = phase, + nmr_weeks_in_this_phase = nmr_weeks_in_phase, + Germination_progress = germination_progress_str, + Imminent_prob = imminent_prob_val, + Status_trigger = status_trigger, + CI_range = range_str, + CI_Percentiles = ci_percentiles_str, # NEW + CV = round(cv_current, 4), + CV_Trend_Short_Term = cv_trend_short, # NEW (2-week) + CV_Trend_Long_Term = cv_trend_long, # NEW (8-week) + Cloud_pct_clear = pct_clear, + Cloud_pct_clear_interval = cloud_interval, # NEW: Rounded intervals + Cloud_pct = cloud_pct, + Cloud_category = cloud_cat, + stringsAsFactors = FALSE + ) + + return(result) + + }, error = function(e) { + message(paste("Error analyzing field", field_idx, ":", e$message)) + return(data.frame( + Field_id = NA_character_, + error = e$message + )) + }) +} + +# ============================================================================ +# SUMMARY GENERATION +# ============================================================================ + +generate_field_analysis_summary <- function(field_df) { + message("Generating summary statistics...") + + total_acreage <- sum(field_df$Acreage, na.rm = TRUE) + + germination_acreage <- sum(field_df$Acreage[field_df$`Phase (age based)` == "Germination"], na.rm = TRUE) + tillering_acreage <- sum(field_df$Acreage[field_df$`Phase (age based)` == "Tillering"], na.rm = TRUE) + grand_growth_acreage <- sum(field_df$Acreage[field_df$`Phase (age based)` == "Grand Growth"], na.rm = TRUE) + maturation_acreage <- sum(field_df$Acreage[field_df$`Phase (age based)` == "Maturation"], na.rm = TRUE) + unknown_phase_acreage <- sum(field_df$Acreage[field_df$`Phase (age based)` == "Unknown"], na.rm = TRUE) + + harvest_ready_acreage <- sum(field_df$Acreage[field_df$Status_trigger == "harvest_ready"], na.rm = TRUE) + stress_acreage <- sum(field_df$Acreage[field_df$Status_trigger == "stress_detected_whole_field"], na.rm = TRUE) + recovery_acreage <- sum(field_df$Acreage[field_df$Status_trigger == "strong_recovery"], na.rm = TRUE) + growth_on_track_acreage <- sum(field_df$Acreage[field_df$Status_trigger == "growth_on_track"], na.rm = TRUE) + germination_complete_acreage <- sum(field_df$Acreage[field_df$Status_trigger == "germination_complete"], na.rm = TRUE) + germination_started_acreage <- sum(field_df$Acreage[field_df$Status_trigger == "germination_started"], na.rm = TRUE) + no_trigger_acreage <- sum(field_df$Acreage[is.na(field_df$Status_trigger)], na.rm = TRUE) + + clear_fields <- sum(field_df$Cloud_category == "Clear view", na.rm = TRUE) + partial_fields <- sum(field_df$Cloud_category == "Partial coverage", na.rm = TRUE) + no_image_fields <- sum(field_df$Cloud_category == "No image available", na.rm = TRUE) + total_fields <- nrow(field_df) + + clear_acreage <- sum(field_df$Acreage[field_df$Cloud_category == "Clear view"], na.rm = TRUE) + partial_acreage <- sum(field_df$Acreage[field_df$Cloud_category == "Partial coverage"], na.rm = TRUE) + no_image_acreage <- sum(field_df$Acreage[field_df$Cloud_category == "No image available"], na.rm = TRUE) + + summary_df <- data.frame( + Category = c( + "--- PHASE DISTRIBUTION ---", + "Germination", + "Tillering", + "Grand Growth", + "Maturation", + "Unknown phase", + "--- STATUS TRIGGERS ---", + "Harvest ready", + "Stress detected", + "Strong recovery", + "Growth on track", + "Germination complete", + "Germination started", + "No trigger", + "--- CLOUD COVERAGE (FIELDS) ---", + "Clear view", + "Partial coverage", + "No image available", + "--- CLOUD COVERAGE (ACREAGE) ---", + "Clear view", + "Partial coverage", + "No image available", + "--- TOTAL ---", + "Total Acreage" + ), + Acreage = c( + NA, + round(germination_acreage, 2), + round(tillering_acreage, 2), + round(grand_growth_acreage, 2), + round(maturation_acreage, 2), + round(unknown_phase_acreage, 2), + NA, + round(harvest_ready_acreage, 2), + round(stress_acreage, 2), + round(recovery_acreage, 2), + round(growth_on_track_acreage, 2), + round(germination_complete_acreage, 2), + round(germination_started_acreage, 2), + round(no_trigger_acreage, 2), + NA, + paste0(clear_fields, " fields"), + paste0(partial_fields, " fields"), + paste0(no_image_fields, " fields"), + NA, + round(clear_acreage, 2), + round(partial_acreage, 2), + round(no_image_acreage, 2), + NA, + round(total_acreage, 2) + ), + stringsAsFactors = FALSE + ) + + attr(summary_df, "cloud_fields_clear") <- clear_fields + attr(summary_df, "cloud_fields_partial") <- partial_fields + attr(summary_df, "cloud_fields_no_image") <- no_image_fields + attr(summary_df, "cloud_fields_total") <- total_fields + + return(summary_df) +} + +# ============================================================================ +# EXPORT FUNCTIONS +# ============================================================================ + +export_field_analysis_excel <- function(field_df, summary_df, project_dir, current_week, reports_dir) { + message("Exporting per-field analysis to Excel, CSV, and RDS...") + + output_subdir <- file.path(reports_dir, "kpis", "field_analysis") + if (!dir.exists(output_subdir)) { + dir.create(output_subdir, recursive = TRUE) + } + + excel_filename <- paste0(project_dir, "_field_analysis_week", sprintf("%02d", current_week), "_test.xlsx") + excel_path <- file.path(output_subdir, excel_filename) + excel_path <- normalizePath(excel_path, winslash = "\\", mustWork = FALSE) + + sheets <- list( + "Field Data" = field_df, + "Summary" = summary_df + ) + + write_xlsx(sheets, excel_path) + message(paste("✓ Field analysis Excel exported to:", excel_path)) + + kpi_data <- list( + field_analysis = field_df, + field_analysis_summary = summary_df, + metadata = list( + current_week = current_week, + project = project_dir, + created_at = Sys.time() + ) + ) + + rds_filename <- paste0(project_dir, "_kpi_summary_tables_week", sprintf("%02d", current_week), ".rds") + rds_path <- file.path(reports_dir, "kpis", rds_filename) + + saveRDS(kpi_data, rds_path) + message(paste("✓ Field analysis RDS exported to:", rds_path)) + + csv_filename <- paste0(project_dir, "_field_analysis_week", sprintf("%02d", current_week), ".csv") + csv_path <- file.path(output_subdir, csv_filename) + write_csv(field_df, csv_path) + message(paste("✓ Field analysis CSV exported to:", csv_path)) + + return(list(excel = excel_path, rds = rds_path, csv = csv_path)) +} + +# ============================================================================ +# MAIN +# ============================================================================ + +main <- function() { + args <- commandArgs(trailingOnly = TRUE) + + end_date <- if (length(args) >= 1 && !is.na(args[1])) { + as.Date(args[1]) + } else if (exists("end_date_str", envir = .GlobalEnv)) { + as.Date(get("end_date_str", envir = .GlobalEnv)) + } else { + Sys.Date() + } + + project_dir <- if (length(args) >= 2 && !is.na(args[2])) { + as.character(args[2]) + } else if (exists("project_dir", envir = .GlobalEnv)) { + get("project_dir", envir = .GlobalEnv) + } else { + "angata" + } + + assign("project_dir", project_dir, envir = .GlobalEnv) + + source(here("r_app", "crop_messaging_utils.R")) + source(here("r_app", "parameters_project.R")) + + message("=== FIELD ANALYSIS WEEKLY (SC-64 ENHANCEMENTS) ===") + message(paste("Date:", end_date)) + message(paste("Project:", project_dir)) + message("") + message("CONFIGURATION:") + message(paste(" Four-week trend thresholds: growth >= ", FOUR_WEEK_TREND_GROWTH_MIN, + ", strong growth >= ", FOUR_WEEK_TREND_STRONG_GROWTH_MIN, sep = "")) + message(paste(" CV trend significant threshold:", CV_TREND_THRESHOLD_SIGNIFICANT)) + message(paste(" Cloud intervals:", paste(CLOUD_INTERVALS, collapse = ", "))) + message("") + + current_week <- as.numeric(format(end_date, "%V")) + year <- as.numeric(format(end_date, "%Y")) + previous_week <- current_week - 1 + if (previous_week < 1) previous_week <- 52 + + message(paste("Week:", current_week, "/ Year:", year)) + + message("Building tile grid from available weekly tiles...") + tile_grid <- build_tile_grid(weekly_tile_max, current_week, year) + message(paste(" Found", nrow(tile_grid), "tiles for analysis")) + + tryCatch({ + boundaries_result <- load_field_boundaries(data_dir) + + if (is.list(boundaries_result) && "field_boundaries_sf" %in% names(boundaries_result)) { + field_boundaries_sf <- boundaries_result$field_boundaries_sf + } else { + field_boundaries_sf <- boundaries_result + } + + if (!is.data.frame(field_boundaries_sf) && !inherits(field_boundaries_sf, "sf")) { + stop("field_boundaries_sf is not a valid SF object") + } + + if (nrow(field_boundaries_sf) == 0) { + stop("No fields loaded from boundaries") + } + + message(paste(" Loaded", nrow(field_boundaries_sf), "fields from boundaries")) + }, error = function(e) { + stop("ERROR loading field boundaries: ", e$message, + "\nCheck that pivot.geojson exists in ", data_dir) + }) + + # Load historical data for trend calculations + message("Loading historical field data for trend calculations...") + num_weeks_to_load <- if (TEST_MODE) TEST_MODE_NUM_WEEKS else max(WEEKS_FOR_FOUR_WEEK_TREND, WEEKS_FOR_CV_TREND_LONG) + if (TEST_MODE) { + message(paste(" TEST MODE: Loading only", num_weeks_to_load, "weeks of historical data")) + } + historical_data <- load_historical_field_data( + project_dir, current_week, reports_dir, + num_weeks = num_weeks_to_load + ) + + planting_dates <- extract_planting_dates(harvesting_data) + + message("Setting up parallel processing...") + current_plan <- class(future::plan())[1] + if (current_plan == "sequential") { + num_workers <- parallel::detectCores() - 1 + message(paste(" Using", num_workers, "workers for parallel processing")) + future::plan(future::multisession, workers = num_workers) + } else { + message(paste(" Using existing plan:", current_plan)) + } + + message("Analyzing fields in parallel...") + + field_analysis_list <- furrr::future_map( + seq_len(nrow(field_boundaries_sf)), + ~ analyze_single_field( + field_idx = ., + field_boundaries_sf = field_boundaries_sf, + tile_grid = tile_grid, + week_num = current_week, + year = year, + mosaic_dir = weekly_tile_max, + historical_data = historical_data, + planting_dates = planting_dates, + report_date = end_date, + harvest_imminence_data = NULL + ), + .progress = TRUE, + .options = furrr::furrr_options(seed = TRUE) + ) + + field_analysis_df <- dplyr::bind_rows(field_analysis_list) + + if (nrow(field_analysis_df) == 0) { + stop("No fields analyzed successfully!") + } + + message(paste("✓ Analyzed", nrow(field_analysis_df), "fields")) + + summary_statistics_df <- generate_field_analysis_summary(field_analysis_df) + + export_paths <- export_field_analysis_excel( + field_analysis_df, + summary_statistics_df, + project_dir, + current_week, + reports_dir + ) + + cat("\n=== FIELD ANALYSIS SUMMARY ===\n") + cat("Fields analyzed:", nrow(field_analysis_df), "\n") + cat("Excel export:", export_paths$excel, "\n") + cat("RDS export:", export_paths$rds, "\n") + cat("CSV export:", export_paths$csv, "\n\n") + + cat("--- Per-field results (first 10) ---\n") + available_cols <- c("Field_id", "Acreage", "Age_week", "Mean_CI", + "Four_week_trend", "Status_trigger", "Cloud_category") + available_cols <- available_cols[available_cols %in% names(field_analysis_df)] + if (length(available_cols) > 0) { + print(head(field_analysis_df[, available_cols], 10)) + } else { + print(head(field_analysis_df, 10)) + } + + cat("\n--- Summary statistics ---\n") + print(summary_statistics_df) + + message("\n✓ Field analysis complete!") +} + +if (sys.nframe() == 0) { + main() +} diff --git a/r_app/ANGATA_KPI_UPDATES.md b/r_app/ANGATA_KPI_UPDATES.md deleted file mode 100644 index eda35b1..0000000 --- a/r_app/ANGATA_KPI_UPDATES.md +++ /dev/null @@ -1,155 +0,0 @@ -# Angata KPI Script Updates - 09_calculate_kpis_Angata.R - -## Overview -The script has been restructured to focus on **4 required KPIs** for Angata, with legacy KPIs disabled by default but retained for future use. - -## Changes Made - -### 1. **Script Configuration** -- **File**: `09_calculate_kpis_Angata.R` -- **Toggle Variable**: `ENABLE_LEGACY_KPIS` (default: `FALSE`) - - Set to `TRUE` to run the 6 original KPIs - - Set to `FALSE` for Angata's 4 KPIs only - -### 2. **Angata KPIs (4 Required)** - -#### KPI 1: **Area Change Summary** ✅ REAL DATA -- **File**: Embedded in script as `calculate_area_change_kpi()` -- **Method**: Compares current week CI to previous week CI -- **Classification**: - - **Improving areas**: Mean change > +0.5 CI units - - **Stable areas**: Mean change between -0.5 and +0.5 CI units - - **Declining areas**: Mean change < -0.5 CI units -- **Output**: Hectares, Acres, and % of farm for each category -- **Data Type**: REAL DATA (processed from satellite imagery) - -#### KPI 2: **Germination Acreage** ✅ REAL DATA -- **Function**: `calculate_germination_acreage_kpi()` -- **Germination Phase Detection**: - - **Start germination**: When 10% of field's CI > 2 - - **End germination**: When 70% of field's CI ≥ 2 -- **Output**: - - Count of fields in germination phase - - Count of fields in post-germination phase - - Total acres and % of farm for each phase -- **Data Type**: REAL DATA (CI-based, calculated from satellite imagery) - -#### KPI 3: **Harvested Acreage** ⚠️ DUMMY DATA -- **Function**: `calculate_harvested_acreage_kpi()` -- **Current Status**: Returns zero values with clear "DUMMY DATA - Detection TBD" label -- **TODO**: Implement harvesting detection logic - - Likely indicators: CI drops below 1.5, sudden backscatter change, etc. -- **Output Format**: - - Number of harvested fields - - Total acres - - % of farm - - Clearly marked as DUMMY DATA in output table - -#### KPI 4: **Mature Acreage** ⚠️ DUMMY DATA -- **Function**: `calculate_mature_acreage_kpi()` -- **Current Status**: Returns zero values with clear "DUMMY DATA - Definition TBD" label -- **Concept**: Mature fields have high and stable CI for several weeks -- **TODO**: Implement stability-based maturity detection - - Calculate CI trend over last 3-4 weeks per field - - Stability metric: low CV over period, high CI relative to field max - - Threshold: e.g., field reaches 80%+ of max CI and stable for 3+ weeks -- **Output Format**: - - Number of mature fields - - Total acres - - % of farm - - Clearly marked as DUMMY DATA in output table - -### 3. **Legacy KPIs (Disabled by Default)** - -These original 6 KPIs are **disabled** but code is preserved for future use: -1. Field Uniformity Summary -2. TCH Forecasted -3. Growth Decline Index -4. Weed Presence Score -5. Gap Filling Score - -To enable: Set `ENABLE_LEGACY_KPIS <- TRUE` in the script - -### 4. **Output & Logging** - -#### Console Output (STDOUT) -``` -=== ANGATA KPI CALCULATION SUMMARY === -Report Date: [date] -Current Week: [week] -Previous Week: [week] -Total Fields Analyzed: [count] -Project: [project_name] -Calculation Time: [timestamp] -Legacy KPIs Enabled: FALSE - ---- REQUIRED ANGATA KPIs --- - -1. Area Change Summary (REAL DATA): - [table] - -2. Germination Acreage (CI-based, REAL DATA): - [table] - -3. Harvested Acreage (DUMMY DATA - Detection TBD): - [table with "DUMMY" marker] - -4. Mature Acreage (DUMMY DATA - Definition TBD): - [table with "DUMMY" marker] - -=== ANGATA KPI CALCULATION COMPLETED === -``` - -#### File Output (RDS) -- **Location**: `laravel_app/storage/app/[project]/reports/kpis/` -- **Filename**: `[project]_kpi_summary_tables_week[XX].rds` -- **Contents**: - - `area_change_summary`: Summary table - - `germination_summary`: Summary table - - `harvested_summary`: Summary table (DUMMY) - - `mature_summary`: Summary table (DUMMY) - - Field-level results for each KPI - - Metadata (report_date, weeks, total_fields, etc.) - -### 5. **Data Clarity Markers** - -All tables in output clearly indicate: -- **REAL DATA**: Derived from satellite CI measurements -- **DUMMY DATA - [TBD Item]**: Placeholder values; actual method to be implemented - -This prevents misinterpretation of preliminary results. - -## Usage - -```powershell -# Run Angata KPIs only (default, legacy disabled) -Rscript r_app/09_calculate_kpis_Angata.R 2025-11-27 7 angata - -# With specific date -Rscript r_app/09_calculate_kpis_Angata.R 2025-11-20 7 angata -``` - -## Future Work - -1. **Harvesting Detection**: Implement CI threshold + temporal pattern analysis -2. **Maturity Definition**: Define stability metrics and thresholds based on field CI ranges -3. **Legacy KPIs**: Adapt or retire based on Angata's needs -4. **Integration**: Connect outputs to reporting system (R Markdown, Word reports, etc.) - -## File Structure - -``` -r_app/ -├── 09_calculate_kpis_Angata.R (main script - UPDATED) -├── kpi_utils.R (optional - legacy functions) -├── crop_messaging_utils.R (dependencies) -├── parameters_project.R (project config) -└── growth_model_utils.R (optional) - -Output: -└── laravel_app/storage/app/angata/reports/kpis/ - └── angata_kpi_summary_tables_week[XX].rds -``` - ---- -**Updated**: November 27, 2025 diff --git a/r_app/ci_extraction_utils.R b/r_app/ci_extraction_utils.R index 99a1fee..d75ff6a 100644 --- a/r_app/ci_extraction_utils.R +++ b/r_app/ci_extraction_utils.R @@ -653,21 +653,27 @@ process_ci_values <- function(dates, field_boundaries, merged_final_dir, #' Process CI values from pre-split tiles (Script 01 output) #' #' This function processes CI values from tiles instead of full-extent rasters. -#' Tiles are created by Script 01 and stored in daily_tiles_split/[DATE]/ folders. +#' Tiles are created by Script 01 and stored in daily_tiles_split/[GRID_SIZE]/[DATE]/ folders. #' For each field, it aggregates CI statistics from all tiles that intersect that field. +#' Output follows the same grid structure: merged_final_tif/[GRID_SIZE]/[DATE]/ +#' +#' NOTE: Processes dates SEQUENTIALLY but tiles WITHIN EACH DATE in parallel (furrr) +#' This avoids worker process communication issues while still getting good speedup. #' #' @param dates List of dates from date_list() -#' @param tile_folder Path to the tile folder (daily_tiles_split) +#' @param tile_folder Path to the tile folder (daily_tiles_split/[GRID_SIZE]) #' @param field_boundaries Field boundaries as vector object #' @param field_boundaries_sf Field boundaries as SF object #' @param daily_CI_vals_dir Directory to save daily CI values #' @param cumulative_CI_vals_dir Directory to save cumulative CI values -#' @param merged_final_dir Directory to save processed tiles with CI band +#' @param merged_final_dir Base directory to save processed tiles with CI band +#' @param grid_size Grid size label (e.g., "5x5", "10x10") for output path structure #' @return NULL (used for side effects) #' process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries, field_boundaries_sf, daily_CI_vals_dir, - cumulative_CI_vals_dir, merged_final_dir) { + cumulative_CI_vals_dir, merged_final_dir, + grid_size = NA) { # Define path for combined CI data combined_ci_path <- here::here(cumulative_CI_vals_dir, "combined_CI_data.rds") @@ -691,11 +697,29 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries, if (!file.exists(combined_ci_path)) { safe_log("combined_CI_data.rds does not exist. Creating new file with all available tile data.") - # Process all tile dates + # Process all tile dates SEQUENTIALLY but with parallel tile processing + # Tiles within each date are processed in parallel via extract_ci_from_tiles() all_pivot_stats <- list() - for (date in tile_dates) { - safe_log(paste("Processing tiles for date:", date)) + for (i in seq_along(tile_dates)) { + date <- tile_dates[i] + + # SKIP: Check if this date already has processed output tiles + if (!is.na(grid_size)) { + output_date_folder <- file.path(merged_final_dir, grid_size, date) + } else { + output_date_folder <- file.path(merged_final_dir, date) + } + + if (dir.exists(output_date_folder)) { + existing_tiles <- list.files(output_date_folder, pattern = "\\.tif$") + if (length(existing_tiles) > 0) { + safe_log(paste("[", i, "/", length(tile_dates), "] SKIP:", date, "- already has", length(existing_tiles), "tiles")) + next + } + } + + safe_log(paste("[", i, "/", length(tile_dates), "] Processing tiles for date:", date)) date_tile_dir <- file.path(tile_folder, date) tile_files <- list.files(date_tile_dir, pattern = "\\.tif$", full.names = TRUE) @@ -705,15 +729,17 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries, next } - safe_log(paste(" Found", length(tile_files), "tiles for date", date)) + safe_log(paste(" Found", length(tile_files), "tiles - processing in parallel")) # Process all tiles for this date and aggregate to fields + # Tiles are processed in parallel via furrr::future_map() inside extract_ci_from_tiles() date_stats <- extract_ci_from_tiles( tile_files = tile_files, date = date, field_boundaries_sf = field_boundaries_sf, daily_CI_vals_dir = daily_CI_vals_dir, - merged_final_tif_dir = merged_final_dir + merged_final_tif_dir = merged_final_dir, + grid_size = grid_size ) if (!is.null(date_stats)) { @@ -735,13 +761,37 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries, } } else { - # Process only new dates + # Process only new dates SEQUENTIALLY but with parallel tile processing safe_log("combined_CI_data.rds exists, adding new tile data.") + if (length(dates_to_process) == 0) { + safe_log("No new dates to process", "WARNING") + return(invisible(NULL)) + } + + safe_log(paste("Processing", length(dates_to_process), "new dates...")) + new_pivot_stats_list <- list() - for (date in dates_to_process[1:2]) { - safe_log(paste("Processing tiles for date:", date)) + for (i in seq_along(dates_to_process)) { + date <- dates_to_process[i] + + # SKIP: Check if this date already has processed output tiles + if (!is.na(grid_size)) { + output_date_folder <- file.path(merged_final_dir, grid_size, date) + } else { + output_date_folder <- file.path(merged_final_dir, date) + } + + if (dir.exists(output_date_folder)) { + existing_tiles <- list.files(output_date_folder, pattern = "\\.tif$") + if (length(existing_tiles) > 0) { + safe_log(paste("[", i, "/", length(dates_to_process), "] SKIP:", date, "- already has", length(existing_tiles), "tiles")) + next + } + } + + safe_log(paste("[", i, "/", length(dates_to_process), "] Processing tiles for date:", date)) date_tile_dir <- file.path(tile_folder, date) tile_files <- list.files(date_tile_dir, pattern = "\\.tif$", full.names = TRUE) @@ -751,7 +801,7 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries, next } - safe_log(paste(" Found", length(tile_files), "tiles for date", date)) + safe_log(paste(" Found", length(tile_files), "tiles - processing in parallel")) # Extract CI from tiles for this date date_stats <- extract_ci_from_tiles( @@ -759,7 +809,8 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries, date = date, field_boundaries_sf = field_boundaries_sf, daily_CI_vals_dir = daily_CI_vals_dir, - merged_final_tif_dir = merged_final_dir + merged_final_tif_dir = merged_final_dir, + grid_size = grid_size ) if (!is.null(date_stats)) { @@ -788,17 +839,18 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries, #' 1. Loads tile #' 2. Creates/extracts CI band #' 3. Creates output raster with Red, Green, Blue, NIR, CI bands -#' 4. Saves to merged_final_tif_dir/[DATE]/ mirroring daily_tiles_split structure +#' 4. Saves to merged_final_tif_dir/[GRID_SIZE]/[DATE]/ mirroring daily_tiles_split structure #' 5. Extracts field-level CI statistics #' Returns statistics aggregated to field level. #' #' @param tile_file Path to a single tile TIF file #' @param field_boundaries_sf Field boundaries as SF object #' @param date Character string of the date (YYYY-MM-DD format) -#' @param merged_final_tif_dir Directory to save processed tiles with CI band +#' @param merged_final_tif_dir Base directory to save processed tiles with CI band +#' @param grid_size Grid size label (e.g., "5x5", "10x10") for output path structure #' @return Data frame with field CI statistics for this tile, or NULL if processing failed #' -process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_final_tif_dir) { +process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_final_tif_dir, grid_size = NA) { tryCatch({ tile_filename <- basename(tile_file) safe_log(paste(" [TILE] Loading:", tile_filename)) @@ -845,8 +897,14 @@ process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_fin output_raster <- c(red_band, green_band, blue_band, nir_band, ci_band) names(output_raster) <- c("Red", "Green", "Blue", "NIR", "CI") - # Save processed tile to merged_final_tif_dir/[DATE]/ with same filename - date_dir <- file.path(merged_final_tif_dir, date) + # Save processed tile to merged_final_tif_dir/[GRID_SIZE]/[DATE]/ with same filename + # This mirrors the input structure: daily_tiles_split/[GRID_SIZE]/[DATE]/ + if (!is.na(grid_size)) { + date_dir <- file.path(merged_final_tif_dir, grid_size, date) + } else { + date_dir <- file.path(merged_final_tif_dir, date) + } + if (!dir.exists(date_dir)) { dir.create(date_dir, recursive = TRUE, showWarnings = FALSE) } @@ -883,7 +941,7 @@ process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_fin #' Given a set of tile files for a single date, this function: #' 1. Loads each tile IN PARALLEL using furrr #' 2. Creates/extracts CI band -#' 3. Saves processed tile (Red, Green, Blue, NIR, CI) to merged_final_tif_dir/[DATE]/ +#' 3. Saves processed tile (Red, Green, Blue, NIR, CI) to merged_final_tif_dir/[GRID_SIZE]/[DATE]/ #' 4. Calculates field statistics from CI band #' 5. Aggregates field statistics across tiles #' 6. Saves individual date file (matching legacy workflow) @@ -894,24 +952,43 @@ process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_fin #' @param date Character string of the date (YYYY-MM-DD format) #' @param field_boundaries_sf Field boundaries as SF object #' @param daily_CI_vals_dir Directory to save individual date RDS files -#' @param merged_final_tif_dir Directory to save processed tiles with CI band (mirrors daily_tiles_split structure) +#' @param merged_final_tif_dir Base directory to save processed tiles with CI band +#' @param grid_size Grid size label (e.g., "5x5", "10x10") for output path structure #' @return Data frame with field CI statistics for the date #' -extract_ci_from_tiles <- function(tile_files, date, field_boundaries_sf, daily_CI_vals_dir = NULL, merged_final_tif_dir = NULL) { +extract_ci_from_tiles <- function(tile_files, date, field_boundaries_sf, daily_CI_vals_dir = NULL, merged_final_tif_dir = NULL, grid_size = NA) { if (!inherits(field_boundaries_sf, "sf")) { field_boundaries_sf <- sf::st_as_sf(field_boundaries_sf) } - safe_log(paste(" Processing", length(tile_files), "tiles for date", date, "(parallel processing)")) + safe_log(paste(" Processing", length(tile_files), "tiles for date", date, "(3-tile parallel batch)")) - # Process tiles in parallel using furrr::future_map - # This replaces the sequential for loop, processing 2-4 tiles simultaneously - stats_list <- furrr::future_map( - .x = tile_files, - .f = ~ process_single_tile(.x, field_boundaries_sf, date, merged_final_tif_dir), - .options = furrr::furrr_options(seed = TRUE) - ) + # Windows-compatible parallelization: Process tiles in small batches + # Use future_map with 3 workers - stable and efficient on Windows + + # Set up minimal future plan (3 workers max) + future::plan(future::multisession, workers = 3) + + # Process tiles using furrr with 2 workers + # Use retry logic for worker stability + stats_list <- tryCatch({ + furrr::future_map( + tile_files, + ~ process_single_tile(.x, field_boundaries_sf, date, merged_final_tif_dir, grid_size = grid_size), + .progress = FALSE, + .options = furrr::furrr_options(seed = TRUE) + ) + }, error = function(e) { + safe_log(paste("Parallel processing failed:", e$message, "- falling back to sequential"), "WARNING") + # Fallback to sequential if parallel fails + lapply( + tile_files, + function(tile_file) { + process_single_tile(tile_file, field_boundaries_sf, date, merged_final_tif_dir, grid_size = grid_size) + } + ) + }) # Extract names and filter out NULL results (failed tiles) tile_names <- basename(tile_files) diff --git a/webapps.zip b/webapps.zip deleted file mode 100644 index cf2f49ba8f58575d99b9a5ba52a30f1bd70bff9e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39213 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 PkR!h>$fJA7s9*mAsvxST diff --git a/webapps/geojson_viewer.html b/webapps/geojson_viewer.html index 36511a1..df29392 100644 --- a/webapps/geojson_viewer.html +++ b/webapps/geojson_viewer.html @@ -56,8 +56,9 @@ border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); display: flex; - align-items: center; - gap: 10px; + flex-direction: column; + align-items: flex-start; + gap: 12px; } .map-controls label { @@ -70,6 +71,19 @@ font-weight: 500; } + .map-controls-group { + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid #e0e0e0; + padding-top: 8px; + } + + .map-controls-group:first-child { + border-top: none; + padding-top: 0; + } + .toggle-switch { position: relative; display: inline-block; @@ -764,14 +778,32 @@
- +
+ +
+
+ + +
@@ -807,8 +839,11 @@ let currentLayer = 'osm'; let geojsonLayer = null; + let labelsLayer = null; let currentGeojsonData = null; let featuresList = []; + let showLabels = true; + let labelPosition = 'center'; // 'center' or 'side' // Toggle map layer const mapToggle = document.getElementById('mapToggle'); @@ -830,6 +865,31 @@ } }); + // Toggle labels visibility + const labelsToggle = document.getElementById('labelsToggle'); + labelsToggle.addEventListener('change', () => { + showLabels = labelsToggle.checked; + if (labelsLayer) { + if (showLabels) { + labelsLayer.addTo(map); + } else { + map.removeLayer(labelsLayer); + } + } + }); + + // Toggle label position + const labelPositionToggle = document.getElementById('labelPositionToggle'); + const labelPositionText = document.getElementById('labelPositionText'); + labelPositionToggle.addEventListener('change', () => { + labelPosition = labelPositionToggle.checked ? 'side' : 'center'; + labelPositionText.textContent = labelPositionToggle.checked ? 'Side' : 'Center'; + // Recreate labels with new position + if (currentGeojsonData) { + loadGeojson(currentGeojsonData, ''); + } + }); + // Elements const geojsonInput = document.getElementById('geojsonFile'); const fileNameDisplay = document.getElementById('fileName'); @@ -863,6 +923,64 @@ setTimeout(() => el.classList.remove('active'), 5000); } + // Resolve label overlaps using collision detection algorithm + function resolveLabelOverlaps(labelPositions) { + const labelWidth = 150; // Approximate label width in pixels + const labelHeight = 30; // Approximate label height in pixels + const minDistance = Math.sqrt(labelWidth * labelWidth + labelHeight * labelHeight); + + const adjustedPositions = labelPositions.map(pos => ({ ...pos })); + + // Iteratively adjust positions to avoid overlaps + for (let iteration = 0; iteration < 5; iteration++) { + let hasAdjustment = false; + + for (let i = 0; i < adjustedPositions.length; i++) { + for (let j = i + 1; j < adjustedPositions.length; j++) { + const pos1 = adjustedPositions[i]; + const pos2 = adjustedPositions[j]; + + const p1 = map.project(pos1.latlng); + const p2 = map.project(pos2.latlng); + + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // If labels are too close, push them apart + if (distance < minDistance && distance > 0) { + hasAdjustment = true; + const angle = Math.atan2(dy, dx); + const pushDistance = (minDistance - distance) / 2 + 5; + + // Move labels away from each other + const offset1 = map.unproject([ + p1.x - Math.cos(angle) * pushDistance, + p1.y - Math.sin(angle) * pushDistance + ]); + const offset2 = map.unproject([ + p2.x + Math.cos(angle) * pushDistance, + p2.y + Math.sin(angle) * pushDistance + ]); + + // Only adjust if original feature hasn't moved too far + const maxDrift = 0.003; + if (Math.abs(offset1.lat - pos1.originalLatlng.lat) < maxDrift) { + pos1.latlng = offset1; + } + if (Math.abs(offset2.lat - pos2.originalLatlng.lat) < maxDrift) { + pos2.latlng = offset2; + } + } + } + } + + if (!hasAdjustment) break; + } + + return adjustedPositions; + } + // Calculate area in square meters using Turf.js function getFeatureArea(feature) { try { @@ -957,6 +1075,11 @@ map.removeLayer(geojsonLayer); } + // Clear previous labels + if (labelsLayer) { + map.removeLayer(labelsLayer); + } + currentGeojsonData = geojson; featuresList = []; @@ -1018,6 +1141,86 @@ } }).addTo(map); + // Create labels layer + labelsLayer = L.featureGroup([]); + + // First pass: collect all label positions + const labelPositions = []; + features.forEach((feature, index) => { + const props = feature.properties || {}; + const fieldName = props.field || props.name || props.field_name || props.fieldName || props.id || `Field ${index + 1}`; + + if (feature.geometry && feature.geometry.type !== 'Point') { + // Get centroid for polygon features + const bounds = L.geoJSON(feature).getBounds(); + const center = bounds.getCenter(); + + let labelLatlng = center; + let iconAnchor = [0, 0]; + + // If side position, offset label to the top-left of the bounds + if (labelPosition === 'side') { + const ne = bounds.getNorthEast(); + labelLatlng = L.latLng(ne.lat, ne.lng); + iconAnchor = [-10, -30]; + } + + labelPositions.push({ + fieldName, + latlng: labelLatlng, + originalLatlng: labelLatlng, + iconAnchor, + isPoint: false + }); + } else if (feature.geometry && feature.geometry.type === 'Point') { + // For points, add label above the marker + const latlng = L.GeoJSON.coordsToLatLng(feature.geometry.coordinates); + labelPositions.push({ + fieldName, + latlng: latlng, + originalLatlng: latlng, + iconAnchor: [0, 30], + isPoint: true + }); + } + }); + + // Apply collision detection to resolve overlaps + const adjustedPositions = resolveLabelOverlaps(labelPositions); + + // Second pass: create markers with adjusted positions + adjustedPositions.forEach((pos) => { + const label = L.marker(pos.latlng, { + icon: L.divIcon({ + className: 'field-label', + html: `
${pos.fieldName}
`, + iconSize: [null, null], + iconAnchor: pos.iconAnchor + }), + interactive: false + }); + labelsLayer.addLayer(label); + }); + + if (showLabels) { + labelsLayer.addTo(map); + } + // Fit bounds const bounds = geojsonLayer.getBounds(); map.fitBounds(bounds, { padding: [50, 50] }); diff --git a/webapps/login.html b/webapps/login.html index 4dc5d83..39d7b9b 100644 --- a/webapps/login.html +++ b/webapps/login.html @@ -162,25 +162,48 @@ }); // Handle login form - document.getElementById('loginForm').addEventListener('submit', function(e) { + document.getElementById('loginForm').addEventListener('submit', async function(e) { e.preventDefault(); const password = document.getElementById('password').value; - const correctPassword = 'Activity3-Quaking4-Unashamed5-Penholder6'; const errorMessage = document.getElementById('errorMessage'); + const button = this.querySelector('button'); - if (password === correctPassword) { - // Store authentication in session - sessionStorage.setItem('authenticated', 'true'); + // Disable button during request + button.disabled = true; + button.textContent = 'Verifying...'; + + try { + // Send password to Netlify function for server-side verification + const response = await fetch('/.netlify/functions/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ password }) + }); - // Redirect to main apps page - window.location.href = 'index.html'; - } else { - // Show error message - errorMessage.textContent = 'Invalid password. Please try again.'; + const data = await response.json(); + + if (response.ok) { + // Authentication successful + sessionStorage.setItem('authenticated', 'true'); + window.location.href = 'index.html'; + } else { + // Authentication failed + errorMessage.textContent = data.error || 'Invalid password. Please try again.'; + errorMessage.classList.add('show'); + document.getElementById('password').value = ''; + document.getElementById('password').focus(); + button.disabled = false; + button.textContent = 'Access Tools'; + } + } catch (error) { + console.error('Login error:', error); + errorMessage.textContent = 'Connection error. Please check your internet and try again.'; errorMessage.classList.add('show'); - document.getElementById('password').value = ''; - document.getElementById('password').focus(); + button.disabled = false; + button.textContent = 'Access Tools'; } }); diff --git a/webapps/netlify.toml b/webapps/netlify.toml new file mode 100644 index 0000000..acb2ef5 --- /dev/null +++ b/webapps/netlify.toml @@ -0,0 +1,11 @@ +[build] + command = "# No build needed" + functions = "netlify/functions" + publish = "." + +# Environment variables - Set these in Netlify UI (Site settings > Build & deploy > Environment) +# DO NOT commit actual values to git +[env] + SMARTCANE_PASSWORD = "set_in_netlify_ui" + GOOGLE_SHEET_ID = "1ZHEIyhupNDHVd1EScBn0DnuiAzMFoZcAPZm3U65abkY" + GOOGLE_SHEET_PASSWORD = "optional_if_needed" diff --git a/webapps/netlify/functions/get-mills.js b/webapps/netlify/functions/get-mills.js new file mode 100644 index 0000000..f0a43e0 --- /dev/null +++ b/webapps/netlify/functions/get-mills.js @@ -0,0 +1,52 @@ +/** + * Netlify Function to fetch Google Sheets data securely + * Credentials are stored as environment variables, not exposed to frontend + */ + +exports.handler = async (event) => { + try { + // Read credentials from environment variables (set in Netlify UI) + const sheetId = process.env.GOOGLE_SHEET_ID; + const sheetPassword = process.env.GOOGLE_SHEET_PASSWORD; // If needed for authentication + + if (!sheetId) { + return { + statusCode: 500, + body: JSON.stringify({ error: 'GOOGLE_SHEET_ID not configured' }) + }; + } + + // Construct the export URL + const csvUrl = `https://docs.google.com/spreadsheets/d/${sheetId}/export?format=csv`; + + // Fetch the CSV from Google Sheets + const response = await fetch(csvUrl); + + if (!response.ok) { + return { + statusCode: response.status, + body: JSON.stringify({ error: `Google Sheets returned ${response.status}` }) + }; + } + + const csv = await response.text(); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'text/csv', + 'Cache-Control': 'max-age=300' // Cache for 5 minutes + }, + body: csv + }; + } catch (error) { + console.error('Error fetching Google Sheet:', error); + return { + statusCode: 500, + body: JSON.stringify({ + error: 'Failed to fetch data', + message: error.message + }) + }; + } +}; diff --git a/webapps/netlify/functions/login.js b/webapps/netlify/functions/login.js new file mode 100644 index 0000000..aad31dc --- /dev/null +++ b/webapps/netlify/functions/login.js @@ -0,0 +1,66 @@ +/** + * Netlify Function for secure login + * Password is stored as environment variable on server, never exposed to frontend + */ + +exports.handler = async (event) => { + // Only allow POST requests + if (event.httpMethod !== 'POST') { + return { + statusCode: 405, + body: JSON.stringify({ error: 'Method not allowed' }) + }; + } + + try { + // Parse request body + const body = JSON.parse(event.body); + const submittedPassword = body.password; + + if (!submittedPassword) { + return { + statusCode: 400, + body: JSON.stringify({ error: 'Password required' }) + }; + } + + // Get correct password from environment variable (set in Netlify UI) + const correctPassword = process.env.SMARTCANE_PASSWORD; + + if (!correctPassword) { + console.error('SMARTCANE_PASSWORD environment variable not set'); + return { + statusCode: 500, + body: JSON.stringify({ error: 'Server configuration error' }) + }; + } + + // Compare passwords + if (submittedPassword === correctPassword) { + return { + statusCode: 200, + headers: { + 'Set-Cookie': `auth_token=smartcane_${Date.now()}; Path=/; SameSite=Strict; Secure; HttpOnly` + }, + body: JSON.stringify({ + success: true, + message: 'Login successful' + }) + }; + } else { + // Add delay to prevent brute force + await new Promise(resolve => setTimeout(resolve, 1000)); + + return { + statusCode: 401, + body: JSON.stringify({ error: 'Invalid password' }) + }; + } + } catch (error) { + console.error('Login error:', error); + return { + statusCode: 500, + body: JSON.stringify({ error: 'Server error' }) + }; + } +}; diff --git a/webapps/sugar_mill_locator/app.js b/webapps/sugar_mill_locator/app.js index 02dcb84..1542b24 100644 --- a/webapps/sugar_mill_locator/app.js +++ b/webapps/sugar_mill_locator/app.js @@ -225,6 +225,10 @@ function initMap() { // Load CSV data loadMillsData(); + // Initialize Google Sheets auto-refresh + initGoogleSheetsAutoRefresh(); + showGoogleSheetsSetup(); + // Attach mode button listeners attachModeListeners(); @@ -277,11 +281,22 @@ function updateMeasurementPanel() { // Load mills from CSV async function loadMillsData() { try { - const response = await fetch('sugar_cane_factories_africa.csv'); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + // Try to load from Google Sheets first + let csvText = await fetchGoogleSheetData(); + + // Fallback to local CSV if Google Sheets fails + if (!csvText) { + console.log('Falling back to local CSV file...'); + const response = await fetch('sugar_cane_factories_africa.csv'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + csvText = await response.text(); + showNotification('Using local data (Google Sheet unavailable)', 'warning'); + } else { + showNotification('✓ Data loaded from Google Sheet', 'success'); } - const csvText = await response.text(); + parseCSV(csvText); console.log('Mills loaded:', mills.length, mills.slice(0, 2)); renderMills(); @@ -289,11 +304,10 @@ async function loadMillsData() { updateLegend(); console.log('Legend updated'); } catch (error) { - console.error('Error loading CSV:', error); - // Show a notification to user + console.error('Error loading mills data:', error); const notification = document.createElement('div'); notification.style.cssText = 'position: fixed; top: 20px; right: 20px; background: #ff6b6b; color: white; padding: 15px 20px; border-radius: 5px; z-index: 9999;'; - notification.textContent = '⚠️ Could not load mill data. Make sure sugar_cane_factories_africa.csv is in the same folder.'; + notification.textContent = '⚠️ Could not load mill data. Check console for details.'; document.body.appendChild(notification); setTimeout(() => notification.remove(), 5000); } diff --git a/webapps/sugar_mill_locator/google-sheets-config.js b/webapps/sugar_mill_locator/google-sheets-config.js new file mode 100644 index 0000000..f6fa752 --- /dev/null +++ b/webapps/sugar_mill_locator/google-sheets-config.js @@ -0,0 +1,151 @@ +// Google Sheets Configuration +// This file connects to your Google Sheet for live data updates + +const GOOGLE_SHEETS_CONFIG = { + // Your Google Sheet ID (from the URL) + SHEET_ID: '1ZHEIyhupNDHVd1EScBn0DnuiAzMFoZcAPZm3U65abkY', + + // The sheet name or gid (the sheet tab you want to read from) + // If using gid=220881066, you can reference it this way + SHEET_NAME: 'Sheet1', // Change this to your actual sheet name if different + + // Auto-refresh interval in milliseconds (5 minutes = 300000ms) + REFRESH_INTERVAL: 300000, + + // Enable auto-refresh (set to false to disable) + AUTO_REFRESH_ENABLED: true +}; + +/** + * Fetch data from Google Sheet via Netlify Function + * The function keeps credentials secret on the server + */ +async function fetchGoogleSheetData() { + try { + // Call Netlify function instead of Google Sheets directly + // This keeps the Sheet ID and password hidden from browser dev tools + const response = await fetch('/.netlify/functions/get-mills'); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const csvText = await response.text(); + console.log('✓ Data fetched from Netlify Function (Google Sheet)'); + return csvText; + } catch (error) { + console.error('Error fetching data from Netlify Function:', error); + showNotification('Could not fetch data. Check your connection.', 'warning'); + return null; + } +} + +/** + * Initialize auto-refresh of data from Google Sheet + */ +function initGoogleSheetsAutoRefresh() { + if (!GOOGLE_SHEETS_CONFIG.AUTO_REFRESH_ENABLED) { + console.log('Google Sheets auto-refresh is disabled'); + return; + } + + console.log(`✓ Auto-refresh enabled (every ${GOOGLE_SHEETS_CONFIG.REFRESH_INTERVAL / 1000 / 60} minutes)`); + + // Refresh immediately on load + // (already done in initApp) + + // Then refresh periodically + setInterval(async () => { + console.log('🔄 Refreshing data from Google Sheet...'); + const csvData = await fetchGoogleSheetData(); + + if (csvData) { + // Clear old markers + Object.values(millMarkers).forEach(marker => map.removeLayer(marker)); + millMarkers = {}; + + // Parse new data + parseCSV(csvData); + + // Render updated mills + renderMills(); + updateLegend(); + + // Reapply filters + applyFilters(); + + console.log(`✓ Updated ${mills.length} mills from Google Sheet`); + showNotification(`Map updated with latest data (${mills.length} mills)`, 'success'); + } + }, GOOGLE_SHEETS_CONFIG.REFRESH_INTERVAL); +} + +/** + * Show notification to user + */ +function showNotification(message, type = 'info') { + const colors = { + 'success': '#4CAF50', + 'warning': '#FF9800', + 'error': '#F44336', + 'info': '#2196F3' + }; + + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: ${colors[type] || colors.info}; + color: white; + padding: 15px 20px; + border-radius: 5px; + z-index: 9999; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + font-weight: 500; + `; + notification.textContent = message; + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.transition = 'opacity 0.3s ease'; + notification.style.opacity = '0'; + setTimeout(() => notification.remove(), 300); + }, 4000); +} + +/** + * Provide setup instructions to the user + */ +function showGoogleSheetsSetup() { + console.log(` + ╔════════════════════════════════════════════════════════════╗ + ║ Google Sheets Integration Setup Instructions ║ + ╠════════════════════════════════════════════════════════════╣ + ║ ║ + ║ 1. Your Google Sheet is configured and ready! ║ + ║ ║ + ║ 2. Share the sheet with your colleagues: ║ + ║ - Click "Share" in the top-right ║ + ║ - Add their email addresses ║ + ║ - They must have "Editor" access ║ + ║ ║ + ║ 3. Column headers required (case-sensitive): ║ + ║ - Mill/Factory Name (or similar) ║ + ║ - Country ║ + ║ - Latitude ║ + ║ - Longitude ║ + ║ - Crushing Capacity (optional) ║ + ║ - Annual Sugar Production (optional) ║ + ║ - Notes (optional) ║ + ║ - Data Year (optional) ║ + ║ ║ + ║ 4. The map will automatically update every 5 minutes ║ + ║ with new data from the sheet ║ + ║ ║ + ║ 5. To change refresh interval, edit: ║ + ║ GOOGLE_SHEETS_CONFIG.REFRESH_INTERVAL ║ + ║ ║ + ╚════════════════════════════════════════════════════════════╝ + `); +} diff --git a/webapps/sugar_mill_locator/index.html b/webapps/sugar_mill_locator/index.html index bf866d7..b3b3286 100644 --- a/webapps/sugar_mill_locator/index.html +++ b/webapps/sugar_mill_locator/index.html @@ -619,6 +619,7 @@ +