Skip to article frontmatterSkip to article content

Author(s)

Karina Zikan, Zach Fair

Learning Outcomes

  • Gain experience in working with SlideRule to access and pre-process ICESat-2 data

  • Learn how to use projections and interpolation to compare ICESat-2 track data with gridded raster products

  • Develop a general understanding of how to measure snow depths with LiDAR, and learn about opportunities and challenges when using ICEsat-2 along-track products

Background

How do we measure snow depth with LiDAR?

LiDAR is a useful tool for collecting high resolution snow depth maps over large spatial areas.

Snow depth is measured from LiDAR by differencing a snow-free LiDAR map from a snow-covered LiDAR map of the same area of interest.

TODO: Insert Figure 6 from Deems et al. here

TODO: Insert Figure 7b from Deems et al

Can we do this with ICESat-2?

Yes! By differencing snow-covered ICESat-2 transects from snow-free maps, we can calculate snow depth!

Performing the calculation with ICESat-2 is a little different from other LiDAR snow depth methods, given that ICESat-2 is a transect of points rather than gridded raster data. ICESat-2 also has sparse coverage in the mid-latitudes, so generating an effective snow-covered or snow-free map will be difficult.

Because of these limitations, we need an independently-collected snow-free map of a region of interest for comparison. We also need to process the snow-free data into a form that can be differenced from snow-on ICESat-2 data.

In this tutorial, we will show an example of how to compare ICESat-2 data to raster data.

TODO: Add Karina’s image over Dry Creek

What do we need to calculate snow depth from ICESat-2?

  1. A region of interest, where snow-free (and snow-covered, for validation) digital elevation models (DEMs) are available.

  2. ICESat-2 data, ideally from one of the lower-level products (ATL03, ATL06, ATL08, Sliderule Earth).

  3. A snow-free reference DEM for the snow depth calculation.

What do we need to consider when comparing ICESat-2 and raster data?

Geolocation: To obtain usable results, it is important that we properly align the snow-free raster data with ICESat-2. Even small offsets can create large errors that worsen in rugged terrain.

TODO: Add Figure 9 from Nuth and Kabb

Vegetation: Incorrectly categorized vegetation returns can positively bias ground or snow surface height estimation. Additionally, dense vegetation can reduce the number of photon returns, thereby increasing uncertainty in our height estimates.

Slope Effects: Rugged terrain increases uncertainty in ICESat-2 returns and increases the impact of geolocation offsets between ICESat-2 and raster data. Additionally, steep slopes can negatively bias ground or snow surface height estimates.

Computing Environment

We’ll be using the following open source Python libraries in this notebook:

import numpy as np
import pandas as pd
import geopandas as gpd
import rioxarray as rxr
import matplotlib.pyplot as plt
from sliderule import icesat2, sliderule
from scipy.interpolate import RectBivariateSpline

Data

We will use SlideRule to acquire customized ATL06 data. Specific customizations that we will implement include footprint averaging (i.e., along-track sampling rate) and photon identification (signal/noise and ground/vegetation).

We will look at snow depth data over Upper Kuparuk/Toolik (UKT) on the Arctic North Slope of Alaska. Because UKT is a relatively flat region with little vegetation, we should expect good agreement between ICESat-2 and our rasters of interest.

Initialize SlideRule

icesat2.init("slideruleearth.io")

Define Region of Interest

After we initialize SlideRule, we define our region of interest. Notice that there are two options given below. This is because SlideRule accepts either the coordinates of a box/polygon or a geoJSON for its region input.

We are going to use the bounding box method in this tutorial, but the syntax for the geoJSON method is included for the user’s reference.

# Define region of interest over Toolik, Alaska
region = [{"lon":-149.5992624418217, "lat":68.63358948385529}, 
          {"lon":-149.5954459662985, "lat":68.60200878043223}, 
          {"lon":-149.2821268688734, "lat":68.60675802967609}, 
          {"lon":-149.2855031235162, "lat":68.63834638180673},
          {"lon":-149.5992624418217, "lat":68.63358948385529}]

print(region)
[{'lon': -149.5992624418217, 'lat': 68.63358948385529}, {'lon': -149.5954459662985, 'lat': 68.60200878043223}, {'lon': -149.2821268688734, 'lat': 68.60675802967609}, {'lon': -149.2855031235162, 'lat': 68.63834638180673}, {'lon': -149.5992624418217, 'lat': 68.63358948385529}]

