Evacuation workflow: function-by-function diagnostics

This vignette is a diagnostic walkthrough for the example tsunami-evacuation workflow. It is intentionally more detailed than a normal user vignette because its purpose is to test every major evacpath function independently before trusting the high-level run_evacpath() wrapper.

The workflow builds on open-source least-cost path methods for evacuation planning (Cordero et al. 2025) and uses leastcostpath for least-cost path and movement-potential modeling (Lewis 2023; Lewis 2021).

This example workflow has three important tsunami-specific complications:

  1. The land-only inundation zone is the correct area for evacuation origins and final output mapping, but it is not necessarily the correct boundary for escape-point detection.
  2. The water-combined escape zone prevents the coastline from becoming a fake safety boundary.
  3. The road-aware escape zone combines buffered roads with the escape zone before escape points are generated. This preserves bridges, causeways, and walkways over water that can be lost when the tsunami layer is split into land and water masks.

The diagnostic workflow therefore uses these separate objects:

hazard_zone          = land-only tsunami evacuation zone for origins/output
escape_zone          = land-only TEZ + water, used as base escape boundary
roads_for_escape     = roads cropped to an inset study extent
escape_boundary_zone = escape_zone + buffered roads_for_escape

To run the full diagnostic workflow locally:

rmarkdown::render(
  "vignettes/diagnostic-example.Rmd",
  params = list(
    run_diagnostics = TRUE,
    run_lcp = TRUE,
    data_dir = "data-raw",
    target_crs = "EPSG:32748",
    dem_sign_multiplier = 1,
    grid_multiplier = 50,
    dem_multiplier = 50,
    escape_roads_inset_x_m = 50,
    escape_roads_inset_y_m = 50,
    road_aware_escape_zone = TRUE
  )
)

For a faster diagnostic that stops before least-cost paths:

rmarkdown::render(
  "vignettes/diagnostic-example.Rmd",
  params = list(
    run_diagnostics = TRUE,
    run_lcp = FALSE,
    data_dir = "data-raw"
  )
)

1. Setup

# This loader works both when the package is installed and during local package development.
if (requireNamespace("evacpath", quietly = TRUE)) {
  library(evacpath)
} else if (requireNamespace("devtools", quietly = TRUE) && file.exists("DESCRIPTION")) {
  devtools::load_all(".")
} else if (requireNamespace("devtools", quietly = TRUE) && file.exists(file.path("evacpath", "DESCRIPTION"))) {
  devtools::load_all("evacpath")
} else {
  stop("Install evacpath or run this from the package root/parent directory.", call. = FALSE)
}

library(terra)
#> terra 1.9.1

run_diagnostics <- isTRUE(params$run_diagnostics)
run_lcp <- isTRUE(params$run_lcp)

assert_true <- function(x, message) {
  if (!isTRUE(x)) {
    stop(message, call. = FALSE)
  }
  invisible(TRUE)
}

is_spatvector <- function(x) inherits(x, "SpatVector")
is_spatraster <- function(x) inherits(x, "SpatRaster")

count_features <- function(x) {
  if (inherits(x, "SpatRaster")) return(terra::ncell(x))
  if (inherits(x, "SpatVector")) return(nrow(x))
  NA_integer_
}

The diagnostic switches are:

run_diagnostics
#> [1] FALSE
run_lcp
#> [1] FALSE

2. Locate example input files

The diagnostic expects these files:

data-raw/dem.tif
data-raw/rds.gpkg
data-raw/tsunami_inundation_depth.tif

The function below searches for them from the package root, the parent folder, or installed package extdata.

find_input_file <- function(filename, data_dir = params$data_dir) {
  candidate_dirs <- unique(c(
    data_dir,
    file.path("evacpath", data_dir),
    file.path(getwd(), data_dir),
    file.path(getwd(), "evacpath", data_dir),
    system.file("extdata", package = "evacpath")
  ))

  candidate_dirs <- candidate_dirs[nzchar(candidate_dirs)]
  candidates <- file.path(candidate_dirs, filename)
  hits <- candidates[file.exists(candidates)]

  if (length(hits) == 0L) {
    return(NA_character_)
  }

  normalizePath(hits[1])
}

