EMS4D Fuel Plot Sampling 2026

Author

Mickey Campbell

Published

June 4, 2026

Below are the basic spatial criteria we need to meet. Each of the following subsections will demonstrate how each masking criterion was defined.

  1. Within the extent of 2023 airborne lidar
  2. Within an area that burned in 2025
  3. On public land
  4. Between 25 and 175m from roads
  5. On slopes < 30 degrees
  6. On vegetated land cover as of 2023
  7. Not in/near water

1. Within the extent of 2023 airborne lidar

Pretty straightforward here – creating a raster mask with pixels that are either within the ALS data collection extents (1) or not (NA).

Show Code
# load libraries
library(lidR)
library(sf)
library(terra)
library(foreign)

# suppress terra progress bars
terraOptions(progress = 0)

# define directories
main_dir <- "S:/ursa/campbell/ems4d_2/data"
samp_dir <- file.path(main_dir, "sampling")
s2_dir <- file.path(main_dir, "s2")
als_dir <- file.path(main_dir, "als")
nifc_dir <- file.path(main_dir, "nifc")
pub_dir <- file.path(main_dir, "fed_land")
road_dir <- file.path(main_dir, "roads")
lf_dir <- file.path(main_dir, "lf")
nhd_dir <- file.path(main_dir, "nhd")
field_dir <- file.path(main_dir, "field")

# define reference rasters for crs, grid, etc.
ref_rast_mm <- file.path(s2_dir, "mm", "s2_mm_2023.tif") |>
  rast()
ref_rast_nk <- file.path(s2_dir, "nk", "s2_nk_2023.tif") |>
  rast()

# get boundaries
als_mm <- file.path(als_dir, "FASMEE2023_C2", "boundary.shp") |>
  vect()
als_nk <- file.path(als_dir, "AZ_USFSR3_3_D23", "boundary.shp") |>
  vect()

# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "mask_als.tif")
out_file_nk <- file.path(samp_dir, "nk", "mask_als.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # convert boundaries to raster
  mask_als_mm <- rasterize(als_mm, ref_rast_mm)
  mask_als_nk <- rasterize(als_nk, ref_rast_nk)
  
  # write to files
  writeRaster(mask_als_mm, out_file_mm, overwrite = T)
  writeRaster(mask_als_nk, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  mask_als_mm <- rast(out_file_mm)
  mask_als_nk <- rast(out_file_nk)
  
}

# plot them
par(mfrow = c(1,2))
plot(mask_als_mm, main = "Monroe Mountain")
plot(mask_als_nk, main = "North Kaibab")

2. Within an area that burned in 2025

Here I’ll use two different datasets:

  1. Burn perimeters from NIFC
    • The primary fires of interest are:
      • Monroe Mountain:
        • Monroe Canyon Fire (7/13/2025 - 9/5/2025)
      • North Kaibab:
        • Dragon Bravo Fire (7/4/2025 - 9/29/2025)
        • White Sage Fire (7/9/2025 - 9/15/2025)
    • These are a useful first cut, but perimeters are quite general and include unburned areas within.
  2. Difference in the normalized burn ratio (dNBR)
    • This will be used to identify areas that actually burned within the perimeters.
    • I used Google Earth Engine to generate NBR from Sentinel-2 (20m resolution) imagery:
      • Pre-fire: Cloud-free median composite from September, 2024
      • Post-fire: Cloud-free median composite from September, 2025
    • dNBR = NBRpre-fire - NBRpost-fire
Show Code
# read in fire perimeters
nifc_gdb <- file.path(nifc_dir, "38f5cf22-be3e-42ff-9a1d-44a5b7e34ab5.gdb")
fires_mm <- vect(nifc_gdb, "Perimeters_mm_2025")
fires_nk <- vect(nifc_gdb, "Perimeters_nk_2025")

# read in sentinel-2 dnbr data
dnbr_mm <- file.path(s2_dir, "mm", "s2_mm_burnsev_202409_202509.tif") |>
  rast(lyrs = "dnbr")
dnbr_nk <- file.path(s2_dir, "nk", "s2_nk_burnsev_202409_202509.tif") |>
  rast(lyrs = "dnbr")

# reproject fires to match dnbrs
fires_mm <- project(fires_mm, dnbr_mm)
fires_nk <- project(fires_nk, dnbr_nk)

# plot it out
par(mfrow = c(1,2))
plot(dnbr_mm, main = "Monroe Mountain")
lines(als_mm, col = "white")
lines(fires_mm, lwd = 2)
plot(dnbr_nk, main = "North Kaibab")
lines(als_nk, col = "white")
lines(fires_nk, lwd = 2)

To yield a final burn mask, I’ll apply a threshold of dNBR >= 0.1 (gleaned from visual inspection) and clip the resulting rasters to the burn perimeters.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "mask_burn.tif")
out_file_nk <- file.path(samp_dir, "nk", "mask_burn.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # apply dnbr threshold and clip to burn perimeters
  mask_burn_mm <- ifel(dnbr_mm >= 0.1, 1, NA) |>
    crop(fires_mm, mask = T)
  mask_burn_nk <- ifel(dnbr_nk >= 0.1, 1, NA) |>
    crop(fires_nk, mask = T)

  # write to files
  writeRaster(mask_burn_mm, out_file_mm, overwrite = T)
  writeRaster(mask_burn_nk, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  mask_burn_mm <- rast(out_file_mm)
  mask_burn_nk <- rast(out_file_nk)
  
}

# plot them
par(mfrow = c(1,2))
plot(mask_burn_mm, main = "Monroe Mountain")
lines(als_mm)
plot(mask_burn_nk, main = "North Kaibab")
lines(als_nk)

3. On public land

Pretty straightforward.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "mask_public.tif")
out_file_nk <- file.path(samp_dir, "nk", "mask_public.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # read in public land boundaries, vectorize, reproject, and rasterize
  mask_pub_mm <- file.path(pub_dir, "fed_land_mm.shp") |>
    vect() |>
    project(ref_rast_mm) |>
    rasterize(ref_rast_mm)
  mask_pub_nk <- file.path(pub_dir, "fed_land_nk.shp") |>
    vect() |>
    project(ref_rast_nk) |>
    rasterize(ref_rast_nk)

  # write to files
  writeRaster(mask_pub_mm, out_file_mm, overwrite = T)
  writeRaster(mask_pub_nk, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  mask_pub_mm <- rast(out_file_mm)
  mask_pub_nk <- rast(out_file_nk)
  
}

# plot them
par(mfrow = c(1,2))
plot(mask_pub_mm, main = "Monroe Mountain")
lines(als_mm)
plot(mask_pub_nk, main = "North Kaibab")
lines(als_nk)

4. Between 25 and 175m from roads

There is no silver bullet roads dataset. The USFS has its own roads data with good road quality attribution, but those don’t include non-FS roads (i.e., state highways, etc.). The USGS National Transporation Dataset (NTD) has basically all roads (including FS roads), but doesn’t have the same road quality attribution. So, I basically removed FS roads from the NTD, merged FS roads back into the NTD, and came up with a lookup table that has a hopefully useful road quality field.

We want plots to be far enough away from roads to avoid edge effects (>25m), while not so far off roads that it becomes challenging to reach on foot (<175m).

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "mask_roads.tif")
out_file_nk <- file.path(samp_dir, "nk", "mask_roads.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # read in roads and reproject
  rds_gdb <- file.path(road_dir, "roads_ems4d.gdb")
  rds_mm <- vect(rds_gdb, "roads_mm_clean") |>
    project(ref_rast_mm)
  rds_nk <- vect(rds_gdb, "roads_nk_clean") |>
    project(ref_rast_nk)

  # buffer and subtract
  buff_in_mm <- buffer(rds_mm, 25) |> aggregate()
  buff_out_mm <- buffer(rds_mm, 175) |> aggregate()
  buff_diff_mm <- erase(buff_out_mm, buff_in_mm)
  
  buff_in_nk <- buffer(rds_nk, 25) |> aggregate()
  buff_out_nk <- buffer(rds_nk, 175) |> aggregate()
  buff_diff_nk <- erase(buff_out_nk, buff_in_nk)
  
  # create masks
  mask_rds_mm <- rasterize(buff_diff_mm, ref_rast_mm)
  mask_rds_nk <- rasterize(buff_diff_nk, ref_rast_nk)

  # write to files
  writeRaster(mask_rds_mm, out_file_mm, overwrite = T)
  writeRaster(mask_rds_nk, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  mask_rds_mm <- rast(out_file_mm)
  mask_rds_nk <- rast(out_file_nk)
  
}

# plot them
par(mfrow = c(1,2))
plot(mask_rds_mm, main = "Monroe Mountain")
lines(als_mm)
plot(mask_rds_nk, main = "North Kaibab")
lines(als_nk)

On slopes < 30 degrees

Pretty straightforward.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "mask_slope.tif")
out_file_nk <- file.path(samp_dir, "nk", "mask_slope.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # see if slope already exists, if so, read in; if not, create them
  slope_file_mm <- file.path(als_dir, "FASMEE2023_C2", "a03_dtm", "dtm_20m_slope.tif")
  slope_file_nk <- file.path(als_dir, "AZ_USFSR3_3_D23", "a03_dtm", "dtm_20m_slope.tif")
  if (!all(file.exists(slope_file_mm), file.exists(slope_file_nk))){
    dtm_mm <- file.path(als_dir, "FASMEE2023_C2", "a03_dtm", "dtm_20m.tif") |>
      rast()
    dtm_nk <- file.path(als_dir, "AZ_USFSR3_3_D23", "a03_dtm", "dtm_20m.tif") |>
      rast()
    slope_mm <- terrain(dtm_mm, "slope")
    slope_nk <- terrain(dtm_nk, "slope")
    writeRaster(slope_mm, slope_file_mm, overwrite = T)
    writeRaster(slope_nk, slope_file_nk, overwrite = T)
  } else {
    slope_mm <- rast(slope_file_mm)
    slope_nk <- rast(slope_file_nk)
  }
  
  # create masks
  mask_slope_mm <- ifel(slope_mm < 30, 1, NA)
  mask_slope_nk <- ifel(slope_nk < 30, 1, NA)

  # write to files
  writeRaster(mask_slope_mm, out_file_mm, overwrite = T)
  writeRaster(mask_slope_nk, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  mask_slope_mm <- rast(out_file_mm)
  mask_slope_nk <- rast(out_file_nk)
  
}

# plot them
par(mfrow = c(1,2))
plot(mask_slope_mm, main = "Monroe Mountain")
lines(als_mm)
plot(mask_slope_nk, main = "North Kaibab")
lines(als_nk)

6. On vegetated land cover as of 2023

For this purpose I will rely on LANDFIRE 2023 Existing Vegetation Type data. I will use the EVT_PHYS (existing vegetation physiognomy) to select only the following classes:

  • Shrubland
  • Grassland
  • Conifer
  • Hardwood
  • Conifer-Hardwood

This will exclude all developed land, large water features, riparian areas, and barren/sparsely-vegetated areas.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "mask_lf.tif")
out_file_nk <- file.path(samp_dir, "nk", "mask_lf.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # read in landfire dbf
  lf_df <- file.path(lf_dir, "mm", "LF2023_EVT_CONUS", "LF2023_EVT_CONUS.dbf") |>
    read.dbf()
  
  # define keeper physiognomy
  keep_phys <- c(
    "Shrubland",
    "Grassland",
    "Conifer",
    "Hardwood",
    "Conifer-Hardwood"
  )
  
  # get raster values
  keep_vals <- lf_df$VALUE[lf_df$EVT_PHYS %in% keep_phys]
  
  # read in and reproject landfire data
  lf_mm <- file.path(lf_dir, "mm", "LF2023_EVT_CONUS", "LF2023_EVT_CONUS.tif") |>
    rast() |>
    project(ref_rast_mm, method = "near")
  lf_nk <- file.path(lf_dir, "nk", "LF2023_EVT_CONUS", "LF2023_EVT_CONUS.tif") |>
    rast() |>
    project(ref_rast_nk, method = "near")
  
  # create masks
  mask_lf_mm <- ifel(lf_mm %in% keep_vals, 1, NA)
  mask_lf_nk <- ifel(lf_nk %in% keep_vals, 1, NA)

  # write to files
  writeRaster(mask_lf_mm, out_file_mm, overwrite = T)
  writeRaster(mask_lf_nk, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  mask_lf_mm <- rast(out_file_mm)
  mask_lf_nk <- rast(out_file_nk)
  
}

# plot them
par(mfrow = c(1,2))
plot(mask_lf_mm, main = "Monroe Mountain")
lines(als_mm)
plot(mask_lf_nk, main = "North Kaibab")
lines(als_nk)

7. Not in/near water

Although LANDFIRE EVT does a good job of identifying large water features, there may be smaller perennial streams and waterbodies that are too small to be represented in a 30m pixel, but would still negatively affect our ability to place a fuel plot within. To remedy that, I will use the National Hydrography Dataset (NHD) High-Resolution Plus. I will use all NHD area and waterbody polygons, as well as perennial streams, buffering all by 20m and creating a raster mask of areas not found within the buffer areas.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "mask_water.tif")
out_file_nk <- file.path(samp_dir, "nk", "mask_water.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # read in nhd data
  nhd_gdb <- file.path(nhd_dir, "nhd.gdb")
  nhd_line_mm <- vect(nhd_gdb, layer = "nhd_flowlines_mm")
  nhd_line_nk <- vect(nhd_gdb, layer = "nhd_flowlines_nk")
  nhd_poly_mm <- vect(nhd_gdb, layer = "nhd_waterbodies_mm")
  nhd_poly_nk <- vect(nhd_gdb, layer = "nhd_waterbodies_nk")
  
  # define keeper codes
  keep_fcodes <- c(33600, 46006, 55800)
  
  # filter features
  nhd_line_mm <- nhd_line_mm[nhd_line_mm$fcode %in% keep_fcodes,]
  nhd_line_nk <- nhd_line_nk[nhd_line_nk$fcode %in% keep_fcodes,]
  
  # reproject
  nhd_line_mm <- project(nhd_line_mm, ref_rast_mm)
  nhd_line_nk <- project(nhd_line_nk, ref_rast_nk)
  nhd_poly_mm <- project(nhd_poly_mm, ref_rast_mm)
  nhd_poly_nk <- project(nhd_poly_nk, ref_rast_nk)
  
  # buffer them all
  nhd_line_mm <- buffer(nhd_line_mm, 20)
  nhd_line_nk <- buffer(nhd_line_nk, 20)
  nhd_poly_mm <- buffer(nhd_poly_mm, 20)
  nhd_poly_nk <- buffer(nhd_poly_nk, 20)
  
  # merge
  water_mm <- rbind(nhd_line_mm, nhd_poly_mm) |> aggregate()
  water_nk <- rbind(nhd_line_nk, nhd_poly_nk) |> aggregate()
  
  # rasterize
  water_mm <- rasterize(water_mm, ref_rast_mm)
  water_nk <- rasterize(water_nk, ref_rast_nk)
  
  # create masks
  mask_water_mm <- ifel(is.na(water_mm), 1, NA)
  mask_water_nk <- ifel(is.na(water_nk), 1, NA)

  # write to files
  writeRaster(mask_water_mm, out_file_mm, overwrite = T)
  writeRaster(mask_water_nk, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  mask_water_mm <- rast(out_file_mm)
  mask_water_nk <- rast(out_file_nk)
  
}

# plot them
par(mfrow = c(1,2))
plot(mask_water_mm, main = "Monroe Mountain")
lines(als_mm)
plot(mask_water_nk, main = "North Kaibab")
lines(als_nk)

Combined mask

I will now take all of these masks, and find the overlapping areas where all layers have a pixel value of 1 (i.e., meet our target criteria).

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "mask_combined.tif")
out_file_nk <- file.path(samp_dir, "nk", "mask_combined.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # put all masks into lists
  masks_mm <- list(
    mask_als_mm,
    mask_burn_mm,
    mask_pub_mm,
    mask_rds_mm,
    mask_slope_mm,
    mask_lf_mm,
    mask_water_mm
  )
  masks_nk <- list(
    mask_als_nk,
    mask_burn_nk,
    mask_pub_nk,
    mask_rds_nk,
    mask_slope_nk,
    mask_lf_nk,
    mask_water_nk
  )
  
  # get smallest shared extents
  common_ext_mm <- Reduce(function(a, b) {
    ext(
      max(xmin(a), xmin(b)),
      min(xmax(a), xmax(b)),
      max(ymin(a), ymin(b)),
      min(ymax(a), ymax(b))
    )
  }, lapply(masks_mm, ext))
  common_ext_nk <- Reduce(function(a, b) {
    ext(
      max(xmin(a), xmin(b)),
      min(xmax(a), xmax(b)),
      max(ymin(a), ymin(b)),
      min(ymax(a), ymax(b))
    )
  }, lapply(masks_nk, ext))
  
  # crop masks to common extents
  masks_crop_mm <- lapply(masks_mm, crop, y = common_ext_mm, snap = "out")
  masks_crop_nk <- lapply(masks_nk, crop, y = common_ext_nk, snap = "out")
  
  # stack them
  masks_stack_mm <- rast(masks_crop_mm)
  masks_stack_nk <- rast(masks_crop_nk)
  
  # combine
  mask_comb_mm <- app(masks_stack_mm, fun = prod)
  mask_comb_nk <- app(masks_stack_nk, fun = prod)
  
  # write to files
  writeRaster(mask_comb_mm, out_file_mm, overwrite = T)
  writeRaster(mask_comb_nk, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  mask_comb_mm <- rast(out_file_mm)
  mask_comb_nk <- rast(out_file_nk)
  
}

# plot them
par(mfrow = c(1,2))
plot(mask_comb_mm, main = "Monroe Mountain")
lines(als_mm)
plot(mask_comb_nk, main = "North Kaibab")
lines(als_nk)

TOTAL SAMPLING AREA

  • Monroe Mountain: 1704.04 ha
  • North Kaibab: 7808.84 ha

For the 2026 field campaign, the goal is to collect plots in areas that burned during the extensive 2025 fires in both study areas. A small subset of the field plots that we collected in 2023 fell within the burn perimeters:

Show Code
# read in fuel plots
plots_2023 <- file.path(field_dir, "Plots_NKFL.shp") |> vect()
plots_2023_mm <- plots_2023[grep("^FL", plots_2023$plot_id),]
plots_2023_nk <- plots_2023[grep("^NK", plots_2023$plot_id),]

# reproject
plots_2023_mm <- project(plots_2023_mm, ref_rast_mm)
plots_2023_nk <- project(plots_2023_nk, ref_rast_nk)

# figure out which fuel plots fell within burn perimeters
plots_2023_mm$burn_perim <- relate(plots_2023_mm, aggregate(fires_mm), "within")
plots_2023_nk$burn_perim <- relate(plots_2023_nk, aggregate(fires_nk), "within")

# get counts
n_mm <- nrow(plots_2023_mm)
n_in_mm <- sum(plots_2023_mm$burn_perim)
n_out_mm <- n_mm - n_in_mm

n_nk <- nrow(plots_2023_nk)
n_in_nk <- sum(plots_2023_nk$burn_perim)
n_out_nk <- n_nk - n_in_nk

# plot mm
par(mfrow = c(1,2))
plot(als_mm, main = "Monroe Mountain")
plot(fires_mm, add = T, col = "orange", border = NA)
plot(
  x = plots_2023_mm, 
  add = T, 
  col = ifelse(plots_2023_mm$burn_perim, "cyan", "black")
)
add_legend(
  "bottomright",
  legend = c(
    paste0("in (", n_in_mm, "/", n_mm, ")"),
    paste0("out (", n_out_mm, "/", n_mm, ")")
  ),
  pch = 16,
  col = c("cyan", "black")
)

# plot nk
plot(als_nk, main = "North Kaibab")
plot(fires_nk, add = T, col = "orange", border = NA)
plot(
  x = plots_2023_nk, 
  add = T, 
  col = ifelse(plots_2023_nk$burn_perim, "cyan", "black")
)
add_legend(
  "bottomleft",
  legend = c(
    paste0("in (", n_in_nk, "/", n_nk, ")"),
    paste0("out (", n_out_nk, "/", n_nk, ")")
  ),  
  pch = 16,
  col = c("cyan", "black")
)

So, a total of 8 of the original 40 plots fell within 2025 burn perimeters. However, upon closer inspection, it becomes clear that even some that were within the perimeters may not have burned, or at least may not have burned severely. Below I will examine the following burn severity variables at each 2023 plot location, derived from Sentinel-2 data:

  • Difference Normalized Burn Ratio (dNBR) (9/2024-9/2025)
  • Relative Difference Normalized Burn Ratio (RdNBR) (9/2024-9/2025)
  • Relative Burn Ratio (RBR) (9/2024-9/2025)
Show Code
# read in sentinel-2 burn severity data
bs_mm <- file.path(s2_dir, "mm", "s2_mm_burnsev_202409_202509.tif") |>
  rast(lyrs = c("dnbr", "rdnbr", "rbr"))
bs_nk <- file.path(s2_dir, "nk", "s2_nk_burnsev_202409_202509.tif") |>
  rast(lyrs = c("dnbr", "rdnbr", "rbr"))

# extract values
plots_2023_mm <- extract(bs_mm, plots_2023_mm, ID = F, bind = T)
plots_2023_nk <- extract(bs_nk, plots_2023_nk, ID = F, bind = T)

# plot it out
par(mfrow = c(3,1), las = 1, mar = c(5,5,0,0), oma = c(0,0,2,0))
plot(
  burn_perim ~ dnbr, 
  data = plots_2023_mm,
  ylim = c(-0.5,1.5),
  pch = 16,
  col = ifelse(plots_2023_mm$burn_perim, "cyan", "black"),
  yaxt = "n",
  ylab = NA,
  xlab = "dNBR"
)
axis(2, at = c(0,1), labels = c("out", "in"))
mtext("Monroe Mountain", line = 0.5)

plot(
  burn_perim ~ rdnbr, 
  data = plots_2023_mm,
  ylim = c(-0.5,1.5),
  pch = 16,
  col = ifelse(plots_2023_mm$burn_perim, "cyan", "black"),
  yaxt = "n",
  ylab = NA,
  xlab = "RdNBR"
)
axis(2, at = c(0,1), labels = c("out", "in"))

plot(
  burn_perim ~ rdnbr, 
  data = plots_2023_mm,
  ylim = c(-0.5,1.5),
  pch = 16,
  col = ifelse(plots_2023_mm$burn_perim, "cyan", "black"),
  yaxt = "n",
  ylab = NA,
  xlab = "RBR"
)
axis(2, at = c(0,1), labels = c("out", "in"))

Show Code
# plot it out
par(mfrow = c(3,1), las = 1, mar = c(5,5,0,0), oma = c(0,0,2,0))
plot(
  burn_perim ~ dnbr, 
  data = plots_2023_nk,
  ylim = c(-0.5,1.5),
  pch = 16,
  col = ifelse(plots_2023_nk$burn_perim, "cyan", "black"),
  yaxt = "n",
  ylab = NA,
  xlab = "dNBR"
)
axis(2, at = c(0,1), labels = c("out", "in"))
mtext("North Kaibab", line = 0.5)

plot(
  burn_perim ~ rdnbr, 
  data = plots_2023_nk,
  ylim = c(-0.5,1.5),
  pch = 16,
  col = ifelse(plots_2023_nk$burn_perim, "cyan", "black"),
  yaxt = "n",
  ylab = NA,
  xlab = "RdNBR"
)
axis(2, at = c(0,1), labels = c("out", "in"))

plot(
  burn_perim ~ rdnbr, 
  data = plots_2023_nk,
  ylim = c(-0.5,1.5),
  pch = 16,
  col = ifelse(plots_2023_nk$burn_perim, "cyan", "black"),
  yaxt = "n",
  ylab = NA,
  xlab = "RBR"
)
axis(2, at = c(0,1), labels = c("out", "in"))

It looks more like 4 plots burned in Monroe Mountain and 1 plot burned in North Kaibab. Here they are:

Show Code
# isolate plots that likely burned
plots_2023_mm$burned <- ifelse(plots_2023_mm$dnbr > 0.1, T, F)
plots_2023_nk$burned <- ifelse(plots_2023_nk$dnbr > 0.1, T, F)

burned_mm <- plots_2023_mm[plots_2023_mm$burned == T,]
burned_nk <- plots_2023_nk[plots_2023_nk$burned == T,]

# read in sentinel-2 2023 imagery
s2_2023_mm <- file.path(s2_dir, "mm", "s2_mm_2023.tif") |>
  rast(lyrs = c("B4", "B3", "B2"))
s2_2023_nk <- file.path(s2_dir, "nk", "s2_nk_2023.tif") |>
  rast(lyrs = c("B4", "B3", "B2"))

# plot them out
par(mfrow = c(2,2), oma = c(0,2,0,0))
for (i in 1:nrow(burned_mm)){
  pt <- burned_mm[i,]
  ptx <- crds(pt)[1]
  pty <- crds(pt)[2]
  e <- ext(ptx - 500, ptx + 500, pty - 500, pty + 500)
  s2_2023_mm_crop <- crop(s2_2023_mm, e, snap = "out")
  s2_2023_mm <- ifel(s2_2023_mm > 0.2, 0.2, s2_2023_mm)
  plot(e, main = pt$plot_id)
  plotRGB(
    s2_2023_mm_crop,
    add = T,
    scale = 0.2,
    smooth = F
  )
  plot(pt, add = T, pch = 21, bg = "white")
}

Show Code
par(mfrow = c(1,2), oma = c(0,2,0,0))
for (i in 1:nrow(burned_nk)){
  pt <- burned_nk[i,]
  ptx <- crds(pt)[1]
  pty <- crds(pt)[2]
  e <- ext(ptx - 500, ptx + 500, pty - 500, pty + 500)
  s2_2023_nk_crop <- crop(s2_2023_nk, e, snap = "out")
  s2_2023_nk <- ifel(s2_2023_nk > 0.2, 0.2, s2_2023_nk)
  plot(e, main = pt$plot_id)
  plotRGB(
    s2_2023_nk_crop,
    add = T,
    scale = 0.2,
    smooth = F
  )
  plot(pt, add = T, pch = 21, bg = "white")
}

So, we will plan on re-measuring these 5 plots in 2026.

For the plots that did not burn, it does not make much sense to remeasure them (as they would yield very similar fuel metrics from 2023). Instead, we have two options:

  1. Create an new stratification within burned areas
    • This would basically be trying to capture a new axis of variability of some kind. For example, we could stratify along some measure of burn severity (e.g., dNBR, etc.), perhaps along with pre-fire vegetation structure.
  2. Use “space-for-time substitution” to try to capture consumption
    • We can’t directly quantify consumption on plots that didn’t burn. But, let’s assume that there is some ecological/structural equivalent area, for each unburned 2023 plot, that fell within the burn areas. By using 2023 ALS and Sentinel-2 data, we may be able to identify areas within the burn that were pre-fire fuel equivalents of unburned plots. Assuming we can find these equivalents, then we can nearly directly quantify consumption at the plot level.

Given the prospect of this second approach providing a scientifically novel way to substitute space for time in quantifying fuel consumption, I’ll try this approach. I’m happy to adapt the sampling approach as needed, if others disagree that this is the best path forward.

Space-for-time substitution approach, summary

For each unburned 2023 plot, we need to find a plot (or, a few potential plots) within the burn area that are very similar in terms of their pre-fire vegetation structure, topography, and spectral response. Vegetation structure and topography will both come from the 2023 lidar, and spectral response will come from 2023 Sentinel-2 imagery. I have a stack of 20m resolution variables (let’s refer to them as “predictors”) for both study areas that include:

  • 61 ALS-based vegetation structure metrics (metrics_set1() from lidRmetrics)
  • 14 ALS-based topography metrics (elevation, slope, aspect, etc.)
  • 22 Sentinel-2-based spectral metrics (reflectance plus a suite of veg indices)

Here is a sample, for Monroe Mountain:

Show Code
# read in the data
samp_rast_mm <- file.path(samp_dir, "mm", "vars_full.tif") |> rast()
samp_rast_nk <- file.path(samp_dir, "nk", "vars_full.tif") |> rast()

# grab a few layers
lyrs <- c(
  "als_zmax", "als_pzabove2", "als_Lskew",
  "topo_dtm", "topo_aspect_cos", "topo_tpi_16",
  "spec_B4", "spec_NDVI", "spec_CIRE"
)
temp_rast <- samp_rast_mm[[lyrs]]

# plot them out
plot(temp_rast, nc = 3, nr = 3)

With a total of 97 predictor variables, many of which will be highly correlated, I’ll first perform a principal components analysis to both reduce dimensionality and mitigate collinearity. I will use these transformed data as the basis of identifying similarity in n-dimensional PC space. For each plot, I will calculate the Euclidean distance between the PCs at the plot location and all of the pixels that fall within the previously generated sampling mask. I will identify a few candidate plots within the mask that minimize Euclidean distance (maximize pre-fire similarity) with each unburned, 2023-measured plot.

Step 1. PCA

First, I will perform a PCA on the full predictor stack. I will keep the resulting PCs that explain 99% of variance of the input predictor stack.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "pca_full.tif")
out_file_nk <- file.path(samp_dir, "nk", "pca_full.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # fix  names
  names(samp_rast_mm) <- sub("\\.", "_", names(samp_rast_mm))
  names(samp_rast_mm) <- sub("-", "_", names(samp_rast_mm))
  names(samp_rast_mm) <- tolower(names(samp_rast_mm))
  
  names(samp_rast_nk) <- sub("\\.", "_", names(samp_rast_nk))
  names(samp_rast_nk) <- sub("-", "_", names(samp_rast_nk))
  names(samp_rast_nk) <- tolower(names(samp_rast_nk))
  
  # run pca
  pca_mm <- prcomp(samp_rast_mm, center = T, scale. = T, maxcell = 1e6)
  pca_nk <- prcomp(samp_rast_nk, center = T, scale. = T, maxcell = 1e6)
  
  # get percent variance explained
  pve_mm <- pca_mm$sdev^2 / sum(pca_mm$sdev^2)
  pve_nk <- pca_nk$sdev^2 / sum(pca_nk$sdev^2)
  
  # get cumulative variance explained
  cve_mm <- cumsum(pve_mm)
  cve_nk <- cumsum(pve_nk)
  
  # get point at which more than 99% of variance is explained
  cutoff_mm <- which(cve_mm < 0.99)
  cutoff_nk <- which(cve_nk < 0.99)
  
  # get pca prediction
  pca_rast_mm <- predict(samp_rast_mm, pca_mm, index = cutoff_mm)
  pca_rast_nk <- predict(samp_rast_nk, pca_nk, index = cutoff_nk)
  
  # write to files
  writeRaster(pca_rast_mm, out_file_mm, overwrite = T)
  writeRaster(pca_rast_nk, out_file_nk, overwrite = T)
  
  # save pca info
  saveRDS(pca_mm, sub("\\.tif", ".rds", out_file_mm))
  saveRDS(pca_nk, sub("\\.tif", ".rds", out_file_nk))
  
} else {
  
  # read the existing files in
  pca_rast_mm <- rast(out_file_mm)
  pca_rast_nk <- rast(out_file_nk)
  pca_mm <- readRDS(sub("\\.tif", ".rds", out_file_mm))
  pca_nk <- readRDS(sub("\\.tif", ".rds", out_file_nk))
  
}

