library(here)
library(stringr)
library(ggplot2)
library(ggnewscale)
library(whitebox)
source(str_glue("{here()}/scripts/raster-to-graph-functions.R"))
<- str_glue("{here()}/_data/testing")
base_folder <- str_glue("{base_folder}/input")
input_folder <- str_glue("{base_folder}/output")
output_folder <- "dry-valleys-10m"
basename <- str_glue("{input_folder}/{basename}.tif")
dem_file <- str_glue("{input_folder}/{basename}-imagery.tif")
imagery_file <- str_glue("{input_folder}/{basename}-cover-costs.gpkg")
landcover_file <- str_glue("{input_folder}/{basename}-extent.gpkg")
extent_file <- str_glue("{input_folder}/{basename}-origins.gpkg") origins_file
Building a hiking network
Also a work in progress…
Date | Changes |
---|---|
2024-09-26 | Added an executive summary. |
2024-08-06 | Initial post. |
TL;DR Executive summary
The key findings are:
- Essential steps in building a hiking network are set out and remain valid.
- The interpolation step in Making an estimated travel time map is overkill: it is sufficient for mapping purposes to display a point map of the graph vertices.
- An additional necessary step became apparent in later work where very steep edges remain in the networks. These may not matter much in practice as shortest path network approaches are unlikely to highlight them as plausible routes (some very steep edges end up with multi-hour traversal time estimates!). However, it makes sense to remove such edges completely from consideration, and if that makes some regions completely inaccessible this is probably a more realistic outcome.
This notebook explores creation of hiking networks across a terrain where movement rates may depend on the direction of movement (because going uphill is different than going downhill…).
First we set up some folders and filenames.
Now read in the DEM and an extent, and imagery (which we might not use…).
<- rast(dem_file)
terrain # and for visualization
<- get_hillshade(terrain)
shade <- rast(imagery_file)
imagery <- st_read(extent_file)
extent
# make a focal area for convenience of plotting in some situations
<- extent |>
centre_of_extent st_centroid() # we use this later...
<- centre_of_extent |>
c_vec st_coordinates() |>
as.vector()
<- ((extent$geom[1] - c_vec) * diag(0.025, 2, 2) + c_vec) |>
focal_area st_sfc(crs = st_crs(extent)) |>
st_as_sf() |>
rename(geom = x)
Setup a resolution for use in building networks (i.e. determining which cells are adjacent to one another in vector GIS space). Although we have a DEM at 10m resolution, at least for now we’ll use a coarser grain for construction of the network. It is in any case unclear what an appropriate resolution might be given the various approximations in play.
# assume square pixels: 50m on a side
<- res(terrain)[1] * 5
resolution # calculate hexagon spacing of equivalent area
<- 2 * resolution / sqrt(2 * sqrt(3))
hex_cell_spacing
<- str_glue("{input_folder}/{basename}-hex-grid-{resolution}.gpkg")
hexgrid_file <- str_glue("{output_folder}/{basename}-graph-hex-{resolution}.txt") graph_file
hex_resolution
is a hexagon spacing for use in st_make_grid
that yields the same area cell as square cells with edge length given by resolution
Making a hex grid across the terrain
We build the network based on hex spaced points, not a square grid. This is helpful in making all edges the same length, as opposed to in a square grid where diagonal neighbour cell centres are further apart than edge neighbouring cells.
We load a hex grid file if one has already been made, otherwise build it.
IMPORTANT NOTE: Don’t forget to remake this grid if changing any of the code prior to this point in the workflow in ways that affect the spacing of the hex grid.
if (file.exists(hexgrid_file)) {
<- st_read(hexgrid_file)
xy else {
} <- extent |>
xy st_make_grid(cellsize = hex_cell_spacing, what = "centers", square = FALSE) |>
st_as_sf() |>
st_set_crs(st_crs(extent))
|>
xy st_write(str_glue(hexgrid_file), delete_dsn = TRUE)
}
Make a plot to make sure things are the right scale, in particular that the hexes we get are the same area as the simple square grid cells would be (this isn’t critical, but it’s nice as a standardisation of what ‘resolution’ means for a hex grid).
<- focal_area |>
square_grid st_make_grid(cellsize = resolution)
ggplot(square_grid) +
geom_sf(colour = "white") +
geom_sf(data = xy |> st_filter(focal_area), colour = "red", size = 3) +
theme_void()
By visual inspection these are similar. We can confirm that the hexagon area in a grid at this spacing is
|>
focal_area st_make_grid(cellsize = hex_cell_spacing, square = FALSE) |>
head(1) |>
st_area()
2500 [m^2]
which is the same as the square grid
|> head(1) |> st_area() square_grid
2500 [m^2]
Retrieve elevations from the terrain
Although we do the modelling of hiking functions using elevations in the GPS data, we don’t have complete elevation data for the area on that basis, so we retrieve these from the DEM.
<- terrain |>
z ::extract(xy |> as("SpatVector"), method = "bilinear")
terra# make pts and remove any NAs that might result from interpolation
# NAs are around the edges of the study area
<- xy |>
pts mutate(z = z[, 2]) |>
filter(!is.na(z))
Again a map is helpful, although again we use only the focal area so we can see what’s going on—and for speed of plotting.
ggplot(pts |> st_filter(focal_area)) +
geom_sf(aes(colour = z), size = 20) +
scale_colour_distiller(palette = "Oranges") +
theme_void()
Make a graph (i.e. network) connecting these elevation points
The graph_from_points
function in raster-to-graph-functions.R
creates an igraph::graph
object from an (x, y) point set. IMPORTANT NOTE: Don’t forget to remake the graph if messing with any of the code above here that might affect the spacing of the hex grid.
if (file.exists(graph_file)) {
<- read_graph(graph_file)
G else {
} <- graph_from_points(pts, hex_cell_spacing * 1.1)
G write_graph(G, graph_file)
}
Before we can visualize this easily, we have to attach various data to its elements (at the moment it is just a set of vertices without any attributes). The required data include a landcover layer with relative impedances. Note that for now this cover layer is entirely made up – pending consultation with someone more knowledgeable about conditions on the ground. As far as I can tell from imagery the high cost areas are ‘ice’ which I’ve arbitrarily assigned a relative cost of 5.
<- st_read(landcover_file)
cover
ggplot() +
geom_sf(data = cover, aes(fill = as.factor(cost)), linewidth = 0) +
scale_fill_manual(values = c("white", "red"), name = "Relative cost") +
# some ggnewscale shenanigans required to plot a second layer with fill aesthetic
new_scale_fill() +
geom_raster(data = shade |> as.data.frame(xy = TRUE),
aes(x = x, y = y, fill = hillshade), alpha = 0.5) +
scale_fill_distiller(palette = "Greys", direction = 1) +
guides(fill = "none") +
theme_void()
Next add attributes to the graph using the assignment_movement_variables_to_graph
function in raster-to-graph-functions.R
<- extract_xyz_from_points(pts)
xyz
<- pts |>
costs st_join(cover, .predicate = st_within) |>
st_drop_geometry() |>
select(cost)
# note... for now this uses a default Tobler hiking function
<- assign_movement_variables_to_graph(G, xyz, impedances = costs$cost) G
Now we have a graph with coordinates assigned to its vertices, we can map it (after transforming to a sf
dataset)
ggplot(G |> get_graph_as_line_layer() |>
st_filter(focal_area)) +
geom_sf(aes(colour = cost)) +
theme_void()
It’s not clear above that this is a bidirectional network, but we can see by inspection, e.g. the first edge in the graph is between vertices 1 and 166. We can access these (in igraph
’s rather abstruse way), as follows:
|>
G edge_attr(index = get.edge.ids(G, c(1, 166, 166, 1))) |>
as_tibble()
length_xyz | length_xy | z_diff | gradient | impedance | tobler_speed | cost |
---|---|---|---|---|---|---|
53.72997 | 53.7285 | -0.3975143 | -0.0073986 | 1 | 3101.323 | 0.0173249 |
53.72997 | 53.7285 | 0.3975143 | 0.0073986 | 1 | 2944.794 | 0.0182457 |
The lengths of the edges are identical, their height differences and gradients are opposite and the speed (metres per hour) and cost (time to traverse in hours) are different due to the slope.
Make an estimated travel time map
Using the graph representation we can estimate travel times from any start location to every other location. For the sake of illustration we’ll make such a map from the graph vertex closest to the centre of the study area.
<- centre_of_extent |>
origin_i st_nearest_feature(pts)
V(G)$time_hrs <- distances(G, v = c(V(G)[origin_i]), weights = edge_attr(G, "cost")) |> t()
# assemble results into a DF
<- data.frame(x = V(G)$x, y = V(G)$y, z = V(G)$z,
df time_hrs = V(G)$time_hrs,
cost = costs$cost)
# write this out to a shapefile, which Whitebox Tools needs
<- str_glue("{here()}/_temp/{basename}-{format(origin_i, scientific = FALSE)}-hex.shp")
shp_fname |> st_as_sf(coords = c("x", "y"), crs = crs(extent)) |>
df st_write(shp_fname, delete_dsn = TRUE)
# note that interpolation is required because we are using hexagonal grid cells
# do interpolation using Sibley's natural neighbours since we have a regular
# and relatively dense array and save it to a tif
<- str_glue("{here()}/_temp/travel_time.tif")
tif_fname wbt_natural_neighbour_interpolation(shp_fname, field = "time_hrs",
output = tif_fname,
base = str_glue(dem_file))
Reading the exported raster layer made by whitebox::wbt_natural_neighbour_interpolation
back in we get a map of estimated travel times.
rast(tif_fname) |>
as.data.frame(xy = TRUE) |>
ggplot(aes(x = x, y = y)) +
geom_raster(aes(fill = travel_time)) +
scale_fill_distiller(palette = "Spectral", name = "Est. time hrs") +
geom_contour(aes(z = travel_time), linewidth = 0.5) +
coord_equal() +
theme_void()
In practice we’ll use other graph functionalities than one origin to all destination shortest paths, but this gives the general idea.