Data preperation for Melanoplus niche modelling

Author

J, Gibson, Arizona Western College

Published

November 12, 2026

Download occurrence data

# ==============================================================================
# Script: 01_data_prep.R
# Purpose: Pre-download and project data for the Melanoplus Shiny Apps
# ==============================================================================
library(rgbif)
library(terra)
library(geodata)
library(dplyr)
library(tidyr)
library(CoordinateCleaner)
library(jsonlite)
library(stringr)
library(httr)

# ==============================================================================
# 0. Setup Directories
# ==============================================================================
shi.dir <- "" #set your working directory here
setwd(shi.dir)
dir.create("data", showWarnings = FALSE)
dir.create("raw", showWarnings = FALSE)

# ==========================================
# 1. GBIF DOWNLOAD (Genus: Melanoplus)
# ==========================================
Melanoplus_key <- name_backbone("Melanoplus", rank = "genus")$usageKey
print(paste("Using Melanoplus Key:", Melanoplus_key))

download_request <- occ_download(
  pred("taxonKey", Melanoplus_key),
  pred("hasCoordinate", TRUE)
)

occ_download_wait(download_request)

raw_df <- occ_download_import(d)

Clean occurrence data


# ==========================================
# 2. METADATA FILTERING & COORDINATE CLEANING & TAXON GROUPING
# ==========================================
df_filtered <- raw_df %>%
  select(genus, species, decimalLongitude, decimalLatitude, basisOfRecord, year) %>%
  filter(!is.na(genus), !is.na(species), species != "") %>%
  filter(!is.na(decimalLongitude), !is.na(decimalLatitude)) %>%
  filter(basisOfRecord != "FOSSIL_SPECIMEN")

df_filtered$decimalLongitude <- as.numeric(df_filtered$decimalLongitude)
df_filtered$decimalLatitude <- as.numeric(df_filtered$decimalLatitude)

df_clean <- clean_coordinates(
  x = df_filtered,
  lon = "decimalLongitude",
  lat = "decimalLatitude",
  species = "species",
  tests = c("capitals", "centroids", "equal", "gbif", "institutions", "zeros", "seas")
) %>% filter(.summary == TRUE)

# 1. Fetch the Top Level (The 32 Groups)
url <- "https://sfg.taxonworks.org/api/v1/otus/816542/inventory/taxonomy.json?project_token=3oerVKf82_196cIECvHYNg&max_descendants_depth=2"
response <- GET(url)
data <- fromJSON(content(response, "text", encoding = "UTF-8"))

data <- fromJSON("Melanoplus_groups.txt")

# 2. Extract the Group and Species Mappings
mapping <- data.frame(species = character(), species_group = character(), stringsAsFactors = FALSE)
groups_df <- data$descendants

for (i in 1:nrow(groups_df)) {
  # Clean HTML tags from the group name
  group_name_raw <- str_replace_all(groups_df$name[i], "<[^>]+>", "")
  
  if (str_detect(group_name_raw, "supersp\\.")) {
    clean_group_name <- paste(str_extract(group_name_raw, "(?<=supersp\\. )[a-z]+"), "group")
    
    # Extract the nested dataframe of species for this specific group
    children_df <- groups_df$descendants[[i]]
    
    if (is.data.frame(children_df) && nrow(children_df) > 0) {
      for (j in 1:nrow(children_df)) {
        sp_name_raw <- str_replace_all(children_df$name[j], "<[^>]+>", "")
        
        # Keep only "Melanoplus [species]" (drop the author/year)
        clean_sp_name <- str_extract(sp_name_raw, "^Melanoplus [a-z]+")
        
        mapping <- rbind(mapping, data.frame(species = clean_sp_name, species_group = clean_group_name))
      }
    }
  }
}

# Remove any duplicates just in case
mapping <- unique(mapping)
print(head(mapping)) # Let's print the first few rows to verify it worked!

# 3. Load your 300 GBIF species and merge
gbif_species <- read.csv("Melanopulus_species.csv") 

final_csv <- gbif_species %>%
  left_join(mapping, by = c("x" = "species")) %>%
  rename(species = x) %>%
  # If a species isn't in a TaxonWorks group, label it "Ungrouped"
  mutate(species_group = ifelse(is.na(species_group), "Ungrouped", species_group)) %>%
  # Drop the extra unnamed column if it exists
  select(species, species_group)

# 4. Save it for the Shiny app
write.csv(final_csv, "data/Melanopulus_species.csv", row.names = FALSE)
cat("Successfully mapped", nrow(final_csv), "species to their BugGuide groups!\n")

Download and process climate data


# ==========================================
# 3. BIOCLIM DATA, CONTINENT MASK & CUSTOM ALBERS
# ==========================================
# Download 30s (~1km) global bioclim data
bioclim_global <- worldclim_global(var = "bio", res = 0.5, path = tempdir())