# get percent variance explained
pve_mm <- pca_mm$sdev^2 / sum(pca_mm$sdev^2)
pve_nk <- pca_nk$sdev^2 / sum(pca_nk$sdev^2)

# get cumulative variance explained
cve_mm <- cumsum(pve_mm)
cve_nk <- cumsum(pve_nk)

# get point at which more than 99% of variance is explained
cutoff_mm <- which(cve_mm < 0.99) |> max()
cutoff_nk <- which(cve_nk < 0.99) |> max()

# plot out pca variance explained results -- mm
par(mfrow = c(1,2), las = 1)
plot(cve_mm, pch = 16, xlab = "PC", ylab = "Variance Explained",
     main = "Monroe Mountain", col = "gray")
lines(
  x = c(par("usr")[1], cutoff_mm),
  y = rep(cve_mm[cutoff_mm],2),
  col = "red"
)
lines(
  x = rep(cutoff_mm,2),
  y = c(par("usr")[3], cve_mm[cutoff_mm]),
  col = "red"
)
points(
  x = cutoff_mm,
  y = cve_mm[cutoff_mm],
  pch = 16,
  col = "red"
)
text(
  x = cutoff_mm,
  y = mean(par("usr")[3:4]),
  pos = 4,
  labels = paste0("99% Var Expl\nPC", cutoff_mm),
  col = "red"
)

