rLakeHabitat provides a complete workflow for turning
raw depth measurements into a bathymetric digital elevation model (DEM),
quantifying physical habitat metrics from it, and visualizing the
results. This vignette walks through that workflow, using an example
waterbody to illustrate the package’s functions and the reasoning behind
them.
Beyond rLakeHabitat itself, this vignette uses a few
companion packages for spatial data handling, plotting, and site
selection:
library(rLakeHabitat)
library(terra)
#> Warning: package 'terra' was built under R version 4.4.3
library(tidyterra)
#> Warning: package 'tidyterra' was built under R version 4.4.3
library(ggspatial)
#> Warning: package 'ggspatial' was built under R version 4.4.3
library(sf)
#> Warning: package 'sf' was built under R version 4.4.3
library(dplyr)
library(httr)
library(spbal)
#> Warning: package 'spbal' was built under R version 4.4.3
library(ggplot2)
#> Warning: package 'ggplot2' was built under R version 4.4.3
library(mapview)
#> Warning: package 'mapview' was built under R version 4.4.3Every bathymetric survey starts with the same question: how far apart can transects be spaced and still produce an accurate map? Most waterbodies are too large to efficiently and accurately map the entire surface, so depth data is generally collected along transects spaced at some fixed distance. That spacing matters: too narrow and you’re spending time and effort collecting more data than you need; too large and you risk missing real features in the shape of the waterbody.
The samplingDensity() function addresses this before
data collection by simulating a plausible bathymetry for a lake (a
smooth depth profile plus realistic random roughness), testing several
candidate transect spacings against it, and reporting how reconstruction
accuracy degrades as spacing increases. It does this using sequential
Gaussian simulation (SGS) to generate a “true” bathymetry. SGS visits
each cell in the outline and uses variogram parameters to estimate a
distribution of potential values based on a weighted average of each
cell’s neighbors, then draws a random depth from that distribution. The
variogram range controls how many surrounding cells influence that
estimate: a small range produces a choppier surface (less influence from
neighbors), while a larger range produces a smoother one. The sill
controls the variance of each cell’s distribution: a larger sill allows
a wider range of possible depths at each point, producing a bumpier
surface, while a smaller sill produces a flatter one.
samplingDensity() also identifies a recommended spacing:
once RMSE is calculated for each candidate spacing, the largest spacing
within a specified tolerance (default 10%) of the minimum RMSE is
selected. This reflects a real tradeoff between sampling cost and
accuracy - for example, increasing transect spacing from 50 to 100 m
might cut the total number of transects in half while increasing RMSE by
less than 10%.
The function requires a best estimate of maximum depth, and
optionally a shape describing whether the basin is a steep
bowl (shape < 1) or broad and flat
(shape > 1). Below, we simulate a plausible bathymetry
for Alcova Reservoir (max depth 55 m, a moderately steep basin) and
evaluate three candidate transect spacings against it.
# Load Alcova outline shapefile
outline <- read_sf(extdata_path("example_outline.shp"))
crs(outline) # verify input CRS
#> [1] "GEOGCRS[\"WGS 84\",\n DATUM[\"World Geodetic System 1984\",\n ELLIPSOID[\"WGS 84\",6378137,298.257223563,\n LENGTHUNIT[\"metre\",1]]],\n PRIMEM[\"Greenwich\",0,\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n CS[ellipsoidal,2],\n AXIS[\"geodetic latitude (Lat)\",north,\n ORDER[1],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n AXIS[\"geodetic longitude (Lon)\",east,\n ORDER[2],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n ID[\"EPSG\",4326]]"
design <- samplingDensity(outline, # shapefile outline
max_depth = 6,
shape = 1, # value describing basin shape - steep (shape < 1) or shallow (shape > 1)
truth_range = 100, # distance where correlation ends - large range = smooth ridges/valleys, small range = jagged terrain
truth_sill = 5, # total variance of field, how big do bumps get - low = flat, high = variable
along_track_interval = 5, # distance between points sampled along transect
spacings = c(50, 100), # hypothetical distances between transect lines (meters)
res = 50, # value describing cell size in meters, lower = more cells and vice versa
n_sim = 3, # number of simulations to run - more is better, at the cost of runtime
plot = T,
seed = 123)
#> | | | 0%[using unconditional Gaussian simulation]
#> | |============ | 17% | |======================= | 33%[using unconditional Gaussian simulation]
#> | |=================================== | 50% | |=============================================== | 67%[using unconditional Gaussian simulation]
#> | |========================================================== | 83% | |======================================================================| 100%
# accuracy vs. transect spacing
plot(design$results$spacing, design$results$mean_rmse, type = "b", pch = 19,
xlab = "Transect spacing (m)", ylab = "RMSE (m)",
main = "Simulated DEM accuracy vs. transect spacing")
# recommended spacing, and the total transect length it implies
design$recommended_spacing
#> [1] 50
design$total_transect_length
#> [1] 215294.3Once a spacing is chosen, the resulting transects can be saved to a
.gpx file for use in most common GPS units:
# reproject to WGS84 for .gpx format
transects_wgs84 <- terra::project(design$transects, "EPSG:4326")
transects_sf <- sf::st_as_sf(transects_wgs84)
transects_sf <- sf::st_cast(transects_sf, "MULTILINESTRING")
transects_sf <- sf::st_cast(transects_sf, "LINESTRING")
transects_sf$name <- paste0("Transect_", seq_len(nrow(transects_sf)))
transects_sf <- transects_sf["name"]
sf::st_write(transects_sf, "transects.gpx", driver = "GPX",
layer_options = "FORCE_GPX_ROUTE=YES", delete_dsn = TRUE)
# Or as a .kml:
# sf::st_write(transects_sf, "transects.kml", driver = "KML")With a transect spacing chosen and sampling complete, the next step is turning raw depth data into a clean interpolation input. Depth data typically arrives one of two ways: raw XYZ points from a sonar unit, or depths encoded as attributes on contour polygons digitized from an existing chart. The general workflow is:
Depth data collected using boat-mounted sonar is often very dense -
measurements are taken continuously, resulting in multiple points over
very small spatial scales. Counterintuitively, this can actually reduce
interpolation accuracy by biasing estimates toward data-dense regions.
rarify() addresses this by calculating the mean of all
depth measurements within each defined cell area. Boat-mounted sonar can
also record erroneous depths when the transducer isn’t oriented properly
(e.g., while turning), so it’s worth reviewing the data to catch false
“islands” before interpolating.
One important consideration with spatial data is the coordinate
reference system (CRS), which describes how coordinates are measured and
projected. rLakeHabitat’s interpolation and analysis
functions handle CRS reprojection internally for their own working
calculations, but they assume the depth data and outline are already in
the same CRS as one another and do not verify this. If they aren’t,
results can be wrong (points may be spatially shifted but still fall
within the outline, producing a plausible-looking but incorrect DEM).
Always explicitly check and reconcile CRSs before interpolating.
# load contours and sample points
contours <- read_sf(extdata_path("example_contour.shp"))
head(contours)
#> Simple feature collection with 6 features and 5 fields
#> Geometry type: MULTIPOLYGON
#> Dimension: XY
#> Bounding box: xmin: -104.7057 ymin: 42.16475 xmax: -104.6948 ymax: 42.16905
#> Geodetic CRS: WGS 84
#> # A tibble: 6 × 6
#> Z Field Field1 Shape_Leng Shape_Area geometry
#> <dbl> <dbl> <dbl> <dbl> <dbl> <MULTIPOLYGON [°]>
#> 1 40 0 0 0.00490 0.00000136 (((-104.7003 42.16636, -104.7002 4…
#> 2 40 0 0 0.00552 0.00000203 (((-104.7034 42.16905, -104.7033 4…
#> 3 35 0 0 0.00585 0.00000115 (((-104.6959 42.16705, -104.6958 4…
#> 4 35 0 0 0.00341 0.000000670 (((-104.7044 42.16679, -104.7043 4…
#> 5 35 0 0 0.00126 0.0000000976 (((-104.7056 42.16596, -104.7056 4…
#> 6 35 0 0 0.000943 0.0000000534 (((-104.7045 42.16496, -104.7045 4…
ggplot()+
geom_sf(data = contours, aes(color = Z))
depths <- contourPoints(contours, depths = "Z", geometry = "geometry")
# were going to continue from here with our raw point data, but could replace that with the 'depths' dataframe
depths <- read.csv(extdata_path("example_depths.csv"))
head(depths)
#> x y z
#> 1 -89.26792 42.97233 1
#> 2 -89.26081 42.97508 1
#> 3 -89.25180 42.97392 2
#> 4 -89.24628 42.96999 1
#> 5 -89.26208 42.96755 5
#> 6 -89.25275 42.96543 7
crs(outline) #EPSG:4326
#> [1] "GEOGCRS[\"WGS 84\",\n DATUM[\"World Geodetic System 1984\",\n ELLIPSOID[\"WGS 84\",6378137,298.257223563,\n LENGTHUNIT[\"metre\",1]]],\n PRIMEM[\"Greenwich\",0,\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n CS[ellipsoidal,2],\n AXIS[\"geodetic latitude (Lat)\",north,\n ORDER[1],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n AXIS[\"geodetic longitude (Lon)\",east,\n ORDER[2],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n ID[\"EPSG\",4326]]"
crs(depths) #No CRS, not projected yet -- recorded in WGS84 (EPSG:4326) -- good
#> [1] NA
# # if needed, we could reproject the outline to match the depth data
# outline <- st_transform(AL_outline, crs = 4326)
# crs(outline)
# Before we rarify, we could verify our depths are accurate
outliers <- depths %>%
filter(z < 1 | z > 10) %>%
st_as_sf(coords = c("x", "y"), crs = 4326)
mapview::mapview(outline) +
outliers
# # optional code to remove outliers
# clean <- depths %>%
# filter(!between(x, -108.17588, -108.17548)) %>%
# filter(!between(y, 43.38636, 43.38667))
# can manually add maximum and minimum depth points if necessary by getting coordinates from mapview() and entering them in the dataframe
# Now that we're comfortable with the depth data, we can rarify
rare <- rarify(outline, depths, "x", "y", "z", res = 10) # the example data is already sparse, so it won't changeInterpolation is the process that fills in all the unknown gaps
between our measured depths. The interpBathy() function can
interpolate using three methods, Inverse Distance Weighting (IDW),
Ordinary Kriging (OK), or Universal Kriging (UK). While both accomplish
the same thing, they function in very different ways. IDW is a
deterministic weighting rule based purely on distance. Each unknown
point is calculated using the weighted average of neighboring points
(nmax), where the weight is determined by the inverse
distance power (idp). By increasing or decreasing the
inverse distance power, you are tuning the model to weight neighboring
points stronger or weaker. The relationship is weight = 1/distance^idp.
Because IDW is deterministic, there is no uncertainty in the estimates.
This method is computationally efficient and works well on both
spatially well distributed datasets and sparse datasets where OK may be
unable to fit a variogram.
Conversely, kriging is a geostatistical model that learns the spatial
correlation structure of the data before predicting unknown points. It
does this by fitting a variogram - a model of how much measurements
differ as a function of the distance between them. The variogram is
calculated by finding the distance and squared difference in depth value
for each pair of points. The variogram tells the interpolation how much
influence neighbors have at each distance in the actual dataset, so the
model can weight points individually based on their distance from the
unknown value. The variogram can be tuned to use different models (curve
shapes) and specific range, sill, and nugget values. Range is the
distance at which a point becomes noninfluential, sill (or partial sill)
is the variance at the range, and the nugget is the variance at a
distance of zero (the y-intercept). Here, the nugget represents
measurement error. By default, interpBathy() automatically
fits a nugget from the data’s empirical variogram, the same way it fits
sill and range - a fixed value can be supplied instead (e.g.,
nugget = 0, to force the interpolation to pass exactly
through each known depth) if preferred.
Each variogram model suggests a different relationship between
variance and distance. Spherical models are often the default and tend
to best represent moderate surface smoothness. Exponential models have
the steepest behavior near the origin meaning influence of distant
points decreases faster, but it asymptotes to the sill so even distant
points can have a very small influence. Gaussian models represent the
smoothest surface of the three with shallow behavior around the origin
of the variogram that increases the influence of distant points. The
interpBathy() function defaults to a spherical variogram
model and automatically calculates the variogram parameters before using
them in the interpolation, but they can be overridden if desired.
Kriging is a more computationally intensive method than IDW but can be
more accurate for many datasets and provides a variance layer alongside
the estimates.
OK and UK differ in how they handle the mean of the unknown depths. OK uses a constant mean, assuming that each unknown depth is the same and spatial patterns in depth are caused only by the spatial correlation between nearby points. UK treats the mean as a trend, so the expected depth differs based on the location of the unknown point. OK is computationally easier than UK because it lacks the extra calclation for the floating mean. UK can be parameterized with two types of trends for the mean - linear or quadratic. A linear trend suggests that mean depth is a flat, tilted plane across the lake while a quadratic trend suggests mean depth follows a parabolic bowl shape across the lake.
Interpolations can be simple or complex, depending on the structure of the data and the desired result. Below, we run some example interpolations using IDW and OK.
# Basic IDW interpolation
IDW <- interpBathy(outline, rare, "x", "y", "z", separation = 10, res = 50, nmax = 8)
#> | | | 0%
#> | |============== | 20% | |============================ | 40%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> [inverse distance weighted interpolation]
#> |---------|---------|---------|---------|[inverse distance weighted interpolation]
#> ========================================= | |========================================== | 60%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= [inverse distance weighted interpolation]
#> |---------|---------|---------|---------|[inverse distance weighted interpolation]
#> ========================================= |---------|---------|---------|---------|========================================= | |======================================================== | 80%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= | |======================================================================| 100%[1] "Raster Interpolated in CRS: GEOGCRS[\"WGS 84\",\n DATUM[\"World Geodetic System 1984\",\n ELLIPSOID[\"WGS 84\",6378137,298.257223563,\n LENGTHUNIT[\"metre\",1]]],\n PRIMEM[\"Greenwich\",0,\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n CS[ellipsoidal,2],\n AXIS[\"geodetic latitude (Lat)\",north,\n ORDER[1],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n AXIS[\"geodetic longitude (Lon)\",east,\n ORDER[2],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n ID[\"EPSG\",4326]]"
plot(IDW, main = "IDW-interpolated DEM")
# Basic OK interpolation
OK <- interpBathy(outline, rare, "x", "y", "z", separation = 20, res = 50, method = "OK", nmax = 8)
#> | | | 0%
#> | |============== | 20% | |============================ | 40%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> Warning in gstat::fit.variogram(vgram, model = init_vgm): No convergence after
#> 200 iterations: try different initial values?
#> [using ordinary kriging]
#> |---------|---------|---------|---------|[using ordinary kriging]
#> ========================================= | |========================================== | 60%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= [using ordinary kriging]
#> |---------|---------|---------|---------|[using ordinary kriging]
#> ========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= | |======================================================== | 80%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= | |======================================================================| 100%[1] "Raster Interpolated in CRS: GEOGCRS[\"WGS 84\",\n DATUM[\"World Geodetic System 1984\",\n ELLIPSOID[\"WGS 84\",6378137,298.257223563,\n LENGTHUNIT[\"metre\",1]]],\n PRIMEM[\"Greenwich\",0,\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n CS[ellipsoidal,2],\n AXIS[\"geodetic latitude (Lat)\",north,\n ORDER[1],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n AXIS[\"geodetic longitude (Lon)\",east,\n ORDER[2],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n ID[\"EPSG\",4326]]"
plot(OK, main = "OK-interpolated DEM (depth + error layers)")You may notice that the OK interpolation above raised a warning:
"No convergence after 200 iterations". This means the NLS
minimizer couldn’t reach stable values for the nugget, psill, and range
parameters while fitting the Gaussian model to the empirical variogram
after 200 attempts - a common outcome with low sample density, or when
the true variogram shape doesn’t match the model being fit. In this
situation, running a variogram manually, comparing model shapes, and
identifying the best-fitting parameters directly is a useful diagnostic
step. The code below mirrors interpBathy()’s own internal
CRS handling by reprojecting to a projected CRS before fitting, allowing
model shapes to be compared directly.
if (!inherits(outline, "SpatVector")) {
outline <- terra::vect(outline)
}
if (terra::is.lonlat(outline)) {
centroid <- terra::centroids(outline)
lon <- terra::crds(centroid)[1]; lat <- terra::crds(centroid)[2]
utm_zone <- floor((lon + 180) / 6) + 1
epsg <- ifelse(lat >= 0, 32600, 32700) + utm_zone
best_crs <- terra::crs(paste0("EPSG:", epsg))
pts <- terra::vect(rare, geom = c("x", "y"), crs = terra::crs(outline))
pts_proj <- terra::project(pts, best_crs)
coords <- terra::crds(pts_proj)
rare$x_m <- coords[, 1]
rare$y_m <- coords[, 2]
} else {
rare$x_m <- rare$x
rare$y_m <- rare$y
}
# calculate empirical points on the variogram and plot
# This will look bad given the sparse data
emp_vgm <- gstat::variogram(z ~ 1, locations = ~x_m + y_m, data = rare)
plot(emp_vgm)
# fit a curve to the variogram
fit_vgm <- gstat::fit.variogram(emp_vgm, model = gstat::vgm(model = "Gau"))
fit_vgm
#> model psill range
#> 1 Gau 11.69316 978.2081
plot(emp_vgm, fit_vgm)
# compare different curves and see which model type fits best
for (m in c("Sph", "Exp", "Gau")) {
f <- gstat::fit.variogram(emp_vgm, model = gstat::vgm(model = m))
cat(m, "- SSErr:", attr(f, "SSErr"), "\n")
}
#> Warning in gstat::fit.variogram(emp_vgm, model = gstat::vgm(model = m)):
#> singular model in variogram fit
#> Sph - SSErr: 0.0006850851
#> Warning in gstat::fit.variogram(emp_vgm, model = gstat::vgm(model = m)): No
#> convergence after 200 iterations: try different initial values?
#> Exp - SSErr: 0.0003537983
#> Gau - SSErr: 0.0003282291Now that DEMs have been generated, the next step is checking how
accurate they are. While OK and UK interpolations return a raster of
variances for each cell, IDW does not. Interpolations can be
cross-validated with the crossValidate() function to
calculate residual mean square error (RMSE), which is useful both to
gauge accuracy and to compare parameters across interpolations to find
the best fit. crossValidate() runs k-fold cross-validation
with folds blocked spatially and by depth, to ensure the training data
still has good coverage of the system. This is more computationally
intensive than a single interpolation, since the function splits depth
data into k folds and runs k interpolations, leaving one fold out each
time to test on. The example below uses IDW interpolation, a low
resolution, a low number of folds, and a low nmax to keep
runtime short; in practice, at least 5 folds is recommended. This is an
excellent method to compare different interpolation parameters to
identify the most accurate model.
# calculate RMSE
crossValidate(outline, rare, "x", "y", "z", zeros = FALSE, separation = 20,
k = 3, res = 10, method = "IDW", nmax = 8, zero_threshold = .5, seed = 123)
#> | | | 0%
#> | |============== | 20% | |============================ | 40%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> [inverse distance weighted interpolation]
#> |---------|---------|---------|---------|[inverse distance weighted interpolation]
#> ========================================= | |========================================== | 60%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> | |======================================================== | 80%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= | |======================================================================| 100%[1] "Raster Interpolated in CRS: GEOGCRS[\"WGS 84\",\n DATUM[\"World Geodetic System 1984\",\n ELLIPSOID[\"WGS 84\",6378137,298.257223563,\n LENGTHUNIT[\"metre\",1]]],\n PRIMEM[\"Greenwich\",0,\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n CS[ellipsoidal,2],\n AXIS[\"geodetic latitude (Lat)\",north,\n ORDER[1],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n AXIS[\"geodetic longitude (Lon)\",east,\n ORDER[2],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n ID[\"EPSG\",4326]]"
#>
#> | | | 0%
#> | |============== | 20% | |============================ | 40%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> [inverse distance weighted interpolation]
#> |---------|---------|---------|---------|[inverse distance weighted interpolation]
#> ========================================= | |========================================== | 60%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> | |======================================================== | 80%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= | |======================================================================| 100%[1] "Raster Interpolated in CRS: GEOGCRS[\"WGS 84\",\n DATUM[\"World Geodetic System 1984\",\n ELLIPSOID[\"WGS 84\",6378137,298.257223563,\n LENGTHUNIT[\"metre\",1]]],\n PRIMEM[\"Greenwich\",0,\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n CS[ellipsoidal,2],\n AXIS[\"geodetic latitude (Lat)\",north,\n ORDER[1],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n AXIS[\"geodetic longitude (Lon)\",east,\n ORDER[2],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n ID[\"EPSG\",4326]]"
#>
#> | | | 0%
#> | |============== | 20% | |============================ | 40%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> [inverse distance weighted interpolation]
#> |---------|---------|---------|---------|[inverse distance weighted interpolation]
#> ========================================= | |========================================== | 60%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|=========================================
#> | |======================================================== | 80%|---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= |---------|---------|---------|---------|========================================= | |======================================================================| 100%[1] "Raster Interpolated in CRS: GEOGCRS[\"WGS 84\",\n DATUM[\"World Geodetic System 1984\",\n ELLIPSOID[\"WGS 84\",6378137,298.257223563,\n LENGTHUNIT[\"metre\",1]]],\n PRIMEM[\"Greenwich\",0,\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n CS[ellipsoidal,2],\n AXIS[\"geodetic latitude (Lat)\",north,\n ORDER[1],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n AXIS[\"geodetic longitude (Lon)\",east,\n ORDER[2],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n ID[\"EPSG\",4326]]"
#> RMSE
#> 1.917906Two major decisions in the interpolation process are how much to
rarify your input data and what resolution to interpolate your DEM at.
While you could cross validate different combinations of interpolations,
the optimizeParams function does that automatically. Enter
a list of rarification densities (spacings), resolutions (res_values),
and the parameters for your interpolation and compare RMSEs across those
scenarios. This function runs both the rarify() and
crossValidate() functions, so this is the same result as
cross validating different combinations manually.
tuning <- optimizeParams(outline, depths, "x", "y", "z",
spacings = c(50, 100), res_values = c(10, 50),
k = 3, zeros = FALSE, separation = 20,
nmax = 4, plot = T, seed = 123, zero_threshold = .6)
#> | | | 0%
#> | |================== | 25%
#> | |=================================== | 50%
#> | |==================================================== | 75%
#> | |======================================================================| 100%
tuning$results
#> spacing res mean_rmse n_points
#> 1 50 10 1.738981 14
#> 2 100 10 1.436033 14
#> 3 50 50 1.569734 14
#> 4 100 50 1.315579 14
# n_points is the number of points after rarification (one value for each 'spacings' value)
# this allows you to see how many data points are being used in the interpolation
tuning$recommended_spacing
#> [1] 100
tuning$recommended_res
#> [1] 50With DEMs generated, the next step is quantifying habitat within each waterbody.
Two important physical aquatic habitat boundaries are considered here: thermocline depth and photic depth.
Thermocline depth regulates the volume of the epilimnion,
metalimnion, and hypolimnion. These distinct habitats influence internal
nutrient dynamics and are important for aquatic organisms. Given
temperature profile data, estThermo() estimates average
thermocline depth across sites and/or dates (built on
rLakeAnalyzer):
# read in profile data
profiles <- read.csv(extdata_path("example_profile_data.csv")) %>%
mutate(date = as.Date(date))
# plot profiles
ggplot(profiles, aes(x = temp, y = depth, color = site)) +
geom_path() +
geom_point(size = 0.8) +
scale_y_reverse() +
facet_wrap(~date) +
labs(x = "Temperature (°C)", y = "Depth (m)", color = "Site") +
theme_minimal()
# calculate average thermocline depth across all sites and dates
# change 'combine =' to get values for specific sites or dates
estThermo(profiles, site = "site", date = "date", depth = "depth", temp = "temp", combine = "all")
#> mean sd n
#> 1 10.5195 2.770556 24Several habitat functions want a photic depth. Photic depth functions
as the boundary of the littoral habitat zone in many waterbodies, an
important area for nutrient cycling and aquatic organisms where benthic
algae and macrophyte growth is possible. Photic depth is most accurate
if measured directly with a PAR sensor, but it can be estimated from
other measures of water clarity like a Secchi disk.
calcPhotic() converts Secchi disk measurements to photic
depth using the light-attenuation relationship from Kirk (1994) and
Koenings & Edmundson (1991) where Z is the Secchi disk depth and F
is a numerical constant that represents optical properties of the water.
Standard values for F are 2.76 for stained lakes, 1.99 for clear lakes,
and 1.05 for turbid lakes.
With thermocline and photic depths approximated, the area of littoral habitats and volumes of stratified habitats can be calculated. A hypsographic curve, showing total surface area plotted against depth, is a useful starting point for understanding the general shape of each system.
calcLittoral() estimates the area of littoral and
non-littoral habitats across water levels, given a photic depth (the
littoral boundary) and a depth interval for the estimates. These
functions were designed for reservoir ecosystems, which often exhibit
substantial water level fluctuation within and across years - hence
quantifying area across a range of depths rather than just at the
surface. If only the littoral extent at the current surface level is of
interest, only the first row of the output table is needed.
littoralVol() integrates across depth slices to estimate
littoral and non-littoral (pelagic) volume at each water level.
calcSDI() calculates the Shoreline Development Index
across water levels, a useful metric of nearshore habitat
complexity.
# hypsography: surface area at each depth increment
# change `output` to "values" for a table of areas by depths
calcHyps(IDW, depthUnits = "m", by = 1, output = "plot")
# littoral (photic-zone) surface area across water levels
lit <- calcLittoral(IDW, photic = photic, depthUnits = "m", by = 1)
lit
#> depth tot_area lit_area perc_lit_tot
#> 1 0 11103145.3 8637865.7 77.79657
#> 2 1 4148466.5 2476081.8 59.68668
#> 3 2 2792721.7 1796866.9 64.34107
#> 4 3 1894285.4 1602022.2 84.57132
#> 5 4 1193399.6 1128453.6 94.55791
#> 6 5 462751.4 462751.4 100.00000
#> 7 6 102831.0 102831.0 100.00000
# shoreline development index (how convoluted the shoreline is relative to a circle)
sdi <- calcSDI(IDW, units = "m", by = 1)
sdi
#> depth tot_area perimeter SDI
#> 1 0 11016553.5 15130.400 1.285946
#> 2 1 4148466.5 9906.101 1.372000
#> 3 2 2792721.7 8221.590 1.387833
#> 4 3 1894285.4 6747.621 1.383003
#> 5 4 1193399.6 5402.620 1.395105
#> 6 5 462751.4 4203.765 1.743251
#> 7 6 102831.0 1441.700 1.268259
# littoral vs. pelagic volume
littoralVol(IDW, photic = photic, depthUnits = "m", by = 1)
#> depth tot_vol_m3 lit_vol_m3 pel_vol_m3 perc_lit_tot perc_pel_tot tot_vol_acft
#> 1 0 21697600.8 16206872.7 5490728.13 74.69 25.31 17590.53141
#> 2 1 10594455.5 7569007.0 3025448.55 71.44 28.56 8589.06494
#> 3 2 6445989.1 5092925.1 1353063.93 79.01 20.99 5225.84843
#> 4 3 3653267.4 3296058.3 357209.13 90.22 9.78 2961.75210
#> 5 4 1758982.0 1694036.1 64945.92 96.31 3.69 1426.02994
#> 6 5 565582.5 565582.5 0.00 100.00 0.00 458.52517
#> 7 6 102831.0 102831.0 0.00 100.00 0.00 83.36647
#> lit_vol_acft pel_vol_acft
#> 1 13139.12564 4451.40577
#> 2 6136.29387 2452.77108
#> 3 4128.90165 1096.94679
#> 4 2672.15795 289.59416
#> 5 1373.37743 52.65251
#> 6 458.52517 0.00000
#> 7 83.36647 0.00000Base terra functions can also be used directly for
metrics like average slope or true (terrain-corrected) surface area.
# Before calculating these metrics, the raster needs to be reprojected to a
# projected CRS (here, the appropriate UTM zone), since slope and surface area
# calculations require real linear distance units.
# quick way to get the right UTM zone from the raster's centroid
get_best_utm <- function(r) {
centroid <- terra::centroids(terra::as.polygons(terra::ext(r), crs = terra::crs(r)))
centroid <- terra::project(centroid, "EPSG:4326")
lon <- terra::crds(centroid)[1]
lat <- terra::crds(centroid)[2]
utm_zone <- floor((lon + 180) / 6) + 1
hemisphere <- ifelse(lat >= 0, 32600, 32700)
paste0("EPSG:", hemisphere + utm_zone)
}
utm_crs <- get_best_utm(IDW)
IDW_utm <- terra::project(IDW[[1]], utm_crs)
# Average slope
slope_deg <- terra::terrain(IDW_utm, v = "slope", unit = "degrees")
plot(slope_deg)
avg_slope <- terra::global(slope_deg, "mean", na.rm = TRUE)
avg_slope
#> mean
#> slope 0.1820658
# Total true (terrain-corrected) surface area
surf_area_cells <- terra::surfArea(IDW_utm)
total_true_area <- terra::global(surf_area_cells, "sum", na.rm = TRUE)
total_true_area # in sq meters
#> sum
#> lyr1 11098516The calcVolume() function estimates the volume of the
epilimnion, metalimnion, and hypolimnion by integrating area across
depth. It takes either a single thermocline midpoint (to estimate
epilimnion and hypolimnion volumes) or an upper and lower thermocline
boundary (to additionally estimate metalimnion volume). Using the
thermocline depths calculated earlier - 12.45 m for Alcova and 7.18 m
for Bull Lake:
vol <- calcVolume(IDW, thermo_depth = 3, depthUnits = "m", by = 1)
vol
#> depth tot_vol_m3 epi_vol_m3 hyp_vol_m3 perc_epi_tot perc_hyp_tot tot_vol_acft
#> 1 0 21697600.8 19938619 1758982.0 91.89 8.11 17590.53140
#> 2 1 10594455.5 10028873 565582.5 94.66 5.34 8589.06493
#> 3 2 6445989.1 6343158 102831.0 98.40 1.60 5225.84843
#> 4 3 3653267.4 3653267 0.0 100.00 0.00 2961.75209
#> 5 4 1758982.0 NA NA NA NA 1426.02993
#> 6 5 565582.5 NA NA NA NA 458.52517
#> 7 6 102831.0 NA NA NA NA 83.36647
#> epi_vol_acft hyp_vol_acft
#> 1 16164.501 1426.02993
#> 2 8130.540 458.52517
#> 3 5142.482 83.36647
#> 4 2961.752 0.00000
#> 5 NA NA
#> 6 NA NA
#> 7 NA NA
# Supplying thermo_high/thermo_low instead of a single thermo_depth additionally
# estimates metalimnion volume between the two boundaries
calcVolume(IDW, thermo_high = 2, thermo_low = 4, depthUnits = "m", by = 1)
#> depth tot_vol_m3 epi_vol_m3 met_vol_m3 hyp_vol_m3 perc_epi_tot perc_met_tot
#> 1 0 21697600.8 18044333 3087685 565582.5 83.16 14.23
#> 2 1 10594455.5 8835474 1656151 102831.0 83.40 15.63
#> 3 2 6445989.1 5880407 565582 0.0 91.23 8.77
#> 4 3 3653267.4 3550436 NA NA 97.19 NA
#> 5 4 1758982.0 1758982 NA NA 100.00 NA
#> 6 5 565582.5 NA NA NA NA NA
#> 7 6 102831.0 NA NA NA NA NA
#> perc_hyp_tot tot_vol_acft epi_vol_acft met_vol_acft hyp_vol_acft
#> 1 2.61 17590.53140 14628.779 2503.2270 458.52517
#> 2 0.97 8589.06493 7163.035 1342.6635 83.36647
#> 3 0.00 5225.84843 4767.323 458.5248 0.00000
#> 4 NA 2961.75209 2878.386 NA NA
#> 5 NA 1426.02993 1426.030 NA NA
#> 6 NA 458.52517 NA NA NA
#> 7 NA 83.36647 NA NA NAThe rLakeHabitat package includes built-in functions for plotting
interpolated DEMs, starting with bathyMap(), followed by
standalone code for more customized raster plots.
# A basic bathymetry map - the density of contour lines can be adjusted via the 'by' argument
bathyMap(IDW, contours = TRUE, units = "m", by = 1)
# These maps are fairly basic on their own. Helpful references like a scale bar,
# compass, or a background providing geographic context can be added with a few
# additional packages.
# First, reproject the DEM to Web Mercator (EPSG:3857) to match the map tiles used below
IDW_3857 <- terra::project(IDW, "EPSG:3857")
# Then turn the DEM into a data frame to remove NA cells around the lake
IDW_df <- as.data.frame(IDW_3857, xy = T, na.rm = T)
# Next, get the extent of the raster as a bounding box
bbox_sf <- sf::st_as_sfc(sf::st_bbox(IDW_3857))
# Optional: retrieve the necessary satellite tiles
# sat_tiles <- maptiles::get_tiles(bbox_sf, provider = "OpenTopoMap", zoom = 14, crop = TRUE) #zoom 13 (broader) or zoom 15 (finer)
# Now we can put it all together
ggplot() +
# tidyterra::geom_spatraster_rgb(data = sat_tiles) + #include for sat_tiles
geom_raster(data = IDW_df, aes(x = x, y = y, fill = lyr1)) +
scale_fill_continuous(name = "Depth (m)", low = "blue", high = "lightblue", na.value="transparent", trans = "reverse") +
tidyterra::geom_spatraster_contour(data = IDW_3857, breaks = seq(0, as.numeric(max(values(IDW_3857, na.rm = T))), 5), color = "black") +
ggspatial::annotation_scale(location = "br",
width_hint = 0.3,
text_col = "black",bar_cols = c("black", "white"),
pad_x = unit(5, "cm"), pad_y = unit(2, "cm")) +
ggspatial::annotation_north_arrow(location = "tl",
which_north = "true",
style = ggspatial::north_arrow_fancy_orienteering(
text_col = "black",
fill = c("white", "black")),
pad_x = unit(2.5, "cm"), pad_y = unit(2, "cm"),
height = unit(3, "cm"), width = unit(3, "cm")) +
coord_sf(crs = 3857) +
labs(title = "Bathymetric DEM", x = NULL, y = NULL) +
theme_minimal()
# and we can zoom out if we want more context
# # add buffer around boundary box
# bbox_buffered <- sf::st_buffer(bbox_sf, dist = 4000) # buffer distance in meters
#
# # get new map tiles
# sat_tiles <- maptiles::get_tiles(bbox_buffered, provider = "OpenTopoMap", zoom = 13, crop = TRUE)
#
# # save the extend of our new boundary box to add to our plot
# buff_ext <- sf::st_bbox(bbox_buffered)
#
# ggplot() +
# tidyterra::geom_spatraster_rgb(data = sat_tiles) +
# geom_raster(data = IDW_df, aes(x = x, y = y, fill = lyr1)) +
# scale_fill_continuous(name = "Depth (m)", low = "blue", high = "lightblue", na.value = "transparent", trans = "reverse") +
# tidyterra::geom_spatraster_contour(data = IDW_3857, breaks = seq(0, as.numeric(max(values(IDW_3857, na.rm = T))), 5), color = "black") +
# ggspatial::annotation_scale(location = "br",
# width_hint = 0.3,
# text_col = "black", bar_cols = c("black", "white"),
# pad_x = unit(5, "cm"), pad_y = unit(2, "cm")) +
# ggspatial::annotation_north_arrow(location = "tl",
# which_north = "true",
# style = ggspatial::north_arrow_fancy_orienteering(
# text_col = "black",
# fill = c("white", "black")),
# pad_x = unit(2.5, "cm"), pad_y = unit(2, "cm"),
# height = unit(3, "cm"), width = unit(3, "cm")) +
# coord_sf(crs = 3857,
# xlim = c(buff_ext["xmin"], buff_ext["xmax"]),
# ylim = c(buff_ext["ymin"], buff_ext["ymax"]),
# expand = FALSE) +
# labs(title = "Bathymetric DEM", x = NULL, y = NULL) +
# theme_minimal()animBathy() produces a gganimate object
showing how littoral (or total) surface area changes as water level
drops, useful for reservoirs with substantial drawdown.
We can also look at the results from the littoral and pelagic habitat analyses above, combining datasets to see percent change in habitat area and volume across water levels.
habitat <- lit %>%
merge(c(vol, sdi), by = "depth") %>%
select(depth, perc_lit_tot, perc_epi_tot, perc_hyp_tot, SDI)
# rescale SDI onto the same 0-100 visual range as the percent variables, computed
# from the actual combined SDI range rather than a fixed assumed scale
sdi_scale_factor <- max(habitat$SDI, na.rm = TRUE) / 100
habitat %>%
mutate(SDI_scaled = SDI / sdi_scale_factor) %>%
ggplot() +
geom_line(aes(x = depth, y = perc_epi_tot, color = 'Epilimnion'), lwd = .8) +
geom_line(aes(x = depth, y = perc_hyp_tot, color = 'Hypolimnion'), lwd = .8) +
geom_line(aes(x = depth, y = perc_lit_tot, color = 'Littoral'), lwd = .8) +
geom_line(aes(x = depth, y = SDI_scaled, color = 'SDI'), lwd = .8) +
scale_color_manual(values = c('Epilimnion' = 'red', 'Hypolimnion' = 'blue', 'Littoral' = 'green', 'SDI' = 'purple')) +
scale_y_continuous(name = "Percent of Total",
sec.axis = sec_axis(~ . * sdi_scale_factor, name = "SDI")) +
labs(x = "Water Level Fluctuation (m)", color = "Habitat") +
theme_bw() +
theme(strip.text.x = element_text(size = 14),
axis.title = element_text(size = 14),
axis.text = element_text(size = 12),
legend.title = element_text(size = 14),
legend.text = element_text(size = 12),
legend.position = 'right')
#> Warning: Removed 3 rows containing missing values or values outside the scale range
#> (`geom_line()`).
#> Removed 3 rows containing missing values or values outside the scale range
#> (`geom_line()`).genStack() splits a DEM into a stack of binary (wet/dry)
layers at each depth increment. A raster stack is a useful format for
further GIS work, or for archiving a raster product. Cloud Optimized
GeoTIFF ("COG") is the default file type - COGs are
formatted so that remote or cloud-hosted tools like web viewers can pull
only the portion, or stack layers, needed instead of downloading the
whole file. COGs do take up slightly more storage space than standard
GeoTIFF files (.tif, "GTiff").
# generate raster stack with layers at 2 m intervals
stack <- genStack(IDW, by = 2, save = FALSE)
stack
#> class : SpatRaster
#> size : 62, 89, 5 (nrow, ncol, nlyr)
#> resolution : 0.0005464108, 0.0005464108 (x, y)
#> extent : -89.27918, -89.23055, 42.94888, 42.98276 (xmin, xmax, ymin, ymax)
#> coord. ref. : lon/lat WGS 84 (EPSG:4326)
#> source(s) : memory
#> names : lyr1, lyr1, lyr1, lyr1, lyr1
#> min values : 0.000000, 0.000000, 2.001398, 4.002190, 6.031895
#> max values : 6.961307, 6.961307, 6.961307, 6.961307, 6.961307
# to actually write it to disk as a cloud-optimized GeoTIFF:
# genStack(IDW, by = 2, save = TRUE, file_name = "Lake", file_type = "COG")Bathymetric contour lines can similarly be generated and exported
directly with saveContours(), in GPX, KML, shapefile, or
GeoPackage format:
# generate and save 5 m contour lines for our lake as a shapefile
saveContours(IDW, by = 5, units = "m", file_name = "LakeContours", file_type = "shp")
# or as a GPX, for use in a handheld GPS unit
saveContours(IDW, by = 5, units = "m", file_name = "LakeContours", file_type = "gpx")Given a DEM, partitionSites() can be used to design a
depth-stratified, spatially balanced sampling program - for example,
gillnet sets spread evenly across depth zones. It does this using Halton
Iterative Partitioning (HIP), a spatially balanced sampling method:
within each depth bin specified, it selects locations that are well
spread out rather than randomly clumped. HIP accomplishes this by
dividing the DEM into n boxes, where n = boxes per bin x number of bins,
then numbering the boxes using a Halton sequence, which assigns numbers
evenly across space. One sample location is then randomly assigned
within each box, ensuring samples are evenly distributed over the whole
area. No outline shapefile is needed - a boundary for the plot is
derived automatically from the DEM’s own footprint.
If the waterbody’s water level is currently below full pool (the
level the DEM represents), water_level_drop rebuilds the
DEM before sampling: depths are reduced by the specified amount, and any
cell that would now be above the water line is excluded from the
candidate pool.
sites <- partitionSites(dem = IDW,
depth_bins = c(0, 2, 4, Inf),
n_per_bin = 2, # or c(1,2,1)
min_spacing = 10,
plot = T, seed = 123)
#> | | | 0% | |======================= | 33% | |=============================================== | 67% | |======================================================================| 100%
sites$locations
#> x y depth_bin bin_index
#> 1 -89.26579 42.97374 [0,2) 1
#> 2 -89.24066 42.95571 [0,2) 1
#> 3 -89.26743 42.96227 [2,4) 2
#> 4 -89.24831 42.96773 [2,4) 2
#> 5 -89.26197 42.96664 [4,Inf] 3
#> 6 -89.25486 42.96172 [4,Inf] 3
# These coordinates and their depth bin can also be saved to a file (e.g. .gpx) for external use
sites_df <- sites$locations %>%
mutate(name = paste0("Site_", row_number())) # partitionSites() doesn't label sites by name, so add one before export
# Convert to an sf object.
# IMPORTANT: crs must match whatever CRS your x/y coordinates are actually in
# Check the DEM CRS:
crs(IDW) #currently in EPSG:4326 - WGS84
#> [1] "GEOGCRS[\"WGS 84\",\n DATUM[\"World Geodetic System 1984\",\n ELLIPSOID[\"WGS 84\",6378137,298.257223563,\n LENGTHUNIT[\"metre\",1]]],\n PRIMEM[\"Greenwich\",0,\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n CS[ellipsoidal,2],\n AXIS[\"geodetic latitude (Lat)\",north,\n ORDER[1],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n AXIS[\"geodetic longitude (Lon)\",east,\n ORDER[2],\n ANGLEUNIT[\"degree\",0.0174532925199433]],\n ID[\"EPSG\",4326]]"
# Convert to coordinates in the CRS of the DEM
sites_sf <- sf::st_as_sf(sites_df, coords = c("x", "y"), crs = 4326)
# Reproject to WGS84 for .gpx format - don't need to do this for our data
sites_wgs84 <- sf::st_transform(sites_sf, crs = 4326)
# GDAL's GPX driver only recognizes a fixed set of standard waypoint fields (name, desc, cmt, sym, ele, time, etc.)
# "depth" isn't a standard field, so we change it to "ele" (elevation)
sites_gpx <- sites_wgs84 %>%
tidyr::extract(
depth_bin,
into = c("depth_min", "depth_max"),
regex = "\\[?\\(?(-?(?:[0-9.]+|Inf)),\\s*(-?(?:[0-9.]+|Inf))[\\)\\]]?",
remove = FALSE,
convert = TRUE) %>%
rename(ele = depth_min) %>%
select(name, ele, geometry)
# save
# sf::st_write(sites_gpx, "sites.gpx", driver = "GPX", delete_dsn = TRUE)Waterbody outlines can also be retrieved directly from the National Hydrography Dataset via ArcGIS REST API, without needing to download the full dataset. This is included for reference, but is not run as part of this vignette, since it requires an internet connection and can have an extended runtime.
# function to get state lakes
get_state_lakes <- function(state_name) {
read_arcgis <- function(base_query_url) {
offset <- 0
page_size <- 2000
all_features <- list()
repeat {
query_url <- paste0(base_query_url, "&resultRecordCount=", page_size,
"&resultOffset=", offset)
response <- GET(query_url, add_headers(`User-Agent` = "Mozilla/5.0"))
if (status_code(response) != 200) stop("Failed to fetch: ", query_url)
content_text <- content(response, as = "text", encoding = "UTF-8")
page <- st_read(content_text, quiet = TRUE)
if (nrow(page) == 0) break
all_features[[length(all_features) + 1]] <- page
offset <- offset + page_size
if (nrow(page) < page_size) break
}
if (length(all_features) == 0) return(NULL)
do.call(rbind, all_features)
}
# State boundary
state_query <- paste0(
"https://services.arcgis.com/P3ePLMYs2RVChkJx/ArcGIS/rest/services/",
"US_States_boundaries/FeatureServer/3/query",
"?where=STATE_NAME%3D'", state_name, "'",
"&outFields=*&returnGeometry=true&f=geojson"
)
state <- read_arcgis(state_query)
bb <- st_bbox(state)
bbox_string <- paste(bb["xmin"], bb["ymin"], bb["xmax"], bb["ymax"], sep = ",")
# Waters
waters_query <- paste0(
"https://services.arcgis.com/P3ePLMYs2RVChkJx/ArcGIS/rest/services/",
"NHDPlusV21/FeatureServer/1/query",
"?where=1%3D1",
"&geometry=", bbox_string,
"&geometryType=esriGeometryEnvelope",
"&inSR=4326",
"&spatialRel=esriSpatialRelIntersects",
"&outFields=*&returnGeometry=true&f=geojson"
)
waters <- read_arcgis(waters_query)
lakes <- waters %>%
st_make_valid() %>%
st_filter(state)
return(list(state = state, lakes = lakes))
}
#subset output and verify
wy_data <- get_state_lakes("Wyoming")
# select specific waterbodies of interest
AL_outline <- wy_data$lakes %>%
filter(GNIS_NAME %in% c("Alcova Reservoir")) %>%
select(GNIS_NAME, geometry)For full documentation on any function, use
?functionName from the console.