Skip to content

Commit cac7e22

Browse files
scottstanieclaude
andcommitted
Add 3DEP_1M data source for native 1-meter resolution DEMs
The existing 3DEP source uses the ImageServer which resamples to ~30m. 3DEP_1M queries the TNM Access API for 1m tiles and fetches them as COGs from S3 via /vsicurl/. Uses a two-step GDAL warp: first reproject from source UTM to EPSG:4326, then convert NAVD88 to WGS84 ellipsoidal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5b342ba commit cac7e22

File tree

6 files changed

+288
-1
lines changed

6 files changed

+288
-1
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,21 @@ No authentication is required.
181181
sardem --bbox -104 30 -103 31 --data-source 3DEP
182182
```
183183
184+
## USGS 3DEP 1-Meter Lidar DEM
185+
186+
The `--data-source 3DEP_1M` option provides access to 1-meter resolution lidar-derived DEMs from the USGS 3DEP program. Tiles are discovered via the [TNM Access API](https://tnmaccess.nationalmap.gov/api/v1/products) and fetched as Cloud Optimized GeoTIFFs (COGs) from S3.
187+
188+
- **US coverage only** — not all areas have 1m lidar data. If no tiles are found, the command will raise an error.
189+
- **No authentication required.**
190+
- Output resolution matches the native ~1m resolution of the source tiles.
191+
- `--xrate`/`--yrate` upsampling is not supported (data is already high-resolution).
192+
193+
### Usage
194+
195+
```bash
196+
sardem --bbox -118.4 33.7 -118.3 33.8 --data-source 3DEP_1M
197+
```
198+
184199
## Citations and Acknowledgments
185200
186201
### Copernicus DEM

sardem/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def positive_small_int(argstring):
4141
sardem --bbox -156 18.8 -154.7 20.3 --data-source NASA_WATER -o my_watermask.wbd # Water mask
4242
sardem --bbox -156 18.8 -154.7 20.3 --data COP -isce # Generate .isce XML files as well
4343
sardem --bbox -104 30 -103 31 --data-source 3DEP # USGS 3DEP lidar DEM (US only)
44+
sardem --bbox -118.4 33.7 -118.3 33.8 --data-source 3DEP_1M # USGS 3DEP 1m lidar (US only)
4445
sardem --bbox -104 30 -103 31 --data-source NISAR # NISAR DEM (requires Earthdata login)
4546
4647

sardem/dem.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,25 @@ def main(
385385
utils.gdal2isce_xml(output_name, keep_egm=keep_egm)
386386
return
387387

388+
# For USGS 3DEP 1m, download tiles from S3 COGs via TNM API
389+
if data_source == "3DEP_1M":
390+
utils._gdal_installed_correctly()
391+
from sardem import usgs_3dep_1m
392+
393+
usgs_3dep_1m.download_and_stitch(
394+
output_name,
395+
bbox,
396+
keep_egm=keep_egm,
397+
xrate=xrate,
398+
yrate=yrate,
399+
output_format=output_format,
400+
output_type=output_type,
401+
)
402+
if make_isce_xml:
403+
logger.info("Creating ISCE2 XML file")
404+
utils.gdal2isce_xml(output_name, keep_egm=keep_egm)
405+
return
406+
388407
if data_source == "NISAR":
389408
if keep_egm:
390409
logger.info(

sardem/download.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ class Downloader:
199199
"https://elevation.nationalmap.gov/arcgis/rest/services"
200200
"/3DEPElevation/ImageServer/exportImage"
201201
),
202-
"NISAR": "https://nisar.asf.earthdatacloud.nasa.gov/NISAR/DEM/v1.2/EPSG4326/EPSG4326.vrt"
202+
"NISAR": "https://nisar.asf.earthdatacloud.nasa.gov/NISAR/DEM/v1.2/EPSG4326/EPSG4326.vrt",
203+
"3DEP_1M": "https://tnmaccess.nationalmap.gov/api/v1/products",
203204
}
204205
VALID_SOURCES = DATA_URLS.keys()
205206
TILE_ENDINGS = {

sardem/tests/test_usgs_3dep_1m.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import json
2+
3+
import pytest
4+
import responses
5+
6+
from sardem.usgs_3dep_1m import TNM_API_URL, _find_tile_urls
7+
8+
9+
def _make_tnm_response(items):
10+
"""Build a fake TNM API JSON response body."""
11+
return json.dumps({"items": items})
12+
13+
14+
def _make_item(url, date_created="2020-01-01"):
15+
"""Build a minimal TNM API item dict."""
16+
return {"downloadURL": url, "dateCreated": date_created}
17+
18+
19+
@responses.activate
20+
def test_find_tile_urls_success():
21+
"""Mock TNM API response, verify URL extraction and sorting."""
22+
items = [
23+
_make_item("https://prd-tnm.s3.amazonaws.com/tile_newer.tif", "2022-06-15"),
24+
_make_item("https://prd-tnm.s3.amazonaws.com/tile_older.tif", "2019-03-01"),
25+
]
26+
responses.add(
27+
responses.GET,
28+
TNM_API_URL,
29+
body=_make_tnm_response(items),
30+
status=200,
31+
content_type="application/json",
32+
)
33+
34+
urls = _find_tile_urls((-118.4, 33.7, -118.3, 33.8))
35+
36+
assert len(urls) == 2
37+
# Oldest first so newest overwrites in gdal.Warp
38+
assert "tile_older" in urls[0]
39+
assert "tile_newer" in urls[1]
40+
41+
42+
@responses.activate
43+
def test_find_tile_urls_empty():
44+
"""Mock empty response, verify RuntimeError."""
45+
responses.add(
46+
responses.GET,
47+
TNM_API_URL,
48+
body=_make_tnm_response([]),
49+
status=200,
50+
content_type="application/json",
51+
)
52+
53+
with pytest.raises(RuntimeError, match="No 3DEP 1m DEM tiles found"):
54+
_find_tile_urls((-118.4, 33.7, -118.3, 33.8))
55+
56+
57+
@responses.activate
58+
def test_find_tile_urls_http_error():
59+
"""Mock 500, verify exception."""
60+
responses.add(responses.GET, TNM_API_URL, status=500)
61+
62+
with pytest.raises(Exception):
63+
_find_tile_urls((-118.4, 33.7, -118.3, 33.8))

sardem/usgs_3dep_1m.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Download USGS 3DEP 1-meter DEM tiles from S3 via the TNM Access API.
2+
3+
Uses the USGS TNM (The National Map) Access API to discover which 1-meter
4+
DEM tiles cover the requested bounding box, then fetches them as Cloud
5+
Optimized GeoTIFFs (COGs) from S3 using GDAL's /vsicurl/ virtual filesystem.
6+
7+
Source tiles are in NAD83/UTM (varying zones) with NAVD88 heights, but without
8+
compound vertical CRS metadata. Horizontal reprojection to EPSG:4326 is always
9+
performed. When keep_egm=False (the default), a second GDAL warp converts
10+
NAVD88 heights to WGS84 ellipsoidal heights.
11+
12+
Coverage: US only -- not all areas have 1m lidar data available.
13+
14+
References:
15+
https://www.usgs.gov/3d-elevation-program
16+
https://tnmaccess.nationalmap.gov/api/v1/products
17+
"""
18+
19+
import logging
20+
import os
21+
import tempfile
22+
23+
import requests
24+
25+
from sardem import utils
26+
27+
logger = logging.getLogger("sardem")
28+
utils.set_logger_handler(logger)
29+
30+
TNM_API_URL = "https://tnmaccess.nationalmap.gov/api/v1/products"
31+
TNM_DATASET = "Digital Elevation Model (DEM) 1 meter"
32+
TNM_MAX_ITEMS = 200
33+
34+
35+
def download_and_stitch(
36+
output_name,
37+
bbox,
38+
keep_egm=False,
39+
xrate=1,
40+
yrate=1,
41+
output_format="GTiff",
42+
output_type="float32",
43+
):
44+
"""Download USGS 3DEP 1m DEM tiles and mosaic them with gdal.Warp.
45+
46+
Parameters
47+
----------
48+
output_name : str
49+
Path for the output DEM file.
50+
bbox : tuple
51+
(left, bottom, right, top) in decimal degrees.
52+
keep_egm : bool
53+
If True, keep NAVD88 geoid heights. If False (default), convert to
54+
WGS84 ellipsoidal heights.
55+
xrate : int
56+
Upsample factor in x direction (ignored for 1m data).
57+
yrate : int
58+
Upsample factor in y direction (ignored for 1m data).
59+
output_format : str
60+
GDAL output format (default GTiff).
61+
output_type : str
62+
Output pixel type (default float32).
63+
"""
64+
from osgeo import gdal
65+
66+
gdal.UseExceptions()
67+
68+
if xrate > 1 or yrate > 1:
69+
logger.warning(
70+
"xrate/yrate upsampling is ignored for 3DEP_1M (data is already"
71+
" ~1m resolution). xrate=%d, yrate=%d.",
72+
xrate,
73+
yrate,
74+
)
75+
76+
tile_urls = _find_tile_urls(bbox)
77+
vsicurl_paths = ["/vsicurl/" + url for url in tile_urls]
78+
logger.info("Found %d tile(s) covering the bounding box", len(vsicurl_paths))
79+
80+
out_type = gdal.GetDataTypeByName(output_type.title())
81+
82+
# Source tiles are in NAD83/UTM (various zones). Do NOT override srcSRS --
83+
# GDAL must read the CRS from each file's metadata for correct reprojection.
84+
reproject_opts = dict(
85+
format=output_format,
86+
outputBounds=list(bbox),
87+
dstSRS="EPSG:4326",
88+
outputType=out_type,
89+
resampleAlg="nearest",
90+
srcNodata=-999999,
91+
multithread=True,
92+
warpMemoryLimit=5000,
93+
warpOptions=["NUM_THREADS=4"],
94+
)
95+
96+
if keep_egm:
97+
logger.info("Creating %s (keeping NAVD88 heights)", output_name)
98+
reproject_opts["callback"] = gdal.TermProgress
99+
gdal.Warp(
100+
output_name, vsicurl_paths, options=gdal.WarpOptions(**reproject_opts)
101+
)
102+
else:
103+
# Two-step warp:
104+
# 1) Reproject from UTM to EPSG:4326 (horizontal only, preserves NAVD88 Z)
105+
# 2) Convert NAVD88 heights to WGS84 ellipsoidal using compound CRS.
106+
# After step 1 the file is in geographic coords, so
107+
# srcSRS="EPSG:4269+5703" (NAD83 + NAVD88) is correct.
108+
fd, tmp_path = tempfile.mkstemp(suffix=".tif")
109+
os.close(fd)
110+
try:
111+
logger.info("Reprojecting tiles to EPSG:4326...")
112+
reproject_opts["callback"] = gdal.TermProgress
113+
gdal.Warp(
114+
tmp_path, vsicurl_paths, options=gdal.WarpOptions(**reproject_opts)
115+
)
116+
117+
logger.info("Converting NAVD88 heights to WGS84 ellipsoidal...")
118+
vert_opts = dict(
119+
format=output_format,
120+
srcSRS="EPSG:4269+5703",
121+
dstSRS="EPSG:4326",
122+
outputType=out_type,
123+
multithread=True,
124+
callback=gdal.TermProgress,
125+
)
126+
gdal.Warp(
127+
output_name, tmp_path, options=gdal.WarpOptions(**vert_opts)
128+
)
129+
finally:
130+
if os.path.exists(tmp_path):
131+
os.remove(tmp_path)
132+
133+
134+
def _find_tile_urls(bbox):
135+
"""Query TNM Access API for 1m DEM tiles covering bbox.
136+
137+
Returns list of S3 download URLs, sorted oldest-first so
138+
newest tiles take priority in gdal.Warp overlap resolution.
139+
140+
Parameters
141+
----------
142+
bbox : tuple
143+
(left, bottom, right, top) in decimal degrees.
144+
145+
Returns
146+
-------
147+
list[str]
148+
S3 URLs to COG tiles.
149+
150+
Raises
151+
------
152+
RuntimeError
153+
If no tiles are found for the requested area.
154+
"""
155+
left, bottom, right, top = bbox
156+
params = {
157+
"datasets": TNM_DATASET,
158+
"bbox": "{},{},{},{}".format(left, bottom, right, top),
159+
"max": TNM_MAX_ITEMS,
160+
"outputFormat": "JSON",
161+
}
162+
163+
logger.info("Querying TNM API for 1m DEM tiles...")
164+
response = requests.get(TNM_API_URL, params=params, timeout=60)
165+
response.raise_for_status()
166+
167+
data = response.json()
168+
items = data.get("items", [])
169+
if not items:
170+
raise RuntimeError(
171+
"No 3DEP 1m DEM tiles found for bbox {}. "
172+
"Not all US areas have 1m coverage.".format(bbox)
173+
)
174+
175+
if len(items) >= TNM_MAX_ITEMS:
176+
logger.warning(
177+
"TNM API returned the maximum %d items. The bounding box may be"
178+
" too large to fetch all tiles in one request.",
179+
TNM_MAX_ITEMS,
180+
)
181+
182+
# Sort by dateCreated ascending (oldest first) so that when gdal.Warp
183+
# processes them in order, newer tiles overwrite older ones in overlaps
184+
items.sort(key=lambda item: item.get("dateCreated", ""))
185+
186+
urls = [item["downloadURL"] for item in items]
187+
logger.info("Found %d tiles from TNM API", len(urls))
188+
return urls

0 commit comments

Comments
 (0)