Build SlideRule Request

Now we are going to build our SlideRule request by defining ICESat-2 parameters.

Since we want something close to the ATL06 product, we will use the icesat2.atl06p() function in this tutorial. You can find other SlideRule functions and more detail on the icesat2.atl06p() function on the SlideRule API reference. (TODO: Add link to API reference)

We won’t use every parameter in this tutorial, but here is a reference list for some of them. More information can be found in the SlideRule users guide (TODO: Add link to users guide)

## Build SlideRule request
# Define parameters (described below)
parms = {
    "poly": region,
    "srt": icesat2.SRT_LAND,
    "cnf": icesat2.CNF_SURFACE_HIGH,
    "atl08_class": ["atl08_ground"],
    "ats": 5.0,
    "len": 20.0,
    "res": 10.0,
    "maxi": 5
}

# Calculated ATL06 dataframe
is2_df = icesat2.atl06p(parms)

# Print SlideRule output
print(is2_df.head())
                                h_sigma      x_atc        y_atc  pflags  \
time                                                                      
2018-10-20 00:56:22.190070016  0.034847  7650522.0 -4500.666992       0   
2018-10-20 00:56:22.191480064  0.031803  7650532.0 -4500.652832       0   
2018-10-20 00:56:22.192890112  0.030313  7650542.0 -4500.643555       0   
2018-10-20 00:56:22.194302976  0.030625  7650552.0 -4500.636719       0   
2018-10-20 00:56:22.195713024  0.035575  7650562.0 -4500.632812       0   

                                   h_mean  dh_fit_dx  gt  segment_id  \
time                                                                   
2018-10-20 00:56:22.190070016  954.377483   0.046045  50      381674   
2018-10-20 00:56:22.191480064  954.520714  -0.019317  50      381675   
2018-10-20 00:56:22.192890112  954.356697  -0.011487  50      381675   
2018-10-20 00:56:22.194302976  954.276748  -0.007737  50      381676   
2018-10-20 00:56:22.195713024  954.128257  -0.017723  50      381676   

                               n_fit_photons  rms_misfit  spot  region  rgt  \
time                                                                          
2018-10-20 00:56:22.190070016             58    0.262867     2       3  327   
2018-10-20 00:56:22.191480064             48    0.219899     2       3  327   
2018-10-20 00:56:22.192890112             44    0.201046     2       3  327   
2018-10-20 00:56:22.194302976             36    0.183061     2       3  327   
2018-10-20 00:56:22.195713024             48    0.221270     2       3  327   

                               w_surface_window_final  cycle  \
time                                                           
2018-10-20 00:56:22.190070016                     3.0      1   
2018-10-20 00:56:22.191480064                     3.0      1   
2018-10-20 00:56:22.192890112                     3.0      1   
2018-10-20 00:56:22.194302976                     3.0      1   
2018-10-20 00:56:22.195713024                     3.0      1   

                                                  geometry  
time                                                        
2018-10-20 00:56:22.190070016  POINT (-149.49739 68.60363)  
2018-10-20 00:56:22.191480064  POINT (-149.49742 68.60372)  
2018-10-20 00:56:22.192890112  POINT (-149.49745 68.60381)  
2018-10-20 00:56:22.194302976   POINT (-149.49748 68.6039)  
2018-10-20 00:56:22.195713024  POINT (-149.49751 68.60399)  

Subsetting the Data

One may notice that the algorithm took a long time to generate the GeoDataFrame. That is because (i) our region of interest was rather large and (ii) we obtained all ICESat-2 tracks in the ROI since its launch (2018).

For the sake of interest, let’s take a look at all of the ICESat-2 tracks over Upper Kuparuk/Toolik.

import contextily as ctx
from shapely.geometry import Polygon

# Convert region to a Polygon
coords = [(point["lon"], point["lat"]) for point in region]
polygon = Polygon(coords)
region_gdf = gpd.GeoDataFrame([1], geometry=[polygon], crs="EPSG:4326")

# Reproject to Web Mercator for contextily
is2_df_mercator = is2_df.to_crs(epsg=3857)
region_mercator = region_gdf.to_crs(epsg=3857)


