SRTM and LiDAR Data Acquisition
Reliable elevation data is the geometric backbone of every hydrologic simulation, from overland flow routing to floodplain delineation. As part of the Hydrology Data Preparation & DEM Processing pipeline, this stage focuses on replacing manual tile selection and GUI-based downloads with reproducible, API-driven acquisition scripts. Automated retrieval eliminates version drift, ensures spatial consistency, and establishes a clean handoff to DEM Pit Filling Algorithms and coordinate reference system alignment without reprocessing raw data by hand.
The two primary sources covered here serve fundamentally different scales and precision requirements: the NASA Shuttle Radar Topography Mission (SRTM) delivers near-global 30 m raster coverage, while airborne LiDAR point clouds provide sub-meter vertical fidelity for channel morphology, engineered structures, and urban drainage geometry.
Prerequisites & Environment Setup
Python stack: Python 3.9+, isolated in a venv or conda environment.
pip install earthaccess>=0.8 rasterio>=1.3 geopandas>=0.12 \
shapely>=2.0 numpy>=1.23 laspy>=2.4 requests>=2.31 tqdm>=4.65
Input data requirements:
| Requirement | SRTM | LiDAR |
|---|---|---|
| Format | GeoTIFF (.tif) |
LAS / LAZ point cloud |
| Native CRS | WGS84 EPSG:4326 | Varies — typically UTM or state-plane |
| Vertical datum | EGM96 geoid | NAVD88 (US) / national geoid |
| Resolution | 1 arc-second (~30 m) | 0.25 m – 2 m after rasterization |
| Nodata encoding | −32768 (Int16) | N/A (sparse points) |
Authentication:
- NASA Earthdata: free account at urs.earthdata.nasa.gov. Store credentials in
~/.netrcor exportEARTHDATA_USERNAME/EARTHDATA_PASSWORDas environment variables — never hardcode tokens in version-controlled scripts. - OpenTopography: register at opentopography.org/developers for an API key. Export as
OPENTOP_API_KEY.
Storage: budget at least 50 GB SSD for staging raw tiles, intermediate mosaics, and point-cloud LAZ files. SRTM tiles covering a large river basin can reach 10–15 GB uncompressed.
Algorithm Mechanics: How the Acquisition Pipeline Works
Both sources follow the same logical sequence: spatial query → metadata resolution → authenticated retrieval → structural validation. Understanding where each stage can fail prevents silent data corruption downstream.
Spatial query converts a watershed boundary polygon into a bounding-box filter compatible with both the NASA CMR API and the OpenTopography catalog. Metadata resolution maps bounding boxes to tile identifiers, dataset versions, and access URLs. Authenticated retrieval streams files directly to local staging directories. Mosaic and reproject stitches multi-tile rasters into a single seamless grid in the project CRS. Validation checks extent, nodata definition, CRS encoding, and — for point clouds — point density before any conditioning occurs.
Dataset Selection: SRTM vs. LiDAR
Choosing between SRTM and LiDAR before writing acquisition code determines every downstream resolution and accuracy decision.
| Criterion | SRTM GL1 (30 m) | Airborne LiDAR (≤1 m) |
|---|---|---|
| Geographic coverage | Near-global (60°N–56°S) | Regional; US coverage via 3DEP |
| Vertical accuracy (RMSE) | ~5–9 m | 0.05–0.15 m |
| Canopy penetration | Surface model (DSM) | Bare-earth returns available |
| Typical file size per tile | 25 MB | 500 MB – 10 GB |
| Best for | Basin-scale routing, continental analysis | Urban drainage, channel bathymetry, floodplain mapping |
| Temporal baseline | February 2000 | Varies by survey year |
Coarser DEMs smooth channel networks and underestimate flow accumulation in incised valleys. Ultra-high-resolution LiDAR introduces significant computational overhead and requires aggressive classification filtering to separate ground returns from vegetation and building artifacts. Align the dataset choice with your hydraulic solver’s cell-size requirements and available compute before initiating downloads.
Anticipate downstream coordinate transformations early. Raw SRTM tiles arrive in WGS84 (EPSG:4326). LiDAR datasets frequently use UTM or local state-plane projections. Establishing a target CRS at this stage prevents geometric distortion during rasterization — a topic covered in depth in coordinate reference system alignment.
Step-by-Step Workflow
Step 1 — Define Spatial Boundary and Query Catalog
Load your watershed boundary as a GeoDataFrame, reproject to EPSG:4326 for catalog compatibility, and extract bounds.
import logging
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
def get_wgs84_bounds(shapefile: str) -> tuple[float, float, float, float]:
"""Reproject watershed boundary to WGS84 and return (minx, miny, maxx, maxy)."""
watershed = gpd.read_file(shapefile)
log.info("Loaded watershed: %d feature(s), CRS=%s", len(watershed), watershed.crs)
watershed_wgs84 = watershed.to_crs(epsg=4326)
bbox = tuple(watershed_wgs84.total_bounds) # (minx, miny, maxx, maxy)
log.info("Catalog query bbox: %.4f, %.4f, %.4f, %.4f", *bbox)
return bbox
bbox = get_wgs84_bounds("data/watersheds/basin_04.shp")
For granular control over tile selection, chunked requests, and metadata caching, see how to download SRTM DEMs for Python hydrology workflows.
Step 2 — Authenticated SRTM Tile Retrieval
NASA distributes SRTM data through Earthdata Cloud. The earthaccess library negotiates OAuth tokens, paginates CMR search results, and supports resumable downloads transparently.
import earthaccess
from pathlib import Path
def fetch_srtm_tiles(bbox: tuple, output_dir: str = "data/raw/srtm") -> list[Path]:
"""Download SRTM GL1 (30 m) tiles covering the bounding box."""
earthaccess.login() # reads ~/.netrc or EARTHDATA_* env vars
log.info("Searching SRTM GL1 catalog for bbox %s", bbox)
results = earthaccess.search_data(
short_name="SRTMGL1",
bounding_box=bbox,
temporal=("2000-02-01", "2000-02-23") # SRTM acquisition window
)
log.info("Found %d SRTM granule(s)", len(results))
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
files = earthaccess.download(results, str(out))
log.info("Retrieved %d file(s) to %s", len(files), out)
return [Path(f) for f in files]
srtm_files = fetch_srtm_tiles(bbox)
earthaccess.download is idempotent: files already present and matching the remote checksum are skipped. For large basins spanning dozens of tiles, wrap the download call in a retry decorator with exponential backoff to respect CMR rate limits.
Step 3 — Regional LiDAR Point-Cloud Fetching
LiDAR acquisition uses the OpenTopography REST catalog. The API returns dataset metadata (survey footprint, acquisition year, point density, file URL) as JSON, allowing programmatic selection before any bytes are transferred.
import os
import requests
from tqdm import tqdm
OPENTOP_CATALOG = "https://portal.opentopography.org/API/otCatalog"
def list_lidar_datasets(bbox: tuple) -> list[dict]:
"""Return OpenTopography LiDAR datasets intersecting the bounding box."""
minx, miny, maxx, maxy = bbox
params = {
"productFormat": "PointCloud",
"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy,
"detail": "true",
"outputFormat": "json",
}
api_key = os.getenv("OPENTOP_API_KEY")
if api_key:
params["API_Key"] = api_key
resp = requests.get(OPENTOP_CATALOG, params=params, timeout=30)
resp.raise_for_status()
datasets = resp.json().get("Datasets", [])
log.info("OpenTopography returned %d LiDAR dataset(s)", len(datasets))
return datasets
def download_lidar(dataset: dict, output_dir: str = "data/raw/lidar") -> Path:
"""Stream-download a single LiDAR LAZ file with progress tracking."""
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
url = dataset.get("downloadURL")
dataset_id = dataset.get("datasetID", "unknown")
dest = out / f"{dataset_id}.laz"
if dest.exists():
log.info("LAZ already present, skipping: %s", dest)
return dest
log.info("Downloading LiDAR dataset %s → %s", dataset_id, dest)
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status()
total = int(r.headers.get("content-length", 0))
with open(dest, "wb") as fh, tqdm(total=total, unit="B", unit_scale=True,
desc=dataset_id) as bar:
for chunk in r.iter_content(chunk_size=65536):
fh.write(chunk)
bar.update(len(chunk))
log.info("Saved %s (%.1f MB)", dest, dest.stat().st_size / 1e6)
return dest
datasets = list_lidar_datasets(bbox)
lidar_files = [download_lidar(ds) for ds in datasets[:3]] # limit for demo
Always verify dataset licensing before commercial deployment — some regional LiDAR surveys carry non-commercial restrictions embedded in the metadata JSON.
Step 4 — Mosaic Assembly and Reprojection
Individual SRTM tiles share the WGS84 geographic coordinate system but must be merged and reprojected to a metric CRS before flow routing. Use rasterio.merge followed by a rasterio.warp.reproject to produce a single Cloud-Optimized GeoTIFF.
import rasterio
from rasterio.merge import merge
from rasterio.warp import calculate_default_transform, reproject, Resampling
def mosaic_and_reproject(tile_paths: list[Path], target_epsg: int,
output_path: str = "data/processed/srtm_mosaic.tif") -> Path:
"""Merge SRTM tiles and reproject to target CRS, writing a COG."""
src_files = [rasterio.open(p) for p in tile_paths]
log.info("Merging %d SRTM tile(s)", len(src_files))
mosaic, transform = merge(src_files)
dst_crs = f"EPSG:{target_epsg}"
dst_transform, width, height = calculate_default_transform(
src_files[0].crs, dst_crs,
mosaic.shape[2], mosaic.shape[1],
left=src_files[0].bounds.left, bottom=src_files[0].bounds.bottom,
right=src_files[-1].bounds.right, top=src_files[0].bounds.top
)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
profile = src_files[0].profile.copy()
profile.update({
"crs": dst_crs, "transform": dst_transform,
"width": width, "height": height,
"driver": "GTiff",
"compress": "deflate", "tiled": True,
"blockxsize": 512, "blockysize": 512,
"interleave": "band"
})
with rasterio.open(out, "w", **profile) as dst:
reproject(
source=mosaic[0], destination=rasterio.band(dst, 1),
src_transform=transform, src_crs=src_files[0].crs,
dst_transform=dst_transform, dst_crs=dst_crs,
resampling=Resampling.bilinear
)
for src in src_files:
src.close()
log.info("Mosaic written: %s (%d×%d px, EPSG:%d)", out, width, height, target_epsg)
return out
mosaic_path = mosaic_and_reproject(srtm_files, target_epsg=32618)
The bilinear resampling kernel preserves smooth elevation gradients during reprojection. For spatial resolution tradeoffs analysis — such as comparing SRTM at 30 m against a LiDAR-derived 1 m DEM — use nearest-neighbor to avoid interpolating integer class rasters or categorical masks.
Step 5 — Structural Validation and Staging
Raw elevation data frequently contains voids, inconsistent nodata values, or CRS metadata that contradicts the file contents. Validate before passing to any conditioning step.
import laspy
def validate_srtm_tile(filepath: Path) -> bool:
"""Check band count, nodata definition, and CRS encoding."""
try:
with rasterio.open(filepath) as src:
if src.count != 1:
log.warning("Unexpected band count %d in %s", src.count, filepath)
return False
if src.nodata is None:
log.warning("No nodata value defined in %s", filepath)
return False
if src.crs is None:
log.warning("No CRS defined in %s", filepath)
return False
log.debug("SRTM tile valid: %s (%.4f°×%.4f°)", filepath,
src.res[1], src.res[0])
return True
except Exception as exc:
log.error("Could not open %s: %s", filepath, exc)
return False
def validate_lidar(filepath: Path, min_point_count: int = 1000) -> bool:
"""Confirm the LAZ file opens and contains a minimum point density."""
try:
with laspy.open(filepath) as las:
count = las.header.point_count
if count < min_point_count:
log.warning("LiDAR %s has only %d points (threshold=%d)",
filepath, count, min_point_count)
return False
log.debug("LiDAR valid: %s (%d points)", filepath, count)
return True
except Exception as exc:
log.error("Could not open LiDAR %s: %s", filepath, exc)
return False
srtm_valid = [f for f in Path("data/raw/srtm").glob("*.tif") if validate_srtm_tile(f)]
lidar_valid = [f for f in Path("data/raw/lidar").glob("*.laz") if validate_lidar(f)]
log.info("Validated %d SRTM tile(s) and %d LiDAR file(s)",
len(srtm_valid), len(lidar_valid))
Validation failures should trigger automated alerts rather than silent skips. Missing tiles or corrupted point clouds propagate errors into flow direction matrices and hydraulic roughness calculations.
Production-Ready Code
The function below integrates all steps into a single, idempotent pipeline entry point suitable for CI/CD or Prefect/Airflow task wrappers.
import logging
from pathlib import Path
import earthaccess
import geopandas as gpd
import rasterio
from rasterio.merge import merge
from rasterio.warp import calculate_default_transform, reproject, Resampling
import laspy
import requests
import os
from tqdm import tqdm
log = logging.getLogger(__name__)
def acquire_elevation_data(
watershed_shp: str,
target_epsg: int,
srtm_dir: str = "data/raw/srtm",
lidar_dir: str = "data/raw/lidar",
mosaic_path: str = "data/processed/dem_mosaic.tif",
max_lidar_datasets: int = 5,
) -> dict[str, Path | list[Path]]:
"""
End-to-end SRTM and LiDAR acquisition pipeline.
Parameters
----------
watershed_shp : path to the watershed boundary shapefile
target_epsg : EPSG code for the project CRS (e.g. 32618 for UTM 18N)
srtm_dir : staging directory for raw SRTM tiles
lidar_dir : staging directory for LAZ point clouds
mosaic_path : output path for the reprojected SRTM mosaic COG
max_lidar_datasets : maximum number of LiDAR datasets to download
Returns
-------
dict with keys 'mosaic', 'lidar_files', 'srtm_valid', 'lidar_valid'
"""
# -- 1. Spatial boundary
watershed = gpd.read_file(watershed_shp)
bbox = tuple(watershed.to_crs(epsg=4326).total_bounds)
log.info("Acquisition bbox (WGS84): %s", bbox)
# -- 2. SRTM retrieval
earthaccess.login()
results = earthaccess.search_data(
short_name="SRTMGL1",
bounding_box=bbox,
temporal=("2000-02-01", "2000-02-23"),
)
log.info("SRTM: %d granule(s) found", len(results))
Path(srtm_dir).mkdir(parents=True, exist_ok=True)
raw_srtm = [Path(f) for f in earthaccess.download(results, srtm_dir)]
# -- 3. LiDAR retrieval
minx, miny, maxx, maxy = bbox
params = {
"productFormat": "PointCloud",
"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy,
"detail": "true", "outputFormat": "json",
}
api_key = os.getenv("OPENTOP_API_KEY")
if api_key:
params["API_Key"] = api_key
resp = requests.get(
"https://portal.opentopography.org/API/otCatalog",
params=params, timeout=30
)
resp.raise_for_status()
datasets = resp.json().get("Datasets", [])[:max_lidar_datasets]
log.info("LiDAR: %d dataset(s) selected", len(datasets))
Path(lidar_dir).mkdir(parents=True, exist_ok=True)
lidar_files: list[Path] = []
for ds in datasets:
url = ds.get("downloadURL")
dsid = ds.get("datasetID", "unknown")
dest = Path(lidar_dir) / f"{dsid}.laz"
if dest.exists():
log.info("Skipping existing LAZ: %s", dest)
lidar_files.append(dest)
continue
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status()
total = int(r.headers.get("content-length", 0))
with open(dest, "wb") as fh, tqdm(total=total, unit="B",
unit_scale=True, desc=dsid) as bar:
for chunk in r.iter_content(chunk_size=65536):
fh.write(chunk)
bar.update(len(chunk))
log.info("Downloaded %s (%.1f MB)", dest, dest.stat().st_size / 1e6)
lidar_files.append(dest)
# -- 4. Mosaic and reproject SRTM
srcs = [rasterio.open(p) for p in raw_srtm]
mosaic_arr, mosaic_tf = merge(srcs)
dst_crs = f"EPSG:{target_epsg}"
dst_tf, width, height = calculate_default_transform(
srcs[0].crs, dst_crs,
mosaic_arr.shape[2], mosaic_arr.shape[1],
left=srcs[0].bounds.left, bottom=srcs[0].bounds.bottom,
right=srcs[-1].bounds.right, top=srcs[0].bounds.top,
)
profile = srcs[0].profile.copy()
profile.update({
"crs": dst_crs, "transform": dst_tf,
"width": width, "height": height,
"driver": "GTiff", "compress": "deflate",
"tiled": True, "blockxsize": 512, "blockysize": 512,
})
mosaic_out = Path(mosaic_path)
mosaic_out.parent.mkdir(parents=True, exist_ok=True)
with rasterio.open(mosaic_out, "w", **profile) as dst:
reproject(
source=mosaic_arr[0], destination=rasterio.band(dst, 1),
src_transform=mosaic_tf, src_crs=srcs[0].crs,
dst_transform=dst_tf, dst_crs=dst_crs,
resampling=Resampling.bilinear,
)
for src in srcs:
src.close()
log.info("SRTM mosaic: %s (%d×%d px)", mosaic_out, width, height)
# -- 5. Validate
srtm_valid = [p for p in raw_srtm if _validate_raster(p)]
lidar_valid = [p for p in lidar_files if _validate_lidar(p)]
log.info("Validation: %d/%d SRTM tiles OK, %d/%d LiDAR files OK",
len(srtm_valid), len(raw_srtm), len(lidar_valid), len(lidar_files))
return {
"mosaic": mosaic_out,
"lidar_files": lidar_files,
"srtm_valid": srtm_valid,
"lidar_valid": lidar_valid,
}
def _validate_raster(filepath: Path) -> bool:
try:
with rasterio.open(filepath) as src:
return src.count == 1 and src.nodata is not None and src.crs is not None
except Exception as exc:
log.error("Raster validation failed for %s: %s", filepath, exc)
return False
def _validate_lidar(filepath: Path, min_points: int = 1000) -> bool:
try:
with laspy.open(filepath) as las:
return las.header.point_count >= min_points
except Exception as exc:
log.error("LiDAR validation failed for %s: %s", filepath, exc)
return False
Validation Protocol
After acquisition, verify the mosaic and point clouds against independent reference data before any conditioning step.
SRTM mosaic checks:
- Open the mosaic in QGIS or with
rasterio.openand confirm the spatial extent matches the watershed bounding box — tile seams sometimes leave 1–2 pixel gaps at antimeridian crossings. - Compute a histogram of elevation values (
numpy.histogram) and flag any anomalous spikes near the nodata value (−32768) that indicate void clusters. - Compare a 1 km² test patch against known survey benchmarks or Copernicus DEM GLO-30 to catch systematic vertical biases above 10 m RMSE.
- Confirm the output CRS with
rio info --crs data/processed/dem_mosaic.tif— a missing authority code causes silent failures in downstream rasterio operations.
LiDAR point-cloud checks:
- Inspect point density: the LAZ header’s
point_countdivided by the survey footprint area should match the advertised density (typically 2–20 pts/m²). Values below 0.5 pts/m² suggest the download is incomplete or the wrong classification filter was applied. - Verify the vertical datum entry in the VLR (Variable Length Records). For US datasets,
laspyexposes this vialas.header.vlrs— look for a WKT or PROJ4 string containingNAVD88orNGVD29to confirm vertical reference before merging with SRTM. - Run a ground-return count:
(las.classification == 2).sum() / las.header.point_countshould exceed 0.1 (10%) for adequately filtered bare-earth data. Ratios below 0.05 indicate heavy canopy or incorrect classification.
Common Failure Modes & Optimization
Authentication token expiry mid-download. earthaccess tokens expire after one hour. For large tile sets, wrap the download loop in a function that calls earthaccess.login() once before each batch of 20 granules rather than once globally.
SRTM void tiles in mountainous terrain. The Shuttle Radar had radar shadow in steep valleys — affected tiles contain contiguous nodata patches that masquerade as valid pixels if nodata is −32768 and the valley floor genuinely reaches that elevation. Detect by counting pixels equal to src.nodata as a fraction of total area; patches exceeding 5% warrant void-filling with an alternative source before DEM Pit Filling Algorithms are applied.
CRS mismatch between LiDAR tiles from different survey years. Successive LiDAR campaigns over the same region sometimes use different horizontal datums (NAD83 vs NAD83(2011)). The difference is small (up to 1 m) but sufficient to create step discontinuities in merged DEMs. Check the WKT for NAD83(2011) identifiers and apply a datum-shift grid before rasterization.
Memory exhaustion during mosaic of high-resolution LiDAR. A 1 m LiDAR DEM covering 500 km² loads as a Float32 array of roughly 500 million pixels — 2 GB minimum. Use rasterio.merge with method="first" and chunked windowed writes, or switch to the spatial resolution tradeoffs strategy of resampling to 5 m before mosaic assembly.
Tile boundary seams after reprojection. Bilinear resampling at tile edges can introduce subtle intensity gradients if source tiles have inconsistent gain calibration. Write each reprojected tile to a virtual raster (.vrt) first and inspect seams at 1:1 zoom before finalising the mosaic.
OpenTopography API rate limits. Unauthenticated requests are capped at 500 per day. Add API_Key to every request and cache catalog JSON responses locally with a 24-hour TTL to avoid re-fetching metadata on repeated pipeline runs.
When to Use This vs. Alternatives
SRTM acquisition via earthaccess is the right choice for regional to continental basin analysis where 30 m resolution is sufficient and global coverage is required. Switch to the USGS 3DEP 1 m LiDAR workflow when the study area is within the contiguous US, Alaska, or Hawaii, and the hydraulic model requires sub-grid channel representation. For coastal and island terrain where neither SRTM nor domestic LiDAR is available, the Copernicus DEM GLO-30 (also 30 m, but with improved void filling in mountainous zones) is a drop-in replacement that uses the same rasterio-based mosaic workflow.
Once acquired, all three elevation sources follow the same conditioning path: apply DEM Pit Filling Algorithms to enforce hydrologic connectivity, then validate the flow network against NHD or comparable authoritative hydrography before moving to flow direction computation.
Related Topics
- Hydrology Data Preparation & DEM Processing — parent pipeline overview
- DEM Pit Filling Algorithms — conditioning acquired DEMs for flow routing
- Coordinate Reference System Alignment — resolving datum and projection mismatches across sources
- Spatial Resolution Tradeoffs — choosing resolution before acquisition and conditioning
- How to Download SRTM DEMs for Python Hydrology Workflows — detailed tile-selection and chunked-download implementation