Comparing Elevation Measurements#
Once you have an area of interest with multiple overlapping DEMs and/or laser altimetry measurements, you can quickly compare elevation values. coincident supports a main plotting panel for elevation comparisons, as well as dynamic boxplots and histograms for elevation differences over various terrain characteristics and land cover types.
This notebook highlights the following functions:
coincident.plot.compare_dems
coincident.plot.boxplot_slope
coincident.plot.boxplot_elevation
coincident.plot.boxplot_aspect
coincident.plot.hist_esa
Warning
Plotting routines are currently only tested for comparing datasets in EPSG:7912 (ITRF 2014)
import coincident
import geopandas as gpd
from shapely.geometry import box
import rioxarray as rxr
import numpy as np
import matplotlib.pyplot as plt
from rasterio.enums import Resampling
/home/docs/checkouts/readthedocs.org/user_builds/coincident/checkouts/stable/src/coincident/io/download.py:25: TqdmExperimentalWarning: Using `tqdm.autonotebook.tqdm` in notebook mode. Use `tqdm.tqdm` instead to force console mode (e.g. in jupyter console)
from tqdm.autonotebook import tqdm
%matplotlib inline
# %config InlineBackend.figure_format = 'retina'
Load reference DEM#
workunit = "CO_WestCentral_2019"
df_wesm = coincident.search.wesm.read_wesm_csv()
gf_lidar = coincident.search.wesm.load_by_fid(
df_wesm[df_wesm.workunit == workunit].index
)
gf_lidar
| workunit | workunit_id | project | project_id | start_datetime | end_datetime | ql | spec | p_method | dem_gsd_meters | ... | seamless_category | seamless_reason | lpc_link | sourcedem_link | metadata_link | geometry | collection | datetime | dayofyear | duration | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | CO_WestCentral_2019 | 175984 | CO_WestCentral_2019_A19 | 175987 | 2019-08-21 | 2019-09-19 | QL 2 | USGS Lidar Base Specification 1.3 | linear-mode lidar | 1.0 | ... | Meets | Meets 3DEP seamless DEM requirements | https://rockyweb.usgs.gov/vdelivery/Datasets/S... | https://prd-tnm.s3.amazonaws.com/index.html?pr... | https://prd-tnm.s3.amazonaws.com/index.html?pr... | MULTIPOLYGON (((-106.13571 38.4146, -106.1702 ... | 3DEP | 2019-09-04 12:00:00 | 247 | 29 |
1 rows × 33 columns
# We will examine the 'sourcedem' 1m
# NOTE: it's important to note the *vertical* CRS of the data
gf_lidar.iloc[0]
workunit CO_WestCentral_2019
workunit_id 175984
project CO_WestCentral_2019_A19
project_id 175987
start_datetime 2019-08-21 00:00:00
end_datetime 2019-09-19 00:00:00
ql QL 2
spec USGS Lidar Base Specification 1.3
p_method linear-mode lidar
dem_gsd_meters 1.0
horiz_crs 6350
vert_crs 5703
geoid GEOID12B
lpc_pub_date 2021-01-30 00:00:00
lpc_update NaT
lpc_category Meets
lpc_reason Meets 3DEP LPC requirements
sourcedem_pub_date 2021-01-29 00:00:00
sourcedem_update NaT
sourcedem_category Meets
sourcedem_reason Meets 3DEP source DEM requirements
onemeter_category Meets
onemeter_reason Meets 3DEP 1-m DEM requirements
seamless_category Meets
seamless_reason Meets 3DEP seamless DEM requirements
lpc_link https://rockyweb.usgs.gov/vdelivery/Datasets/S...
sourcedem_link https://prd-tnm.s3.amazonaws.com/index.html?pr...
metadata_link https://prd-tnm.s3.amazonaws.com/index.html?pr...
geometry MULTIPOLYGON (((-106.1357105581 38.4146013052,...
collection 3DEP
datetime 2019-09-04 12:00:00
dayofyear 247
duration 29
Name: 0, dtype: object
# NOTE: since we're going to compare to satellite altimetry, let's convert first to EPSG:7912 !
# GDAL / PROJ will use the following transform by default
!projinfo -s EPSG:6350+5703 -t EPSG:7912 -q -o proj
+proj=pipeline
+step +inv +proj=aea +lat_0=23 +lon_0=-96 +lat_1=29.5 +lat_2=45.5 +x_0=0 +y_0=0
+ellps=GRS80
+step +proj=vgridshift +grids=us_noaa_g2018u0.tif +multiplier=1
+step +proj=cart +ellps=GRS80
+step +inv +proj=helmert +x=1.0053 +y=-1.90921 +z=-0.54157 +rx=0.02678138
+ry=-0.00042027 +rz=0.01093206 +s=0.00036891 +dx=0.00079 +dy=-0.0006
+dz=-0.00144 +drx=6.667e-05 +dry=-0.00075744 +drz=-5.133e-05
+ds=-7.201e-05 +t_epoch=2010 +convention=coordinate_frame
+step +inv +proj=cart +ellps=GRS80
+step +proj=unitconvert +xy_in=rad +xy_out=deg
+step +proj=axisswap +order=2,1
Now, let’s grab the vendor DEM products for two arbitrary tiles of this flight to look at elevation differences between the aerial LIDAR derved DEM, Copernicus GLO-30 DEM, ICESat-2 elevations, and GEDI elevations.
# We will rely on GDAL to do this reprojection
# gdalvsi = "/vsicurl?empty_dir=yes&url="
gdalvsi = "/vsicurl/"
url_tile_1 = f"{gdalvsi}https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/OPR/Projects/CO_WestCentral_2019_A19/CO_WestCentral_2019/TIFF/USGS_OPR_CO_WestCentral_2019_A19_be_w1032n1780.tif"
url_tile_2 = f"{gdalvsi}https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/OPR/Projects/CO_WestCentral_2019_A19/CO_WestCentral_2019/TIFF/USGS_OPR_CO_WestCentral_2019_A19_be_w1032n1779.tif"
# First build a mosaic in native 3D CRS
!gdalbuildvrt -q -a_srs EPSG:6350+5703 /tmp/mosaic.vrt '{url_tile_1}' '{url_tile_2}'
# Then reproject
!GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR gdalwarp -q -overwrite -t_srs EPSG:7912 -r bilinear /tmp/mosaic.vrt /tmp/mosaic_7912.tif
# Open our 'reference' DEM
da_lidar = rxr.open_rasterio("/tmp/mosaic_7912.tif", masked=True).squeeze()
da_lidar
<xarray.DataArray (y: 1917, x: 1465)> Size: 11MB
[2808405 values with dtype=float32]
Coordinates:
* y (y) float64 15kB 38.47 38.47 38.47 38.47 ... 38.46 38.46 38.46
* x (x) float64 12kB -108.0 -108.0 -108.0 ... -108.0 -108.0 -108.0
band int64 8B 1
spatial_ref int64 8B 0
Attributes:
AREA_OR_POINT: Area
scale_factor: 1.0
add_offset: 0.0# Plot a single DEM
ax = coincident.plot.plot_dem(da_lidar, title="USGS_OPR_CO_WestCentral_2019_A19");
Load secondary DEMs#
Note
Currently coincident facilitates loading select global DEMs in EPSG:7912, if your dataset is not in that CRS be sure to first reproject your data
# search geodataframe to grab the COP30 DEM
bbox_tile = da_lidar.rio.bounds()
poly_tile = box(*bbox_tile)
gf_tile = gpd.GeoDataFrame(geometry=[poly_tile], crs=da_lidar.rio.crs)
# NOTE: this is 30m data, but we are going to resample it to 1m to match our lidar
da_cop = coincident.io.xarray.load_dem_7912("cop30", aoi=gf_tile)
da_cop
<xarray.DataArray 'band_data' (y: 69, x: 53)> Size: 15kB
array([[1800.7252, 1800.2207, 1799.4016, ..., 1773.1454, 1770.5667,
1770.0399],
[1800.5754, 1799.8733, 1799.3422, ..., 1774.0353, 1771.1886,
1770.74 ],
[1800.5575, 1799.7347, 1799.4817, ..., 1772.8221, 1772.1632,
1771.3894],
...,
[1859.3857, 1858.9048, 1857.8425, ..., 1829.5653, 1828.9321,
1828.7308],
[1861.8115, 1861.497 , 1860.04 , ..., 1829.9324, 1830.4056,
1830.1598],
[1863.0483, 1862.5264, 1860.8612, ..., 1830.5692, 1830.6556,
1830.5651]], shape=(69, 53), dtype=float32)
Coordinates:
* y (y) float64 552B 38.47 38.47 38.47 38.47 ... 38.46 38.46 38.46
* x (x) float64 424B -108.0 -108.0 -108.0 ... -108.0 -108.0 -108.0
band int64 8B 1
spatial_ref int64 8B 0
Attributes:
AREA_OR_POINT: Point# Handy to have an informative name for plots
da_cop.name = "elevation"
da_cop_r = da_cop.rio.reproject_match(
da_lidar,
resampling=Resampling.bilinear,
).where(da_lidar.notnull())
da_cop_r
<xarray.DataArray 'elevation' (y: 1917, x: 1465)> Size: 11MB
array([[nan, nan, nan, ..., nan, nan, nan],
[nan, nan, nan, ..., nan, nan, nan],
[nan, nan, nan, ..., nan, nan, nan],
...,
[nan, nan, nan, ..., nan, nan, nan],
[nan, nan, nan, ..., nan, nan, nan],
[nan, nan, nan, ..., nan, nan, nan]],
shape=(1917, 1465), dtype=float32)
Coordinates:
* y (y) float64 15kB 38.47 38.47 38.47 38.47 ... 38.46 38.46 38.46
* x (x) float64 12kB -108.0 -108.0 -108.0 ... -108.0 -108.0 -108.0
band int64 8B 1
spatial_ref int64 8B 0
Attributes:
AREA_OR_POINT: Point
_FillValue: nan# Plot a single DEM with hillshade
# da_hillshade = coincident.io.gdal.gdaldem(da_cop_r, subcommand="hillshade")
da_hillshade = coincident.io.gdal.gdaldem(da_lidar, subcommand="hillshade")
ax = coincident.plot.plot_dem(
da_cop_r,
title="Resampled Copernicus DEM",
add_hillshade=True,
)
# Get GEDI
gf_gedi = coincident.search.search(
dataset="gedi",
intersects=gf_tile,
)
data_gedi = coincident.io.sliderule.subset_gedi02a(
gf_gedi, gf_tile, include_worldcover=True
)
# Get ICESAT-2
gf_is2 = coincident.search.search(
dataset="icesat-2",
intersects=gf_tile,
)
data_is2 = coincident.io.sliderule.subset_atl06(
gf_is2, gf_tile, include_worldcover=True
)
Alert <-7>: Failure on resource ATL06_20190913063941_11790402_007_02.h5 beam gt2l: H5Coro::Future read failure on gt2l/land_ice_segments/latitude
Alert <-7>: Failure on resource ATL06_20190913063941_11790402_007_02.h5 beam gt1l: H5Coro::Future read failure on gt1l/land_ice_segments/latitude
Alert <-7>: Failure on resource ATL06_20190913063941_11790402_007_02.h5 beam gt3r: H5Coro::Future read failure on gt3r/land_ice_segments/latitude
Alert <-7>: Failure on resource ATL06_20190913063941_11790402_007_02.h5 beam gt2r: H5Coro::Future read failure on gt2r/land_ice_segments/latitude
Alert <-7>: Failure on resource ATL06_20190913063941_11790402_007_02.h5 beam gt1r: H5Coro::Future read failure on gt1r/land_ice_segments/latitude
Alert <-7>: Failure on resource ATL06_20190913063941_11790402_007_02.h5 beam gt3l: H5Coro::Future read failure on gt3l/land_ice_segments/latitude
coincident.plot.plot_altimeter_points(
data_is2, "h_li", da_hillshade=da_hillshade, title="ICESat-2 ATL06 points"
);
Main Elevation Comparison Panel#
‘coincident’ supports a main compare_dems function that allows you to compare multiple DEMs and altimetry points in a single panel. This function dynamically adjusts based on the number of datasets provided.
Note
compare_dems assumes that all DEMs are in the same coordinate reference system (CRS) and aligned. A minimum of two DEMs must be provided and a maximum of 5 total datasets (datasets being DEMs and GeoDataFrames of altimetry points) can be provided.
# dictionary of DEMs to compare, where the first DEM in the list is the 'source' DEM
dem_dict = {"3DEP DTM": da_lidar, "COP30": da_cop_r}
# dictionary of GeoDataFrames with altimetry points to compare
# where the key is the name/plot title of the dataset and the value is a tuple of the respective
# GeoDataFrame and the column name of the elevation values of interest
dicts_gds = {"IS2 ATL06": (data_is2, "h_li"), "GEDI L2A": (data_gedi, "elevation_lm")}
axd = coincident.plot.compare_dems(
dem_dict,
dicts_gds,
add_hillshade=True,
altimetry_basemap="Esri.WorldImagery",
# altimetry_basemap="hillshade",
diff_clim=(-2.5, 2.5),
altimetry_pointsize=20,
)
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[18], line 7
3 # dictionary of GeoDataFrames with altimetry points to compare
4 # where the key is the name/plot title of the dataset and the value is a tuple of the respective
5 # GeoDataFrame and the column name of the elevation values of interest
6 dicts_gds = {"IS2 ATL06": (data_is2, "h_li"), "GEDI L2A": (data_gedi, "elevation_lm")}
----> 7 axd = coincident.plot.compare_dems(
8 dem_dict,
9 dicts_gds,
10 add_hillshade=True,
File ~/checkouts/readthedocs.org/user_builds/coincident/checkouts/stable/src/coincident/plot/matplotlib.py:517, in compare_dems(dem_dict, gdf_dict, add_hillshade, downsample_factor, altimetry_basemap, elevation_cmap, elevation_clim, diff_cmap, diff_clim, altimetry_pointsize, figsize, suptitle)
512 if gf.crs != first_dem.rio.crs:
513 error_message = (
514 f"All datasets must have the same CRS. "
515 f"GeoDataFrame {name} has CRS {gf.crs}, but first DEM has CRS {first_dem.rio.crs}"
516 )
--> 517 raise ValueError(error_message)
519 # Scale figure size with number of columns
520 if figsize is None:
ValueError: All datasets must have the same CRS. GeoDataFrame IS2 ATL06 has CRS {"$schema": "https://proj.org/schemas/v0.7/projjson.schema.json", "type": "GeographicCRS", "name": "ITRF2020", "datum": {"type": "DynamicGeodeticReferenceFrame", "name": "International Terrestrial Reference Frame 2020", "frame_reference_epoch": 2015, "ellipsoid": {"name": "GRS 1980", "semi_major_axis": 6378137, "inverse_flattening": 298.257222101}}, "coordinate_system": {"subtype": "ellipsoidal", "axis": [{"name": "Geodetic latitude", "abbreviation": "Lat", "direction": "north", "unit": "degree"}, {"name": "Geodetic longitude", "abbreviation": "Lon", "direction": "east", "unit": "degree"}, {"name": "Ellipsoidal height", "abbreviation": "h", "direction": "up", "unit": "metre"}]}, "scope": "Geodesy.", "area": "World.", "bbox": {"south_latitude": -90, "west_longitude": -180, "north_latitude": 90, "east_longitude": 180}, "id": {"authority": "EPSG", "code": 9989}}, but first DEM has CRS EPSG:7912
Dynamic Elevation Difference Boxplots Based on Terrain Variables (slope, elevation, aspect)#
coincident allows users to dynamically plot elevation differences based on grouped data such as slope values. In these functions (that are wrappers for the generalized coincident.plot.boxplot_terrain_diff function), the user provides a reference DEM and one other DEM or GeoDataFrame with elevation values to compare to. These function assumes that the reference DEM has the respective terrain variable (slope, aspect, etc.) of interest.
Note
Boxplots will only be generated with groups of value counts greater than 30. Dynamic ylims will be fit to the IQRs of the boxplots.
# These routines assume rectilinear data, so we first convert to UTM
crs_utm = da_lidar.rio.estimate_utm_crs()
ds_lidar_utm = da_lidar.rio.reproject(crs_utm).to_dataset(name="elevation")
ds_cop_utm = da_cop_r.rio.reproject(crs_utm).to_dataset(name="elevation")
slope = coincident.io.gdal.gdaldem(ds_lidar_utm.elevation, "slope")
ds_lidar_utm["slope"] = slope
_, ax = plt.subplots()
ds_lidar_utm.slope.plot.imshow(ax=ax)
ax.set_title("USGS_OPR_CO_WestCentral_2019_A19");
ax_boxplot_dems = coincident.plot.boxplot_slope([ds_lidar_utm, ds_cop_utm])
# we also support elevation differences based on elevation bins
# grouped on the reference DEM's elevation values
ax_elev = coincident.plot.boxplot_elevation(
[ds_lidar_utm, ds_cop_utm], elevation_bins=np.arange(1700, 2000, 20)
)
# lastly for the boxplots, let's look at aspect
aspect = coincident.io.gdal.gdaldem(ds_lidar_utm.elevation, "aspect")
ds_lidar_utm["aspect"] = aspect
_, ax = plt.subplots()
ds_lidar_utm.aspect.plot.imshow(ax=ax)
ax.set_title("USGS_OPR_CO_WestCentral_2019_A19");
ax_aspect = coincident.plot.boxplot_aspect([ds_lidar_utm, ds_cop_utm])
Histograms of Elevation Differences over Land Cover#
Plot elevation differences between DEMs or point data, grouped by ESA World Cover 2020 land cover class
Note
Histograms will only be generated with groups of value counts greater than a user-defined threshold (default is 30).
ax_lulc_cop30 = coincident.plot.hist_esa(
[da_lidar.to_dataset(name="elevation"), da_cop_r.to_dataset(name="elevation")]
)