dem_file <- find_input_file("dem.tif")
roads_file <- find_input_file("rds.gpkg")
inundation_file <- find_input_file("tsunami_inundation_depth.tif")

input_files <- data.frame(
  layer = c("DEM", "roads", "inundation depth"),
  file = c(dem_file, roads_file, inundation_file),
  exists = file.exists(c(dem_file, roads_file, inundation_file))
)

input_files
#>              layer
#> 1              DEM
#> 2            roads
#> 3 inundation depth
#>                                                                                                                                  file
#> 1                      /private/var/folders/7j/dr505g_j3zd9z6m9qdykzc4w0000gn/T/RtmprRFJQI/Rinst33ee1efff34d/evacpath/extdata/dem.tif
#> 2                     /private/var/folders/7j/dr505g_j3zd9z6m9qdykzc4w0000gn/T/RtmprRFJQI/Rinst33ee1efff34d/evacpath/extdata/rds.gpkg
#> 3 /private/var/folders/7j/dr505g_j3zd9z6m9qdykzc4w0000gn/T/RtmprRFJQI/Rinst33ee1efff34d/evacpath/extdata/tsunami_inundation_depth.tif
#>   exists
#> 1   TRUE
#> 2   TRUE
#> 3   TRUE

example_data_available <- all(input_files$exists)
example_data_available
#> [1] TRUE

If example_data_available is FALSE, check params or use the packaged files in inst/extdata/.

3. Read the spatial inputs with read_spatial()

read_spatial() accepts a file path or an existing terra object. Raster formats are read with terra::rast() and vector formats are read with terra::vect().

dem_raw <- read_spatial(dem_file)
roads_raw <- read_spatial(roads_file)
inundation_raw <- read_spatial(inundation_file)

assert_true(is_spatraster(dem_raw), "DEM was not read as a SpatRaster.")
assert_true(is_spatvector(roads_raw), "Roads were not read as a SpatVector.")
assert_true(is_spatraster(inundation_raw), "Inundation input was not read as a SpatRaster.")

list(
  dem_class = class(dem_raw),
  roads_class = class(roads_raw),
  inundation_class = class(inundation_raw)
)

3.1 Raw input diagnostics

Before modeling, inspect CRS, extent, resolution, raster value ranges, and road attributes.

cat("DEM CRS:\n", terra::crs(dem_raw), "\n\n")
cat("Roads CRS:\n", terra::crs(roads_raw), "\n\n")
cat("Inundation CRS:\n", terra::crs(inundation_raw), "\n\n")

cat("DEM resolution:\n")
print(terra::res(dem_raw))

cat("Inundation resolution:\n")
print(terra::res(inundation_raw))

cat("DEM cell count:\n")
print(terra::ncell(dem_raw))

cat("Road feature count:\n")
print(nrow(roads_raw))

cat("Road attribute names:\n")
print(names(roads_raw))

cat("DEM range:\n")
print(terra::global(dem_raw, fun = range, na.rm = TRUE))

cat("Inundation range:\n")
print(terra::global(inundation_raw, fun = range, na.rm = TRUE))
oldpar <- par(mfrow = c(1, 2), mar = c(3, 3, 3, 5), las = 1)
plot(dem_raw, main = "Raw DEM")
plot(inundation_raw, main = "Raw inundation depth")
par(oldpar)

4. Clean roads with clean_roads()

This is where region-specific road filtering belongs. In the packaged example, piers are excluded because they can create misleading road-water intersections.

road_exclude <- NULL
if (!is.null(params$road_exclude_field) && nzchar(params$road_exclude_field)) {
  road_exclude <- list(
    field = params$road_exclude_field,
    values = params$road_exclude_values
  )
}

roads_clean <- clean_roads(
  roads = roads_raw,
  exclude = road_exclude,
  target_crs = params$target_crs
)

assert_true(is_spatvector(roads_clean), "clean_roads() did not return a SpatVector.")
assert_true(nrow(roads_clean) > 0, "clean_roads() returned no roads.")

nrow(roads_raw)
nrow(roads_clean)

5. Prepare tsunami zones with prepare_tsunami_zones()

This is the critical tsunami-specific step. The old script created a land mask, water mask, land-only TEZ, and then a TEZ-plus-water layer. The package makes that separation explicit.