fig, ax = plt.subplots(figsize=(12, 8))
# Plot surface height
is2_df_mercator.plot(column='h_mean', 
                  ax=ax, 
                  cmap='viridis',
                  legend=True,
                  markersize=10,
                  alpha=0.8)

# Plot the region bounding box
region_mercator.plot(ax=ax, 
                     facecolor='none', 
                     edgecolor='red', 
                     linewidth=2)

# Add ESRI World Imagery basemap
ctx.add_basemap(ax, 
                crs=is2_df_mercator.crs, 
                source=ctx.providers.Esri.WorldImagery)
plt.tight_layout()
plt.show()
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[5], line 1
----> 1 import contextily as ctx
      2 from shapely.geometry import Polygon
      4 # Convert region to a Polygon

ModuleNotFoundError: No module named 'contextily'

It is cool to see all of the available data, but we only have snow-free lidar DEMs available from March 2022. So, we are going to subset the data to include one ICESat-2 track (RGT 152) in March 2023.

# Subset ICESat-2 data to single RGT, time of year
is2_df_subset = is2_df[is2_df['rgt']==152]
is2_df_subset = is2_df_subset.loc['2023-03-31']

# Display top of dataframe
print(is2_df_subset.head())

Sample the Lidar DTM to ICESat-2 ground track

The ICESat-2 data is ready to go! Now it’s time to load the airborne lidar data, and co-register it with ICESat-2.

The lidar data used here is from the University of Alaska, Fairbanks (UAF). The UAF lidar obtains snow-on and snow-off DEMs/digital terrain models (DTMs) with a 1064 nm (near-infrared) laser, from which it can also derive snow depth.

UAF lidar rasters normally have a spatial resolution of 0.5 m, which can take a long time to process. As a compromise between computation speed and resolution, we will coarsen the rasters to 3 m resolution.

The best way to handle lidar DEMs/DTMs is through rioxarray:

TODO: Access UAF lidar data without needing it locally.

import earthaccess
earthaccess.login(strategy='interactive', persist=True)
auth = earthaccess.login()
# Coordinates for SW/NE corners
lon_min = min([coord[0] for coord in coords])
lat_min = min([coord[1] for coord in coords])
lon_max = max([coord[0] for coord in coords])
lat_max = max([coord[1] for coord in coords])

# Data query for lidar point clouds over Fairbanks, AK
results = earthaccess.search_data(
    short_name='SNEX23_Lidar',
    bounding_box = (lon_min, lat_min, lon_max, lat_max),
    temporal = ('2023-03-13', '2023-03-14')
)
# File paths for UAF rasters (TODO: Add lidar files, and update names)
tifpath = "/your/path/here"
f_snow_off = f"{tifpath}/uaf_lidar_snowoff.tif"
f_snow_on = f"{tifpath}/uaf_lidar_snowon.tif"

# Load files as rioxarray datasets
lidar_snow_off = rxr.open_rasterio(f_snow_off)
lidar_snow_on = rxr.open_rasterio(f_snow_on)

It is not immediately obvious, but the uAF rasters are in a different spatial projection than ICESat-2. UAF is in EPSG:32606, and ICESat-2 is in WGS84/EPSG:4326.

In order to directly compare these two datasets, we are going to add reprojected coordinates to the ICESat-2 GeoDataFrame. In essence, we will go from latitude/longitude to northing/easting. Luckily, there is an easy way to do this with GeoPandas, specifically with the geopandas.to_crs() function.

# Initialize ICESat-2 coordinate projection
is2_df_subset = is2_df_subset.set_crs("EPSG:4326")

# Change to EPSG:32606
is2_df_subset = is2_df_subset.to_crs("EPSG:32606")

# Display top of dataframe
print(is2_df_subset.head())

Co-register rasters and ICESat-2

Now, we are going to co-register both rasters to the queried ICESat-2 data. The function below is fairly long, but the gist is that we a re using a spline interpolant to match both the snow-off UAF data (surface height) and UAF snow depths with ICESat-2 surface heights. The resulting GeoDataFrame will have both ICESat-2 and UAF data in it.

