This vignette walks through the full flooded pipeline on
a section of Neexdzii Kwah (Bulkley River) near Topley, BC. The test
data covers ~8 km of mainstem plus tributaries (Richfield Creek, Cesford
Creek, Robert Hatch Creek) at 10 m resolution.
Load data
library(flooded)
library(terra)
#> terra 1.8.93
library(sf)
#> Linking to GEOS 3.12.1, GDAL 3.8.4, PROJ 9.4.0; sf_use_s2() is TRUE
dem <- rast(system.file("testdata/dem.tif", package = "flooded"))
slope <- rast(system.file("testdata/slope.tif", package = "flooded"))
streams <- st_read(
system.file("testdata/streams.gpkg", package = "flooded"),
quiet = TRUE
)
cat("DEM:", ncol(dem), "x", nrow(dem), "pixels at", res(dem)[1], "m\n")
#> DEM: 800 x 648 pixels at 10 m
cat("Streams:", nrow(streams), "segments\n")
#> Streams: 50 segments
cat("Upstream area range:", range(streams$upstream_area_ha), "ha\n")
#> Upstream area range: 1928.765 110337.4 ha
cat("Mean annual precip range:", range(streams$map_upstream), "mm\n")
#> Mean annual precip range: 526 587 mm
plot(dem, main = "Elevation (m)")
plot(st_geometry(streams), add = TRUE, col = "blue", lwd = 1.5)
DEM with stream network overlay.
Step 1: Rasterize streams
Burn the stream network onto the DEM grid (Figure @ref(fig:plot-dem)) using upstream contributing area as the cell value (Figure @ref(fig:plot-streams)). This is what the VCA bankfull regression expects — it estimates flood depth from contributing area in hectares.
stream_r <- fl_stream_rasterize(streams, dem, field = "upstream_area_ha")
plot(stream_r, main = "Upstream area (ha)")
Rasterized streams coloured by upstream area (ha).
Step 2: Precipitation
The VCA bankfull regression has a precipitation term that strongly controls flood depth:
bankfull_width = (upstream_area ^ 0.280) * 0.196 * (precip ^ 0.355)
bankfull_depth = bankfull_width ^ 0.607 * 0.145
flood_depth = bankfull_depth * flood_factor
With precip = 1 (the default), the precipitation term
drops out and flood depths are dramatically underestimated. For the
Bulkley mainstem (upstream_area_ha ~ 110,000), the
difference is ~2 m vs ~8 m flood depth.
The test data includes map_upstream — mean annual
precipitation (mm) from fwapg/ClimateBC, carried as a stream attribute.
We rasterize it alongside the streams to create a spatially varying
precipitation surface at stream cells.
precip_r <- fl_stream_rasterize(streams, dem, field = "map_upstream")
cat("Precip at stream cells:", range(values(precip_r), na.rm = TRUE), "mm\n")
#> Precip at stream cells: 526 587 mmStep 3: Individual mask components
The VCA combines four spatial criteria. Let’s inspect each one.
Slope mask
Cells with slope <= 9% (the VCA default) represent potentially flat valley floor (Figure @ref(fig:plot-slope-mask)).
mask_slope <- fl_mask(slope, threshold = 9, operator = "<=")
cat("Gentle slope:", sum(values(mask_slope) == 1, na.rm = TRUE), "cells\n")
#> Gentle slope: 280449 cells
plot(mask_slope, col = c("grey90", "darkgreen"), main = "Slope <= 9%",
legend = FALSE)
plot(st_geometry(streams), add = TRUE, col = "blue", lwd = 0.8)
Slope mask: green = slope <= 9%.
Distance mask
Cells within 1000 m of a stream (half the default
max_width = 2000; Figure @ref(fig:plot-dist-mask)).
mask_dist <- fl_mask_distance(stream_r, threshold = 1000)
cat("Within 1 km:", sum(values(mask_dist) == 1, na.rm = TRUE), "cells\n")
#> Within 1 km: 288888 cells
plot(mask_dist, col = c("grey90", "steelblue"), main = "Within 1 km of stream",
legend = FALSE)
plot(st_geometry(streams), add = TRUE, col = "blue", lwd = 0.8)
Distance mask: corridor within 1 km of streams.
Cost distance
Accumulated cost (slope-weighted) distance from streams (Figure @ref(fig:plot-cost)). The VCA default threshold is 2500.
cost <- fl_cost_distance(slope, stream_r)
mask_cost <- fl_mask(cost, threshold = 2500, operator = "<")
cat("Cost < 2500:", sum(values(mask_cost) == 1, na.rm = TRUE), "cells\n")
#> Cost < 2500: 113534 cells
plot(cost, main = "Cost distance", range = c(0, 5000))
plot(st_geometry(streams), add = TRUE, col = "blue", lwd = 0.8)
Cost distance from streams. Warm colours = high cost.
Flood model
The bankfull regression estimates flood depth from upstream contributing area and precipitation, interpolates the water surface outward from streams, and identifies cells below the flood surface (Figure @ref(fig:plot-flood)).
flood <- fl_flood_model(dem, stream_r, flood_factor = 6, precip = precip_r,
max_width = 2000)
cat("Flooded cells:", sum(values(flood[["flooded"]]) == 1, na.rm = TRUE), "\n")
#> Flooded cells: 62943
depth <- flood[["flood_depth"]]
depth[depth == 0] <- NA
plot(depth, main = "Flood depth (m)")
plot(st_geometry(streams), add = TRUE, col = "blue", lwd = 0.8)
Flood model: depth above terrain (m).
Step 4: Full VCA pipeline
fl_valley_confine() chains all the above (slope +
distance + cost + flood masks), applies morphological cleanup (closing,
hole filling, small patch removal, majority filter), and returns a
binary valley raster (Figure @ref(fig:plot-valleys)).
Note the precip argument — without it, flood depths are
~4x too shallow and the resulting valley is significantly narrower.
valleys <- fl_valley_confine(
dem, streams,
field = "upstream_area_ha",
slope_threshold = 9,
max_width = 2000,
cost_threshold = 2500,
flood_factor = 6,
precip = precip_r
)
n_valley <- sum(values(valleys) == 1, na.rm = TRUE)
cat("Valley cells:", n_valley, "/", ncell(valleys),
"(", round(100 * n_valley / ncell(valleys), 1), "%)\n")
#> Valley cells: 55420 / 518400 ( 10.7 %)
plot(valleys, col = c("grey90", "darkgreen"),
main = "Unconfined valleys", legend = FALSE)
plot(st_geometry(streams), add = TRUE, col = "blue", lwd = 1.2)
Unconfined valleys (green) with stream network.
Step 5: Post-processing
Connect to streams
Keep only valley patches that touch a stream cell — removes isolated flat areas disconnected from the network (Figure @ref(fig:plot-connected)).
connected <- fl_patch_conn(valleys, stream_r)
cat("Connected valley cells:",
sum(values(connected) == 1, na.rm = TRUE), "\n")
#> Connected valley cells: 54487
plot(connected, col = c("grey90", "darkgreen"),
main = "Connected valleys", legend = FALSE)
plot(st_geometry(streams), add = TRUE, col = "blue", lwd = 1.2)
Valley patches connected to streams.
Trim with exclusion masks
fl_flood_trim() subtracts user-supplied masks. For
example, you could trim by urban areas, steep terrain, railways, or
waterbodies. Here we demonstrate by removing cells on slopes > 5% (a
stricter threshold).
steep_mask <- fl_mask(slope, threshold = 5, operator = ">")
trimmed <- fl_flood_trim(connected, steep_mask)
cat("After trimming steep cells:",
sum(values(trimmed) == 1, na.rm = TRUE), "\n")
#> After trimming steep cells: 37694Assemble multiple layers
fl_flood_assemble() unions multiple binary rasters. This
is useful when combining outputs from different flood models or data
sources.
# Example: union the connected valleys with a wider flood-only mask
flooded_mask <- flood[["flooded"]]
flooded_mask <- ifel(is.na(flooded_mask), 0L, flooded_mask)
assembled <- fl_flood_assemble(connected, flooded_mask)
cat("Assembled cells:", sum(values(assembled) == 1, na.rm = TRUE), "\n")
#> Assembled cells: 64302Precipitation matters
The VCA bankfull regression includes a precipitation term
(precip ^ 0.355) that scales flood depth with local
climate. In BC, map_upstream (mean annual precipitation in
mm) is available as a stream attribute from fwapg (the Freshwater Atlas
Postgres layer). For other jurisdictions, any gridded precipitation
product will work — rasterize it to the DEM grid and pass it as the
precip argument.
Omitting precipitation (precip = 1, the default)
underestimates flood depth by roughly 4x in wet climates (~500 mm MAP).
This produces a valley that is about half the width of the correct
result:
valleys_no_precip <- fl_valley_confine(
dem, streams, field = "upstream_area_ha",
precip = 1
)
n_no <- sum(values(valleys_no_precip) == 1, na.rm = TRUE)
cat("Without precip:", n_no, "cells (",
round(100 * n_no / ncell(dem), 1), "%)\n")
#> Without precip: 24840 cells ( 4.8 %)
cat("With precip: ", n_valley, "cells (",
round(100 * n_valley / ncell(dem), 1), "%)\n")
#> With precip: 55420 cells ( 10.7 %)Performance
Several terra operations inside
fl_valley_confine() support multi-threading (focal filters,
distance calculations, raster math). Set threads before running:
terra::terraOptions(threads = 12) # adjust to your machineOn an Apple M4 Max (16 cores, 12 performance), this reduced a
Neexdzii Kwah run (~2,700 km², 27M cells, 1165 stream segments) from
~3.5 minutes to ~1 minute. The remaining time is dominated by
terra::costDist() and terra::interpIDW(),
which are single-threaded.
Summary
Key tuning parameters:
| Parameter | Default | Effect |
|---|---|---|
slope_threshold |
9% | Higher = more valley floor included |
max_width |
2000 m | Analysis corridor width |
cost_threshold |
2500 | Higher = valley extends further up hillslopes |
flood_factor |
6 | Higher = deeper flood, more floodplain |
precip |
1 | MAP in mm — critical for realistic flood depth |
size_threshold |
5000 m² | Minimum valley patch area |
hole_threshold |
2500 m² | Maximum hole size to fill |