zones <- prepare_tsunami_zones(
  inundation = inundation_raw,
  dem = dem_raw,
  target_crs = params$target_crs,
  inundation_threshold = params$inundation_threshold,
  land_threshold = params$land_threshold,
  water_threshold = params$water_threshold,
  dem_sign_multiplier = params$dem_sign_multiplier,
  resample_method = params$resample_method,
  as_polygon = TRUE,
  dissolve = TRUE
)

zones

assert_true(is_spatvector(zones$hazard_zone), "zones$hazard_zone is not a SpatVector.")
assert_true(is_spatvector(zones$escape_zone), "zones$escape_zone is not a SpatVector.")
assert_true(is_spatraster(zones$hazard_raster), "zones$hazard_raster is not a SpatRaster.")
assert_true(is_spatraster(zones$escape_raster), "zones$escape_raster is not a SpatRaster.")

cat("Land-only hazard-zone polygons:", nrow(zones$hazard_zone), "\n")
cat("Water-combined escape-zone polygons:", nrow(zones$escape_zone), "\n")

5.1 Check the land/water classification

If land and water are reversed, change dem_sign_multiplier from 1 to -1 or adjust the thresholds.

oldpar <- par(mfrow = c(1, 2), mar = c(3, 3, 3, 5), las = 1)
plot(zones$land_mask, col = "grey85", legend = FALSE, main = "DEM-derived land mask")
plot(zones$water_mask, col = "lightskyblue1", legend = FALSE, main = "DEM-derived water mask")
par(oldpar)

5.2 Check the two tsunami zones

The land-only zone is for origins and final mapped evacuation time. The water-combined zone is the base zone for escape-point detection.

oldpar <- par(mfrow = c(1, 2), mar = c(3, 3, 3, 5), las = 1)
plot(zones$hazard_zone, col = "tomato", border = NA, main = "hazard_zone: land-only TEZ")
plot(zones$escape_zone, col = "grey90", border = NA, main = "escape_zone: TEZ + water")
plot(zones$hazard_zone, add = TRUE, col = "tomato", border = NA)
par(oldpar)

6. Crop roads to an inner extent before escape-point detection

If the road dataset extends beyond the reliable hazard-zone coverage, roads can intersect the artificial rectangular study-area boundary. Those intersections are not real escape points. The fix is to crop the roads used for escape-point detection to a slightly inset extent. The x and y insets are controlled separately.

roads_for_escape <- crop_roads_to_inner_extent(
  roads = roads_clean,
  zone = zones$escape_zone,
  inset_x_m = params$escape_roads_inset_x_m,
  inset_y_m = params$escape_roads_inset_y_m
)

assert_true(is_spatvector(roads_for_escape), "roads_for_escape is not a SpatVector.")
assert_true(nrow(roads_for_escape) > 0, "roads_for_escape has no features.")

cat("Clean roads:", nrow(roads_clean), "\n")
cat("Roads used for escape-point detection:", nrow(roads_for_escape), "\n")
plot(zones$escape_zone, col = "grey92", border = "grey60", main = "Roads cropped to inset extent for escape detection")
plot(roads_clean, add = TRUE, col = adjustcolor("red", alpha.f = 0.25), lwd = 0.35)
plot(roads_for_escape, add = TRUE, col = "black", lwd = 0.45)
legend(
  "topright",
  legend = c("Original cleaned roads", "Roads used for escape detection"),
  col = c(adjustcolor("red", alpha.f = 0.5), "black"),
  lty = 1,
  lwd = 1,
  bty = "n",
  cex = 0.8
)

7. Combine buffered roads with the escape zone

This is the second major correction. The buffered roads should be combined with the tsunami escape zone before escape points are generated. Otherwise, bridge, causeway, or walkway segments over water can be lost because the land and water masks do not always represent the accessible transportation surface.

The package helper is make_road_aware_escape_zone().

escape_boundary_zone <- make_road_aware_escape_zone(
  escape_zone = zones$escape_zone,
  roads = roads_for_escape,
  road_buffer_m = params$road_buffer_m,
  crop_buffer_m = params$final_road_buffer_m,
  include_base_zone = TRUE
)