# Make coregistration function
def coregister_is2(lidar_snow_off, lidar_snow_on, is2_df):
    """
    Co-registers UAF data with ICESat-2 data with a rectangular 
    bivariate spline.

    Parameters
    ------------
    lidar_snow_off: rioxarray dataset
        Lidar DEM/DTM in rioxarray format.
    lidar_snow_on: rioxarray dataset
        Lidar-derived snow depth in rioxarray format.
    is2_df: GeodataFrame
        GeoDataFrame for the ICESat-2 data generated with SlideRule.

    Returns
    ------------
    is2_uaf_df: GeoDataFrame
        Contains the coordinate and elevation data that matches best
        between ICESat-2 and UAF.
    """

    # Helper function to prepare lidar data
    def prepare_lidar_data(raster):
        coords_x = np.array(raster.x)
        coords_y = np.array(raster.y)
        values = np.array(raster.sel(band=1))[::-1, :]
        values[np.isnan(values)] = -9999
        return coords_x, coords_y, values

    # Get coordinates and height/depth values from lidar data
    x0, y0, dem_heights = prepare_lidar_data(lidar_snow_off)
    xs, ys, dem_depths = prepare_lidar_data(lidar_snow_on)

    # Generate interpolators
    interp_height = RectBivariateSpline(y0[::-1], x0, dem_heights)
    interp_height = RectBivariateSpline(ys[::-1], xs, dem_depths)

    # Pre-filter IS2 data to bounds (apply once instead of per beam)
    x_bounds = (is2_df.geometry.x > np.min(x0)) & (is2_df.geometry.x < np.max(x0))
    y_bounds = (is2_df.geometry.y > np.min(y0)) & (is2_df.geometry.y < np.max(y0))
    is2_filtered = is2_df[x_bounds & y_bounds].copy()

    if is2_filtered.empty:
        print('Error with GeoDataFrame or raster bounds.')
        return gpd.GeoDataFrame()

    # Extract coordinates once
    xn = is2_filtered.geometry.x.values
    yn = is2_filtered.geometry.y.values

    # Estimate lidar height and snow depth at the ICESat-2 coordinates
    lidar_heights = interp_height(yn, xn, grid=False)
    lidar_snow_depths = interp_depth(yn, xn, grid=False)

    # Create result DataFrame in one operation
    is2_uaf_df = gpd.GeoDataFrame({
        'x': xn,
        'y': yn,
        'time': is2_filtered.index.values,
        'beam': is2_filtered['gt'].values,
        'lidar_height': lidar_heights,
        'lidar_snow_depth': lidar_snow_depths,
        'is2_height': is2_filtered['h_mean'].values,
        'h_sigma': is2_filtered['h_sigma'].values,
        'dh_fit_dx': is2_filtered['dh_fit_dx'].values
    })

    # Add coordinate transformation
    transformer = Transformer.from_crs("EPSG:32606", "EPSG:4326", always_xy=True)
    is2_uaf_df['lon'], is2_uaf_df['lat'] = transformer.transform(
        is2_uaf_df['x'], is2_uaf_df['y']
    )

    return is2_uaf_df
# Co-locate ICESat-2 and UAF using the above function
is2_uaf_df = coregister_is2(lidar_snow_off, 
                            lidar_snow_on, 
                            is2_df_subset
                           )
# Convert to a GeoDataFrame
geom = gpd.points_from_xy(is2_uaf_df.lon, is2_uaf_df.lat)
is2_uaf_gdf = gpd.GeoDataFrame(is2_uaf_df,
                               geometry=geom,
                               crs="EPSG:4326"
                              )
# Print head of GeoDataFrame
is2_uaf_gdf.head()

As one can see, we now have a GeoDataFrame that includes several useful variables:

  • beam: ICESat-2 beam (gt1l, gt2l, etc.)

  • lidar_height: Snow-off surface height from UAF lidar.

  • lidar_snow_depth: Snow depth derived from UAF.

  • is2_height: ICESat-2 surface height (snow-on, in this case).

  • h_sigma: ICESat-2 height uncertainty.

  • dh_fit_dx: Along-track slope of the terrain.

With this GeoDataFrame, it is very simple to derive snow depth!

# Derive snow depth using snow-on/snow-off differencing
is2_uaf_gdf['is2_snow_depth'] = is2_uaf_gdf['is2_height'] - is2_uaf_gdf['lidar_height']

# Estimate the residual (bias) between IS-2 and UAF depths
is2_uaf_gdf['snow_depth_residual'] = is2_uaf_gdf['is2_snow_depth'] - is2_uaf_gdf['lidar_snow_depth']