# plot out pca variance explained results -- nk
plot(cve_nk, pch = 16, xlab = "PC", ylab = "Variance Explained",
     main = "North Kaibab", col = "gray")
lines(
  x = c(par("usr")[1], cutoff_nk),
  y = rep(cve_nk[cutoff_nk],2),
  col = "red"
)
lines(
  x = rep(cutoff_nk,2),
  y = c(par("usr")[3], cve_nk[cutoff_nk]),
  col = "red"
)
points(
  x = cutoff_nk,
  y = cve_nk[cutoff_nk],
  pch = 16,
  col = "red"
)
text(
  x = cutoff_nk,
  y = mean(par("usr")[3:4]),
  pos = 4,
  labels = paste0("99% Var Expl\nPC", cutoff_nk),
  col = "red"
)

Step 2. Get PC values at unburned plots

I will now use the PC rasters to extract pixel values at the unburned plot locations. The resulting data will serve as the basis of the subsequent similarity analysis.

Show Code
# get unburned plots
unburned_mm <- plots_2023_mm[plots_2023_mm$burned == F,]
unburned_nk <- plots_2023_nk[plots_2023_nk$burned == F,]

# extract pc values
unburned_mm_pc <- extract(pca_rast_mm, unburned_mm, method = "bilinear")
unburned_nk_pc <- extract(pca_rast_nk, unburned_nk, method = "bilinear")