assert_true(is_spatvector(escape_boundary_zone), "escape_boundary_zone is not a SpatVector.")
assert_true(nrow(escape_boundary_zone) > 0, "escape_boundary_zone has no features.")

nrow(escape_boundary_zone)
plot(escape_boundary_zone, col = "grey90", border = NA, main = "Road-aware escape-boundary zone")
plot(zones$hazard_zone, add = TRUE, col = adjustcolor("tomato", alpha.f = 0.45), border = NA)
plot(roads_for_escape, add = TRUE, col = adjustcolor("grey20", alpha.f = 0.45), lwd = 0.35)

8. Compare escape-point methods

This section intentionally compares three methods:

  1. Wrong: land-only hazard_zone.
  2. Better: TEZ + water escape_zone.
  3. Best for this workflow: road-aware escape boundary plus inset-cropped roads.
escape_wrong <- try(
  find_escape_points(
    hazard_zone = zones$hazard_zone,
    roads = roads_clean
  ),
  silent = TRUE
)

escape_water <- find_escape_points(
  hazard_zone = zones$escape_zone,
  roads = roads_for_escape
)

escape_points <- find_escape_points(
  hazard_zone = escape_boundary_zone,
  roads = roads_for_escape
)

comparison <- data.frame(
  method = c(
    "wrong: land-only hazard zone",
    "better: TEZ + water, inset roads",
    "preferred: road-aware escape zone, inset roads"
  ),
  escape_point_count = c(
    if (inherits(escape_wrong, "try-error")) NA_integer_ else nrow(escape_wrong),
    nrow(escape_water),
    nrow(escape_points)
  )
)
comparison

assert_true(nrow(escape_points) > 0, "Preferred escape-point calculation returned no points.")
oldpar <- par(mfrow = c(1, 3))

plot(zones$hazard_zone, col = "grey90", border = "grey40", main = "Wrong\nland-only boundary")
plot(roads_clean, add = TRUE, col = adjustcolor("grey20", alpha.f = 0.35), lwd = 0.35)
if (!inherits(escape_wrong, "try-error")) {
  plot(escape_wrong, add = TRUE, pch = 22, bg = "red", col = "black", cex = 0.45)
}

plot(zones$escape_zone, col = "grey90", border = "grey40", main = "Better\nTEZ + water")
plot(roads_for_escape, add = TRUE, col = adjustcolor("grey20", alpha.f = 0.35), lwd = 0.35)
plot(escape_water, add = TRUE, pch = 22, bg = "red", col = "black", cex = 0.45)

plot(escape_boundary_zone, col = "grey90", border = "grey40", main = "Preferred\nroad-aware boundary")
plot(roads_for_escape, add = TRUE, col = adjustcolor("grey20", alpha.f = 0.35), lwd = 0.35)
plot(escape_points, add = TRUE, pch = 22, bg = "red", col = "black", cex = 0.45)

par(oldpar)

From this point forward, the diagnostic uses:

hazard_zone          = zones$hazard_zone
escape_zone          = zones$escape_zone
roads_for_escape     = inset-cropped roads
escape_boundary_zone = road-aware TEZ + water boundary
escape_points        = preferred escape points
hazard_zone <- zones$hazard_zone
escape_zone <- zones$escape_zone
dem <- zones$dem
roads <- roads_clean

9. Prepare core inputs with prepare_evac_inputs()

prepare_evac_inputs() reads, cleans, projects, and converts the land-only hazard_zone into a polygon if needed. It does not create the road-aware escape boundary; that is handled separately above.

inputs <- prepare_evac_inputs(
  hazard_zone = hazard_zone,
  roads = roads,
  dem = dem,
  target_crs = params$target_crs,
  hazard_as_polygon = TRUE,
  dissolve_hazard = TRUE,
  road_exclude = NULL
)

hazard_zone <- inputs$hazard_zone
roads <- inputs$roads
dem <- inputs$dem

assert_true(is_spatvector(hazard_zone), "Prepared hazard_zone is not a SpatVector.")
assert_true(is_spatvector(roads), "Prepared roads are not a SpatVector.")
assert_true(is_spatraster(dem), "Prepared DEM is not a SpatRaster.")

10. Create the evacuation grid with make_evac_grid()