# 1. Isolate the North American Continent
world_borders <- geodata::world(path = tempdir())
# Subset to the primary NA landmass (add Central American countries here if needed)
na_borders <- world_borders[world_borders$NAME_0 %in% c("Canada", "United States", "Mexico"), ]

cat("Cropping and masking climate data to the North American landmass...\n")
# Crop sets the bounding box; Mask converts everything outside the borders (oceans) to NA
bioclim_na <- crop(bioclim_global, na_borders)
bioclim_na <- mask(bioclim_na, na_borders)
names(bioclim_na) <- paste0("BIO", 1:19)

# 2. Define Custom Albers Equal Area Conic (Central Meridian = -100)
# This centers the map over the Great Plains instead of further east
custom_albers <- "+proj=aea +lat_1=20 +lat_2=60 +lat_0=40 +lon_0=-100 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs"

# 3. Project Layer-by-Layer to bypass GDAL RAM limits
cat("Projecting climate raster to custom Albers layer by layer (this will take a while)...\n")
proj_layers <- list()

for (i in 1:nlyr(bioclim_na)) {
  var_name <- names(bioclim_na)[i]
  cat(sprintf("Processing %s (%d of 19)...\n", var_name, i))
  
  # Project just this single layer and save it to a temp file
  temp_file <- file.path(tempdir(), paste0(var_name, "_albers.tif"))
  
  layer_proj <- project(
    x = bioclim_na[[i]], 
    y = custom_albers, 
    res = 1000, 
    filename = temp_file, 
    overwrite = TRUE
  )
  
  proj_layers[[i]] <- layer_proj
}

# Stack the 19 temporary layers back into a single multi-band raster
bioclim_albers <- rast(proj_layers)
bioclim_albers_trimmed <- trim(bioclim_albers)

## trim of the aleutions and hawaii
# 1. Extract the current bounding box dimensions
current_ext <- ext(bioclim_albers_trimmed)

# 2. Create a new extent, keeping East, South, and North the same, but moving West to -3,400,000
new_ext <- ext(-3400000, current_ext[2], current_ext[3], current_ext[4])

# 3. Crop the raster using the new bounding box
bioclim_albers_final <- crop(bioclim_albers_trimmed, new_ext)

# 4. Plot to verify the new, perfectly tight continental frame
plot(bioclim_albers_final[[1]], maxcell = 5000000, main = "Final Continental Albers (BIO1)")

# 5. Overwrite the main climate raster with this optimized version
writeRaster(bioclim_albers_final, "data/bioclim_na_albers_1km.tif", overwrite = TRUE)

# 6. Create and save a 100km template for richness
template_100km <- aggregate(bioclim_albers_final[[1]], fact = 100)
writeRaster(template_100km, "data/template_100km.tif", overwrite = TRUE)

#  Clean up the individual temp files to free up SSD space
file.remove(unlist(lapply(proj_layers, sources)))


# ==========================================
# 3a. ***PATCH*** TOPOGRAPHIC ROUGHNESS PATCH
# ==========================================
library(terra)
library(geodata)

# 1. Download global elevation data (same 30-arc-second resolution)
cat("Downloading elevation data...\n")
elev_global <- elevation_global(res = 0.5, path = tempdir())

# 2. Crop and mask to the existing North American borders
elev_na <- crop(elev_global, na_borders)
elev_na <- mask(elev_na, na_borders)

# 3. Compute Topographic Complexity 
# TRI (Terrain Ruggedness Index) is the mean difference between a central pixel and its 8 surrounding cells.
cat("Calculating Terrain Ruggedness Index (TRI)...\n")
tri_na <- terrain(elev_na, v = "TRI")
names(tri_na) <- "TRI"

# 4. Project TRI to your Custom Albers
cat("Projecting TRI to Custom Albers...\n")
tri_albers <- project(
  x = tri_na, 
  y = custom_albers, 
  res = 1000, 
  filename = file.path(tempdir(), "tri_albers_temp.tif"),
  overwrite = TRUE
)

# 5. Load your beautifully trimmed 19-layer Bioclim raster
bioclim_albers_final <- rast("data/bioclim_na_albers_1km.tif")

# 6. Crop the newly projected TRI to match your exact western cutoff (-3400000)
tri_albers_matched <- crop(tri_albers, ext(bioclim_albers_final))

# 7. Stack them together into a 20-layer super-raster
env_stack <- c(bioclim_albers_final, tri_albers_matched)

# 8. Overwrite the main file with the new 20-layer version
writeRaster(env_stack, "data/bioclim_na_albers_1km.tif", overwrite = TRUE)
cat("Success! TRI is now Layer 20.\n")