# replace "ID" with plot_id
unburned_mm_pc$ID <- unburned_mm$plot_id
unburned_nk_pc$ID <- unburned_nk$plot_id
names(unburned_mm_pc)[1] <- "plot_id"
names(unburned_nk_pc)[1] <- "plot_id"

Step 3. Apply sampling mask to PC rasters

I now need to mask the PC rasters to only the previously generated sampling mask which, among other criteria, defines areas that burned in 2025.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "pca_mask.tif")
out_file_nk <- file.path(samp_dir, "nk", "pca_mask.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # mask pc rasters to sampling mask
  pca_rast_mm_mask <- crop(pca_rast_mm, mask_comb_mm, mask = T)
  pca_rast_nk_mask <- crop(pca_rast_nk, mask_comb_nk, mask = T)
  
  # write to files
  writeRaster(pca_rast_mm_mask, out_file_mm, overwrite = T)
  writeRaster(pca_rast_nk_mask, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  pca_rast_mm_mask <- rast(out_file_mm)
  pca_rast_nk_mask <- rast(out_file_nk)
  
}

# plot the data out -- mm
plot(pca_rast_mm_mask[[1:4]])

Show Code
# plot the data out -- nk
plot(pca_rast_mm_mask[[1:4]])

Step 4. Create Euclidean distance rasters

The next step is to create Euclidean distance rasters for each unburned plot within the masked sample areas.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "dist_mask.tif")
out_file_nk <- file.path(samp_dir, "nk", "dist_mask.tif")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # Euclidean distance function for a single plot vector
  dist_fun <- function(x, ref) {
    x <- as.matrix(x)
    if (ncol(x) != length(ref) && nrow(x) == length(ref)) {
      x <- t(x)
    }
    out <- rep(NA_real_, nrow(x))
    ok <- complete.cases(x)
    if (any(ok)) {
      xx <- x[ok, , drop = FALSE]
      out[ok] <- sqrt(rowSums((xx - matrix(ref,
                                          nrow = nrow(xx),
                                          ncol = length(ref),
                                          byrow = TRUE))^2))
    }
    out
  }
  
  # output folders
  out_dir_mm <- file.path(samp_dir, "mm", "distance_rasts")
  out_dir_nk <- file.path(samp_dir, "nk", "distance_rasts")
  dir.create(out_dir_mm, showWarnings = FALSE, recursive = TRUE)
  dir.create(out_dir_nk, showWarnings = FALSE, recursive = TRUE)
  
  # get pc columns
  pc_cols_mm <- names(unburned_mm_pc)[grep("^PC", names(unburned_mm_pc))]
  pc_cols_nk <- names(unburned_nk_pc)[grep("^PC", names(unburned_nk_pc))]
  
  # loop over plots and create ed rasters -- mm
  n_mm <- nrow(unburned_mm_pc)
  for (i in 1:n_mm) {
    plot_id <- unburned_mm_pc$plot_id[i]
    out_file <- file.path(out_dir_mm, paste0("dist_", plot_id, ".tif"))
    if (!file.exists(out_file)){
      ref_vals <- as.numeric(unburned_mm_pc[i, pc_cols_mm])
      names(ref_vals) <- pc_cols_mm
      dist_rast <- app(pca_rast_mm_mask, fun = dist_fun, ref = ref_vals)
      names(dist_rast) <- paste0("dist_", plot_id)
      writeRaster(dist_rast, out_file, overwrite = T)
    }
  }
  
  # loop over plots and create ed rasters -- nk
  n_nk <- nrow(unburned_nk_pc)
  for (i in 1:n_nk) {
    plot_id <- unburned_nk_pc$plot_id[i]
    out_file <- file.path(out_dir_nk, paste0("dist_", plot_id, ".tif"))
    if (!file.exists(out_file)){
      ref_vals <- as.numeric(unburned_nk_pc[i, pc_cols_nk])
      names(ref_vals) <- pc_cols_nk
      dist_rast <- app(pca_rast_nk_mask, fun = dist_fun, ref = ref_vals)
      names(dist_rast) <- paste0("dist_", plot_id)
      writeRaster(dist_rast, out_file, overwrite = T)
    }
  }
  
  # create distance stacks
  dist_stack_mm <- list.files(out_dir_mm, "*.tif$", full.names = T) |> rast()
  dist_stack_nk <- list.files(out_dir_nk, "*.tif$", full.names = T) |> rast()
  
  # write to files
  writeRaster(dist_stack_mm, out_file_mm, overwrite = T)
  writeRaster(dist_stack_nk, out_file_nk, overwrite = T)

} else {
  
  # read the existing files in
  dist_stack_mm <- rast(out_file_mm)
  dist_stack_nk <- rast(out_file_nk)
  
}