The evacuation grid should be created from the land-only hazard_zone, not the water-combined or road-aware escape zone.

grid_resolution <- terra::res(dem) * params$grid_multiplier

evac_grid <- make_evac_grid(
  hazard_zone = hazard_zone,
  resolution = grid_resolution
)

assert_true(is_spatvector(evac_grid), "make_evac_grid() did not return a SpatVector.")
assert_true(nrow(evac_grid) > 0, "make_evac_grid() returned no grid cells.")

cat("Grid resolution:", paste(grid_resolution, collapse = " x "), "\n")
cat("Grid cells:", nrow(evac_grid), "\n")
plot(hazard_zone, col = "grey95", border = "grey50", main = "Evacuation grid over land-only TEZ")
plot(evac_grid, add = TRUE, border = adjustcolor("black", alpha.f = 0.25), col = NA)
plot(roads, add = TRUE, col = adjustcolor("grey20", alpha.f = 0.45), lwd = 0.35)

11. Create the road mask with make_road_mask()

make_road_mask() buffers the roads and preferred escape points, then combines them. The mask constrains least-cost movement to the road/pathway network while still allowing access around escape points.

road_mask_parts <- make_road_mask(
  roads = roads,
  escape_points = escape_points,
  road_buffer_m = params$road_buffer_m,
  escape_buffer_m = params$escape_buffer_m,
  return_components = TRUE
)

road_mask <- road_mask_parts$mask
roads_buffer <- road_mask_parts$roads_buffer
escape_buffer <- road_mask_parts$escape_buffer

assert_true(is_spatvector(road_mask), "Road mask is not a SpatVector.")
assert_true(is_spatvector(roads_buffer), "roads_buffer is not a SpatVector.")
assert_true(is_spatvector(escape_buffer), "escape_buffer is not a SpatVector.")
plot(hazard_zone, col = "grey95", border = "grey60", main = "Road-constrained movement mask")
plot(road_mask, add = TRUE, col = adjustcolor("grey30", alpha.f = 0.35), border = NA)
plot(escape_points, add = TRUE, pch = 22, bg = "red", col = "black", cex = 0.45)

12. Create road origin points with make_road_origins()

Origins should be road-based points inside the land-only inundation zone.

road_points <- make_road_origins(
  evac_grid = evac_grid,
  roads_buffer = roads_buffer,
  hazard_zone = hazard_zone,
  max_origins = params$max_origins,
  seed = params$seed
)

assert_true(is_spatvector(road_points), "make_road_origins() did not return a SpatVector.")
assert_true(nrow(road_points) > 0, "make_road_origins() returned no origins.")

cat("Road origin points retained:", nrow(road_points), "\n")
plot(hazard_zone, col = "grey95", border = "grey60", main = "Road origin points inside land-only TEZ")
plot(roads, add = TRUE, col = adjustcolor("grey20", alpha.f = 0.35), lwd = 0.35)
plot(road_points, add = TRUE, pch = 21, bg = "dodgerblue", col = "black", cex = 0.45)
plot(escape_points, add = TRUE, pch = 22, bg = "red", col = "black", cex = 0.45)

13. Create the conductance surface with make_conductance_surface()

The conductance surface is built from the DEM but masked to the road-constrained movement area. This is the expensive part. dem_resolution can be coarsened for testing.

dem_resolution <- terra::res(dem) * params$dem_multiplier

conductance <- make_conductance_surface(
  dem = dem,
  road_mask = road_mask,
  resolution = dem_resolution
)

cs_raster <- leastcostpath::rasterise(conductance)

assert_true(is_spatraster(cs_raster), "Conductance rasterization failed.")

cat("Conductance raster resolution:", paste(terra::res(cs_raster), collapse = " x "), "\n")
cat("Conductance raster cells:", terra::ncell(cs_raster), "\n")
plot(cs_raster, main = "Road-constrained conductance surface")
plot(hazard_zone, add = TRUE, border = "black", col = NA)
plot(roads, add = TRUE, col = adjustcolor("grey20", alpha.f = 1), lwd = 0.35)
plot(escape_points, add = TRUE, pch = 22, bg = "red", col = "black", cex = 0.45)

14. Calculate least-cost distance with calc_min_distance_to_safety()