Horray! We finally have ICESat-2 snow depths! Let’s make a couple of plots with the data we have.

Map Plot

# Create plot
fig, ax = plt.subplots(figsize=(12, 8))
# Plot surface height
is2_uaf_gdf.to_crs("EPSG:3857").plot(column='is2_snow_depth', 
                  ax=ax, 
                  cmap='viridis',
                  legend=True,
                  markersize=10,
                  alpha=0.8)

# Plot the region bounding box
region_mercator.plot(ax=ax, 
                     facecolor='none', 
                     edgecolor='red', 
                     linewidth=2)

# Add ESRI World Imagery basemap
ctx.add_basemap(ax, 
                crs="EPSG:3857", 
                source=ctx.providers.Esri.WorldImagery)
plt.tight_layout()
plt.show()

Along-track plots

# Plot snow depths for the three strong beams
fig,axs = plt.subplots(3)

#Left strong beam
tmp_df = is2_uaf_gdf[is2_uaf_gdf['beam']==10]
axs[0].plot(tmp_df['lat'], tmp_df['is2_snow_depth'], label='ICESat-2')
axs[0].plot(tmp_df['lat'], tmp_df['lidar_snow_depth'], label='UAF')
axs[0].set_title('gt1l')
axs[0].legend()

# Central strong beam
tmp_df = is2_uaf_gdf[is2_uaf_gdf['beam']==30]
axs[1].plot(tmp_df['lat'], tmp_df['is2_snow_depth'])
axs[1].plot(tmp_df['lat'], tmp_df['lidar_snow_depth'])
axs[1].set_ylabel('Snow depth [m]', fontsize=18)
axs[1].set_title('gt2l')

# Right strong beam
tmp_df = is2_uaf_gdf[is2_uaf_gdf['beam']==50]
axs[2].plot(tmp_df['lat'], tmp_df['is2_snow_depth'])
axs[2].plot(tmp_df['lat'], tmp_df['lidar_snow_depth'])
axs[2].set_xlabel('Latitude [m]', fontsize=18)
axs[2].set_title('gt3l')
plt.tight_layout()

# Only include outer axis labels
for ax in axs:
    ax.label_outer()
    ax.set_ylim([0, 1.5])

Scatter Plot

fig, ax = plt.subplots(figsize=(12,6))
s = is2_uaf_gpd.plot.scatter(ax=ax,
                             x='lidar_snow_depth',
                             y='is2_snow_depth',
                             c='snow_depth_residual',
                             vmin=-0.5, vmax=0.5
                            )
ax.set_xlabel("UAF snow depth [m]", fontsize=18)
ax.set_ylabel("ICESat-2 snow depth [m]", fontsize=18)
ax.set_xlim([0, 1.5])
ax.set_ylim([0, 1.5])
cbar = fig.colorbar(s, ax=ax)
cbar.set_label("Snow depth residual [m]")
plt.tight_layout()

Summary

🎉 Congratulations! You have completed this tutorial and have seen how to compare ICESat-2 to raster data, how to obtain ICESat-2 data with SlideRule, and how to calculate snow depths from ICESat-2 data.

Reference

To further explore the topics of this tutorial, see the following detailed documentation: (TODO: ADD LINKS TO SITES AND PAPERS)

  • SlideRule Website

  • SlideRule online demo

Papers

Deems, Jeffrey S., et al. “Lidar Measurement of Snow Depth: A Review.” Journal of Glaciology, vol. 59, no. 215, 2013, pp. 467–79. DOI.org (Crossref), Deems et al. (2013).

Nuth, C., and A. Kääb. “Co-Registration and Bias Corrections of Satellite Elevation Data Sets for Quantifying Glacier Thickness Change.” The Cryosphere, vol. 5, no. 1, Mar. 2011, pp. 271–90. DOI.org (Crossref), Nuth & Kääb (2011).

References
  1. Deems, J. S., Painter, T. H., & Finnegan, D. C. (2013). Lidar measurement of snow depth: a review. Journal of Glaciology, 59(215), 467–479. 10.3189/2013jog12j154
  2. Nuth, C., & Kääb, A. (2011). Co-registration and bias corrections of satellite elevation data sets for quantifying glacier thickness change. The Cryosphere, 5(1), 271–290. 10.5194/tc-5-271-2011