# plot the data out -- mm
plot(dist_stack_mm[[1:2]])

Show Code
# plot the data out -- nk
plot(dist_stack_nk[[1:2]])

Step 5. Extract the most similar pixel for each plot

The next step is to take each one of those 2023 unburned field plot-based distance rasters and identify the single pixel within the sampling mask that is most similar. To ensure that resulting samples are at least 100m from one another, I will run this iteratively, such that after a sample is identified, a 100m buffer around that sample is eliminated from the sampling mask of candidate pixels. While I’m at it, I might as well also block out 100m buffers around the original plots too.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "samp_pts_new.shp")
out_file_nk <- file.path(samp_dir, "nk", "samp_pts_new.shp")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){

  # block out 100m buffers around original plots
  buffs_2023_mm <- buffer(plots_2023_mm, 100)
  buffs_2023_nk <- buffer(plots_2023_nk, 100)
  dist_stack_mm <- mask(dist_stack_mm, buffs_2023_mm, inverse = T)
  dist_stack_nk <- mask(dist_stack_nk, buffs_2023_nk, inverse = T)
  
  # loop over plots and find most similar pixels -- mm
  n_mm <- nrow(unburned_mm_pc)
  for (i in 1:n_mm) {
    plot_id <- unburned_mm_pc$plot_id[i]
    dist_rast <- dist_stack_mm[[paste0("dist_", plot_id)]]
    min_dist <- global(dist_rast, "min", na.rm = T)[1,1]
    min_rast <- ifel(abs(dist_rast - min_dist) < 1e-9, 1, NA)
    min_pt <- as.points(min_rast)[1,]
    min_pt$dist <- extract(dist_rast, min_pt, ID = F)[1,1]
    min_pt$priority <- "A"
    min_pt$plot_id <- plot_id
    min_pt <- min_pt[,c("plot_id", "priority", "dist")]
    min_pt$type <- "new"
    if (i == 1){
      min_pts_mm <- min_pt
    } else {
      min_pts_mm <- rbind(min_pts_mm, min_pt)
    }
    eraser <- buffer(min_pt, 100)
    dist_stack_mm <- mask(dist_stack_mm, eraser, inverse = T)
  }
  
  # loop over plots and find most similar pixels -- nk
  n_nk <- nrow(unburned_nk_pc)
  for (i in 1:n_nk) {
    plot_id <- unburned_nk_pc$plot_id[i]
    dist_rast <- dist_stack_nk[[paste0("dist_", plot_id)]]
    min_dist <- global(dist_rast, "min", na.rm = T)[1,1]
    min_rast <- ifel(abs(dist_rast - min_dist) < 1e-9, 1, NA)
    min_pt <- as.points(min_rast)[1,]
    min_pt$dist <- extract(dist_rast, min_pt, ID = F)[1,1]
    min_pt$priority <- "A"
    min_pt$plot_id <- plot_id
    min_pt <- min_pt[,c("plot_id", "priority", "dist")]
    min_pt$type <- "new"
    if (i == 1){
      min_pts_nk <- min_pt
    } else {
      min_pts_nk <- rbind(min_pts_nk, min_pt)
    }
    eraser <- buffer(min_pt, 100)
    dist_stack_nk <- mask(dist_stack_nk, eraser, inverse = T)
  }
  
} else {
  
  # read the existing files in
  min_pts_mm <- vect(out_file_mm)
  min_pts_nk <- vect(out_file_nk)
  
}