Aggregate occurrence records & extract variables

# ==========================================
# 4. SPATIAL AGGREGATION & EXTRACTION
# ==========================================
# Convert GBIF points to a spatial vector and project them to our new custom Albers
pts <- vect(as.data.frame(df_clean), geom = c("decimalLongitude", "decimalLatitude"), crs = "EPSG:4326")
pts_albers <- project(pts, custom_albers)

# Extract cell IDs from the projected Albers raster
df_clean$cell <- cellFromXY(env_stack, crds(pts_albers))
df_clean <- df_clean %>% filter(!is.na(cell))

# Aggregate occurrences by grid cell and species to reduce sampling bias
df_agg <- df_clean %>%
  group_by(cell, genus, species) %>%
  summarise(occurrence_count = n(), .groups = "drop")

# Retrieve the central projected X/Y coordinates for each populated cell
cell_coords <- xyFromCell(env_stack, df_agg$cell)
df_agg$x <- cell_coords[, "x"]
df_agg$y <- cell_coords[, "y"]

# Extract the 19 Bioclim variables for those specific cells
cat("Extracting climate variables for occurrences...\n")
clim_vals <- terra::extract(env_stack, df_agg$cell)

# Bind the climate data (dropping the redundant 'ID' column from extract)
final_data <- cbind(df_agg, clim_vals)

# ==========================================
# 5. ROBUST SPECIES FILTERING
# ==========================================
# Filter for species with at least 50 occurrences to ensure model stability
species_counts <- final_data %>%
  group_by(species) %>%
  summarize(total_n = sum(occurrence_count)) %>%
  arrange(desc(total_n))

robust_species <- species_counts %>%
  filter(total_n >= 50) %>%
  pull(species)

final_data_pruned <- final_data %>%
  filter(species %in% robust_species)

write.csv(final_data_pruned, "data/melanoplus_aggregated.csv", row.names = FALSE)
cat("Data prep complete. Saved", length(unique(final_data_pruned$species)), "robust Melanoplus species.\n")

Create species groups


# ==========================================
# 6. CREATE SPECIES GROUPS
# ==========================================
library(dplyr)
library(stringr)
library(tidyr)

# 1. Read and parse the TaxonWorks JSON text
cat("Parsing BugGuide/TaxonWorks groups...\n")
lines <- readLines("raw/Melanoplus_groups.txt", warn = FALSE)
raw_text <- paste(lines, collapse = " ")
chunks <- str_split(raw_text, "supersp\\.")[[1]]

mapping <- data.frame(species = character(), species_group = character(), stringsAsFactors = FALSE)

for (i in 2:length(chunks)) {
  chunk <- chunks[i]
  group_name_match <- str_extract(chunk, "^\\s*([a-z]+)")
  
  if (!is.na(group_name_match)) {
    clean_group_name <- paste0(trimws(group_name_match), " group")
    sp_matches <- str_extract_all(chunk, "Melanoplus\\s+[a-z]+")[[1]]
    
    if (length(sp_matches) > 0) {
      chunk_df <- data.frame(
        species = unique(sp_matches),
        species_group = clean_group_name,
        stringsAsFactors = FALSE
      )
      mapping <- rbind(mapping, chunk_df)
    }
  }
}

# Ensure no duplicate species exist in the mapping dictionary
mapping <- distinct(mapping, species, .keep_all = TRUE)

# 2. Load GBIF species list, merge with mapping, and save
cat("Merging groups with GBIF species list...\n")
gbif_species <- read.csv("data/Melanopulus_species.csv")

# Safely rename 'x' to 'species' if it's the raw file
if ("x" %in% colnames(gbif_species)) {
  gbif_species <- dplyr::rename(gbif_species, species = x)
}

final_csv <- gbif_species %>%
  # 1. Force the dataframe to ONLY keep the 'species' column, destroying any old groups
  dplyr::select(species) %>%
  dplyr::distinct(species, .keep_all = TRUE) %>%           
  
  # 2. Now the join has zero risk of .x/.y collisions
  dplyr::left_join(mapping, by = "species") %>%            
  
  # 3. Apply the "Ungrouped" label to anything missing a group
  dplyr::mutate(species_group = ifelse(is.na(species_group), "Ungrouped", species_group)) %>%
  
  # 4. Drop NA species rows
  tidyr::drop_na(species)                           

# 3. Save the clean mapping file for the Shiny App
write.csv(final_csv, "data/Melanopulus_species.csv", row.names = FALSE)

cat("\nSuccessfully mapped", nrow(final_csv), "species.\n")
cat("Ungrouped Status (FALSE = Successfully Grouped):\n")
print(table(final_csv$species_group == "Ungrouped"))