This section is optional because it can be slow for large study areas. The packaged example area is small, so the default parameters use all available origins and destinations.

escape_points_ltd <- escape_points
if (!is.null(params$max_destinations) && params$max_destinations < nrow(escape_points_ltd)) {
  set.seed(params$seed)
  escape_points_ltd <- escape_points_ltd[sort(sample.int(nrow(escape_points_ltd), params$max_destinations)), ]
}

cat("Escape points available:", nrow(escape_points), "\n")
cat("Escape points used in diagnostic LCP:", nrow(escape_points_ltd), "\n")
distance_points <- calc_min_distance_to_safety(
  cs = conductance,
  origins = road_points,
  destinations = escape_points_ltd,
  include_destinations = TRUE,
  progress = TRUE,
  progress_every = 500,
  check_locations = FALSE
)

assert_true(is_spatvector(distance_points), "calc_min_distance_to_safety() did not return a SpatVector.")
assert_true("distance" %in% names(distance_points), "distance_points does not contain a distance field.")

summary(distance_points$distance)
table(distance_points$type)
plot(hazard_zone, col = "grey95", border = "grey60", main = "Minimum least-cost distance points")
plot(roads, add = TRUE, col = adjustcolor("grey20", alpha.f = 0.30), lwd = 0.35)
plot(distance_points, "distance", add = TRUE)

15. Convert distance to time with calc_evac_time()

calc_evac_time() converts distance to time using a walking speed. The default paper-style value is 1.22 m/s, but local planning scenarios should modify it.

example_distances <- c(0, 100, 500, 1000, 2000)
data.frame(
  distance_m = example_distances,
  time_min = calc_evac_time(
    distance_m = example_distances,
    walking_speed_mps = params$walking_speed_mps,
    units = "minutes"
  )
)

16. Create evacuation polygons with make_evac_polygons()

make_evac_polygons() converts the distance points to Voronoi polygons, calculates evacuation time, and clips the result to the selected output area. For the broad time grid, the clip area should be the land-only hazard_zone.

evac_polygons <- make_evac_polygons(
  distance_points = distance_points,
  clip_area = hazard_zone,
  walking_speed_mps = params$walking_speed_mps,
  region_name = params$region_name
)

assert_true(is_spatvector(evac_polygons), "make_evac_polygons() did not return a SpatVector.")
assert_true("EvacTimeAvg" %in% names(evac_polygons), "EvacTimeAvg field is missing.")

summary(evac_polygons$EvacTimeAvg)
plot(evac_polygons, "EvacTimeAvg", border = NA, main = "Evacuation-time polygons")
plot(roads, add = TRUE, col = adjustcolor("grey20", alpha.f = 0.30), lwd = 0.35)
plot(escape_points_ltd, add = TRUE, pch = 22, bg = "red", col = "black", cex = 0.45)

17. Run the high-level wrapper with run_evacpath()

After the individual steps work, the same logic should run through run_evacpath(). The key tsunami-specific arguments are:

hazard_zone = zones$hazard_zone
escape_zone = zones$escape_zone
escape_roads_inset_x_m = 50
escape_roads_inset_y_m = 50
road_aware_escape_zone = TRUE

18. Final map check

This map uses land/water as background, the land-only evacuation-time polygons as the main layer, and the preferred escape points as squares.

evac_map <- result$time_grid
roads_plot <- crop(result$roads, evac_map)
escape_plot <- crop(result$escape_points, evac_map)

dem_bg <- result$dem
if (!same.crs(dem_bg, evac_map)) {
  dem_bg <- project(dem_bg, crs(evac_map))
}

e <- ext(evac_map)
xpad <- (xmax(e) - xmin(e)) * 0.06
ypad <- (ymax(e) - ymin(e)) * 0.06
e2 <- ext(xmin(e) - xpad, xmax(e) + xpad, ymin(e) - ypad, ymax(e) + ypad)

dem_bg <- crop(dem_bg, e2)
land_water <- ifel(dem_bg > params$land_threshold, 2, 1)
names(land_water) <- "land_water"

bg_cols <- c(
  adjustcolor("lightskyblue1", alpha.f = 0.65),
  adjustcolor("grey92", alpha.f = 1.00)
)
time_cols <- hcl.colors(100, "YlOrRd", rev = TRUE)