# plot the data out -- mm
par(mfrow = c(1,2))
plot(als_mm, main = "Monroe Mountain")
plot(unburned_mm, col = 1, add = T)
plot(burned_mm, col = 2, add = T)
plot(min_pts_mm[min_pts_mm$priority == "A",], col = 3, add = T)
add_legend(
  "bottomright",
  legend = c("2023 unburned", "2023 burned", "2026 sample A"),
  pch = 16,
  col = c(1,2,3),
  cex = 2/3
)

# plot the data out -- nk
plot(als_nk, main = "North Kaibab")
plot(unburned_nk, col = 1, add = T)
plot(burned_nk, col = 2, add = T)
plot(min_pts_nk[min_pts_nk$priority == "A",], col = 3, add = T)
add_legend(
  "bottomleft",
  legend = c("2023 unburned", "2023 burned", "2026 sample A"),
  pch = 16,
  col = c(1,2,3),
  cex = 2/3
)

Step 6. Identify backup plots

Although the sampling mask was designed to facilitate access, there are many reasons why a selected plot could or should not be sampled (e.g., access constrains, edge conditions, etc.). So, it would be wise, in addition to the most similar plots determined in the previous step, to generate some backup options for each plot. Fortunately, since the approach defined above effectively punches a buffered hole in the distance-based sample rasters (thus ensuring samples aren’t placed too close together), I can just repeat the steps above to create “B” and “C” priority samples that are still, theoretically, similar to each pre-fire, unburned plot, while keeping a safe distance from already-sampled areas.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "samp_pts_new.shp")
out_file_nk <- file.path(samp_dir, "nk", "samp_pts_new.shp")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){

  # loop over plots and find most similar pixels -- mm
  n_mm <- nrow(unburned_mm_pc)
  for (priority in c("B", "C")){
    for (i in 1:n_mm) {
      plot_id <- unburned_mm_pc$plot_id[i]
      dist_rast <- dist_stack_mm[[paste0("dist_", plot_id)]]
      min_dist <- global(dist_rast, "min", na.rm = T)[1,1]
      min_rast <- ifel(abs(dist_rast - min_dist) < 1e-9, 1, NA)
      min_pt <- as.points(min_rast)[1,]
      min_pt$dist <- extract(dist_rast, min_pt, ID = F)[1,1]
      min_pt$priority <- priority
      min_pt$plot_id <- plot_id
      min_pt <- min_pt[,c("plot_id", "priority", "dist")]
      min_pt$type <- "new"
      min_pts_mm <- rbind(min_pts_mm, min_pt)
      eraser <- buffer(min_pt, 100)
      dist_stack_mm <- mask(dist_stack_mm, eraser, inverse = T)
    }
  }
  
  # loop over plots and find most similar pixels -- nk
  n_nk <- nrow(unburned_nk_pc)
  for (priority in c("B", "C")){
    for (i in 1:n_nk) {
      plot_id <- unburned_nk_pc$plot_id[i]
      dist_rast <- dist_stack_nk[[paste0("dist_", plot_id)]]
      min_dist <- global(dist_rast, "min", na.rm = T)[1,1]
      min_rast <- ifel(abs(dist_rast - min_dist) < 1e-9, 1, NA)
      min_pt <- as.points(min_rast)[1,]
      min_pt$dist <- extract(dist_rast, min_pt, ID = F)[1,1]
      min_pt$priority <- priority
      min_pt$plot_id <- plot_id
      min_pt <- min_pt[,c("plot_id", "priority", "dist")]
      min_pt$type <- "new"
      min_pts_nk <- rbind(min_pts_nk, min_pt)
      eraser <- buffer(min_pt, 100)
      dist_stack_nk <- mask(dist_stack_nk, eraser, inverse = T)
    }
  }

} else {
  
  # read the existing files in
  min_pts_mm <- vect(out_file_mm)
  min_pts_nk <- vect(out_file_nk)
  
}

# plot the data out -- mm
par(mfrow = c(1,2))
plot(als_mm, main = "Monroe Mountain")
plot(unburned_mm, col = 1, add = T)
plot(burned_mm, col = 2, add = T)
cols <- NA
cols[min_pts_mm$priority == "A"] <- 3
cols[min_pts_mm$priority == "B"] <- 4
cols[min_pts_mm$priority == "C"] <- 5
plot(min_pts_mm, col = cols, add = T)
add_legend(
  "bottomright",
  legend = c(
    "2023 unburned", 
    "2023 burned", 
    "2026 sample A", 
    "2026 sample B",
    "2026 sample C"
  ),
  pch = 16,
  col = c(1,2,3,4,5),
  cex = 2/3
)

# plot the data out -- nk
plot(als_nk, main = "North Kaibab")
plot(unburned_nk, col = 1, add = T)
plot(burned_nk, col = 2, add = T)
cols <- NA
cols[min_pts_nk$priority == "A"] <- 3
cols[min_pts_nk$priority == "B"] <- 4
cols[min_pts_nk$priority == "C"] <- 5
plot(min_pts_nk, col = cols, add = T)
add_legend(
  "bottomleft",
  legend = c(
    "2023 unburned", 
    "2023 burned", 
    "2026 sample A", 
    "2026 sample B",
    "2026 sample C"
  ),
  pch = 16,
  col = c(1,2,3,4,5),
  cex = 2/3
)

Show Code
# write to files
writeVector(min_pts_mm, out_file_mm, overwrite = T)
writeVector(min_pts_nk, file.path(samp_dir, "nk", "samp_pts_new.shp"), overwrite = T)

Theoretically, the Euclidean distance from points in the priority “A” group should be lower than (i.e., more similar to the pre-fire unburned plot than) “B” and “C”. Just for fun, let’s see if that’s true:

Show Code
# get kernel densities of euclidean distance by priority
d_a_mm <- density(min_pts_mm$dist[min_pts_mm$priority == "A"], cut = 0)
d_b_mm <- density(min_pts_mm$dist[min_pts_mm$priority == "B"], cut = 0)
d_c_mm <- density(min_pts_mm$dist[min_pts_mm$priority == "C"], cut = 0)

d_a_nk <- density(min_pts_nk$dist[min_pts_nk$priority == "A"], cut = 0)
d_b_nk <- density(min_pts_nk$dist[min_pts_nk$priority == "B"], cut = 0)
d_c_nk <- density(min_pts_nk$dist[min_pts_nk$priority == "C"], cut = 0)

# plot them out
par(mfrow = c(1,2), las = 1)
plot(
  x = range(c(d_a_mm$x, d_b_mm$x, d_c_mm$x)),
  y = range(c(d_a_mm$y, d_b_mm$y, d_c_mm$y)),
  type = "n",
  xlab = "Euclidean distance",
  ylab = "kernel density",
  main = "Monroe Mountain"
)
lines(d_a_mm, lwd = 2, col = 2)
lines(d_b_mm, lwd = 2, col = 3)
lines(d_c_mm, lwd = 2, col = 4)
legend(
  "topright",
  legend = c("A", "B", "C"),
  col = c(2,3,4),
  lwd = 2,
  bty = "n"
)

plot(
  x = range(c(d_a_nk$x, d_b_nk$x, d_c_nk$x)),
  y = range(c(d_a_nk$y, d_b_nk$y, d_c_nk$y)),
  type = "n",
  xlab = "Euclidean distance",
  ylab = "kernel density",
  main = "North Kaibab"
)
lines(d_a_nk, lwd = 2, col = 2)
lines(d_b_nk, lwd = 2, col = 3)
lines(d_c_nk, lwd = 2, col = 4)
legend(
  "topright",
  legend = c("A", "B", "C"),
  col = c(2,3,4),
  lwd = 2,
  bty = "n"
)

Step 7. Merging new and remeasure plots

The last step in creating our sample points is merging the 2023 plots that did burn (i.e., those that will be remeasured in 2026) with our new sample points.

Show Code
# define output files and see if they have already been generated
out_file_mm <- file.path(samp_dir, "mm", "samp_pts_all.shp")
out_file_nk <- file.path(samp_dir, "nk", "samp_pts_all.shp")
if (!all(file.exists(out_file_mm), file.exists(out_file_nk))){
  
  # make copy of burned plots
  min_pts_mm_burn <- burned_mm
  min_pts_nk_burn <- burned_nk
  
  # make attributes match
  min_pts_mm_burn <- min_pts_mm_burn[,"plot_id"]
  min_pts_nk_burn <- min_pts_nk_burn[,"plot_id"]
  
  min_pts_mm_burn$priority <- "A"
  min_pts_mm_burn$dist <- 0
  min_pts_mm_burn$type <- "remeasure"
  
  min_pts_nk_burn$priority <- "A"
  min_pts_nk_burn$dist <- 0
  min_pts_nk_burn$type <- "remeasure"
  
  # combine
  samp_pts_mm <- rbind(min_pts_mm, min_pts_mm_burn)
  samp_pts_nk <- rbind(min_pts_nk, min_pts_nk_burn)
  
  # write to files
  writeVector(samp_pts_mm, out_file_mm, overwrite = T)
  writeVector(samp_pts_nk, out_file_nk, overwrite = T)
  
} else {
  
  # read the existing files in
  samp_pts_mm <- vect(out_file_mm)
  samp_pts_nk <- vect(out_file_nk)
  
}

Step 8. Evaluate burn severity

Although we didn’t explicitly stratify across burn severity, it would be useful if the points captured a wide range of severities, to ensure that we’re capturing a wide-range of post-fire structure. To test this, I will get the distribution of dNBR values within each study area’s sample mask. I will compare that distribution to that of the “A”-priority field plots (including the remeasure plots).

Show Code
# clip dnbr to sample mask
dnbr_mm_clip <- crop(dnbr_mm, mask_comb_mm, mask = T)
dnbr_nk_clip <- crop(dnbr_nk, mask_comb_nk, mask = T)

# subset to just burned areas (dnbr >= 0.1)
dnbr_mm_clip <- ifel(dnbr_mm_clip >= 0.1, dnbr_mm_clip, NA)
dnbr_nk_clip <- ifel(dnbr_nk_clip >= 0.1, dnbr_nk_clip, NA)

# get kernel density distribution of full burns
dnbr_mm_vals <- values(dnbr_mm_clip, na.rm = T)
dnbr_nk_vals <- values(dnbr_nk_clip, na.rm = T)
d_dnbr_mm_full <- density(dnbr_mm_vals, cut = 0)
d_dnbr_nk_full <- density(dnbr_nk_vals, cut = 0)

# get "A"-priority sample subset
samp_pts_mm_a <- samp_pts_mm[samp_pts_mm$priority == "A",]
samp_pts_nk_a <- samp_pts_nk[samp_pts_nk$priority == "A",]

# extract dnbr values
samp_pts_mm_a <- extract(dnbr_mm, samp_pts_mm_a, bind = T, ID = F)
samp_pts_nk_a <- extract(dnbr_nk, samp_pts_nk_a, bind = T, ID = F)

# get kernel density distribution of "A"-priority sample
d_dnbr_mm_samp <- density(samp_pts_mm_a$dnbr, cut = 0)
d_dnbr_nk_samp <- density(samp_pts_nk_a$dnbr, cut = 0)

# plot them out -- mm
par(mfrow = c(1,2), las = 1)
plot(
  x = range(c(d_dnbr_mm_full$x, d_dnbr_mm_samp$x)),
  y = range(c(d_dnbr_mm_full$y, d_dnbr_mm_samp$y)),
  type = "n",
  xlab = "dNBR",
  ylab = "kernel density",
  main = "Monroe Mountain"
)
lines(d_dnbr_mm_full, lwd = 2, col = 2)
lines(d_dnbr_mm_samp, lwd = 2, col = 3)
legend(
  "topright",
  legend = c("Full Burn", "Sample Points"),
  col = c(2,3),
  lwd = 2,
  bty = "n"
)

# plot them out -- nk
plot(
  x = range(c(d_dnbr_nk_full$x, d_dnbr_nk_samp$x)),
  y = range(c(d_dnbr_nk_full$y, d_dnbr_nk_samp$y)),
  type = "n",
  xlab = "dNBR",
  ylab = "kernel density",
  main = "North Kaibab"
)
lines(d_dnbr_nk_full, lwd = 2, col = 2)
lines(d_dnbr_nk_samp, lwd = 2, col = 3)
legend(
  "topright",
  legend = c("Full Burn", "Sample Points"),
  col = c(2,3),
  lwd = 2,
  bty = "n"
)

The North Kaibab sample does a better job of capturing the range of severities than the Monroe Mountain sample. The Kolmogorov�Smirnov (K�S) test is one way to tell if there is a statistical difference between the distributions of a sample and a broader population.

Show Code
# run k-s tests
ks_mm <- ks.test(samp_pts_mm_a$dnbr, dnbr_mm_vals)
ks_nk <- ks.test(samp_pts_nk_a$dnbr, dnbr_nk_vals)

# print results
print(ks_mm)

    Asymptotic two-sample Kolmogorov-Smirnov test

data:  samp_pts_mm_a$dnbr and dnbr_mm_vals
D = 0.37615, p-value = 0.01632
alternative hypothesis: two-sided
Show Code
print(ks_nk)

    Asymptotic two-sample Kolmogorov-Smirnov test

data:  samp_pts_nk_a$dnbr and dnbr_nk_vals
D = 0.10346, p-value = 0.9664
alternative hypothesis: two-sided
  • With a p-value of 0.0163, the K-S test revealed that the Monroe Mountain sample and population are unlikely to have been drawn from the same underlying population.
  • With a p-value of 0.9664, the K-S test revealed that the North Kaibab sample and population are likely to have been drawn from the same underlying population.

So, the question becomes: are we OK with this? Or, do we want to reconsider this space-for-time substitution approach in favor of one that focuses more explicitly on stratifying by burn severity?