oldpar <- par(mar = c(4, 4, 3, 5), mgp = c(2.2, 0.7, 0), las = 1, bg = "white", xpd = FALSE)

plot(
  land_water,
  ext = e2,
  col = bg_cols,
  legend = FALSE,
  axes = TRUE,
  box = FALSE,
  main = "Modeled Pedestrian Evacuation Time",
  cex.main = 1.1
)

plot(
  evac_map,
  "EvacTimeAvg",
  add = TRUE,
  col = time_cols,
  border = NA,
  plg = list(title = "Minutes", cex = 0.75, title.cex = 0.85, x = "right")
)

plot(roads_plot, add = TRUE, col = adjustcolor("grey25", alpha.f = 0.30), lwd = 0.30)
plot(escape_plot, add = TRUE, pch = 22, bg = adjustcolor("red", alpha.f = 0.75), col = "black", cex = 0.38, lwd = 0.35)

legend(
  "topright",
  legend = c("Escape / safety points", "Roads", "Land", "Water"),
  pch = c(22, NA, 15, 15),
  pt.bg = c("red", NA, "grey92", "lightskyblue1"),
  col = c("black", adjustcolor("grey25", alpha.f = 0.5), "grey92", "lightskyblue1"),
  lty = c(NA, 1, NA, NA),
  lwd = c(NA, 1, NA, NA),
  pt.cex = c(0.8, NA, 1.1, 1.1),
  bty = "n",
  cex = 0.7,
  inset = 0.02
)
par(oldpar)

19. Write outputs with write_evac_outputs()

write_evac_outputs() writes the major spatial outputs from a completed result object. This should only run after the diagnostic workflow succeeds.

write_evac_outputs(
  result,
  output_dir = params$output_dir,
  prefix = "example"
)

list.files(params$output_dir, full.names = TRUE)

20. Troubleshooting checklist

If escape points look wrong, check these first:

  1. Did you create both hazard_zone and escape_zone with prepare_tsunami_zones()?
  2. Did you crop the roads used for escape detection with crop_roads_to_inner_extent()?
  3. Did you combine buffered roads with the escape zone using make_road_aware_escape_zone()?
  4. Did you use the road-aware boundary for find_escape_points()?
  5. Does the DEM-derived land/water mask look correct?
  6. If land/water are reversed, did you set dem_sign_multiplier = -1?
  7. Are piers, ferry routes, or non-road features still present in the road layer?
  8. Is the target CRS projected in meters?
  9. Are you using dem_resolution to coarsen the conductance raster for testing, not only grid_resolution?

The expected tsunami pattern is:

zones <- prepare_tsunami_zones(inundation, dem, target_crs = target_crs)

roads_for_escape <- crop_roads_to_inner_extent(
  roads = roads,
  zone = zones$escape_zone,
  inset_x_m = 50,
  inset_y_m = 50
)

escape_boundary_zone <- make_road_aware_escape_zone(
  escape_zone = zones$escape_zone,
  roads = roads_for_escape,
  road_buffer_m = 2,
  crop_buffer_m = 3
)

escape_points <- find_escape_points(
  hazard_zone = escape_boundary_zone,
  roads = roads_for_escape
)

result <- run_evacpath(
  hazard_zone = zones$hazard_zone,
  escape_zone = zones$escape_zone,
  roads = roads,
  dem = zones$dem,
  target_crs = target_crs,
  escape_roads_inset_x_m = 50,
  escape_roads_inset_y_m = 50,
  road_aware_escape_zone = TRUE
)

References

Cordero, E., Ruiz Vélez, R., Huérfano Moreno, V., Sherman, C., 2025. Enhancing tsunami evacuation strategies in Puerto Rico using open-source least-cost path analysis. J. Disaster Sci. Manag. 1, 18. https://doi.org/10.1007/s44367-025-00018-y

Lewis, J., 2023. leastcostpath: Modelling Pathways and Movement Potential Within a Landscape.

Lewis, J., 2021. Probabilistic Modelling for Incorporating Uncertainty in Least Cost Path Results: a Postdictive Roman Road Case Study. Journal of Archaeological Method and Theory 28, 911-924. https://doi.org/10.1007/s10816-021-09522-w