# Import required libraries
import os
import json
import folium
import earthaccess
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
from branca.element import Figure
from shapely.geometry import MultiPolygon, Polygon, box
from shapely.geometry.polygon import orient
from datetime import timedelta
1 Finding Concurrent ECOSTRESS and EMIT Data
This notebook is part of a numbered series (01-05) that focuses specifically on using EMIT and ECOSTRESS Data together to prepare for future SBG research.
Summary
Both the ECOsystem Spaceborne Thermal Radiometer Experiment on Space Station (ECOSTRESS) and the Earth surface Mineral dust source InvesTigation (EMIT) instruments are located on the International Space Station (ISS). Their overlapping fields of view provide an unprecedented opportunity to demonstrate the compounded benefits of working with both datasets. In this notebook we will show how to search for data using the earthaccess
Python library, then how to filter down results to find concurrently acquired ECOSTRESS and EMIT data. Another notebook offering similar functionality via a different approach is available: Finding Coincident Airborne and Orbital Data.
Background
The ECOSTRESS instrument is a multispectral thermal imaging radiometer designed to answer three overarching science questions:
- How is the terrestrial biosphere responding to changes in water availability?
- How do changes in diurnal vegetation water stress the global carbon cycle?
- Can agricultural vulnerability be reduced through advanced monitoring of agricultural water consumptive use and improved drought estimation?
The ECOSTRESS mission is answering these questions by accurately measuring the temperature of plants. Plants regulate their temperature by releasing water through tiny pores on their leaves called stomata. If they have sufficient water they can maintain their temperature, but if there is insufficient water, their temperatures rise and this temperature rise can be measured with ECOSTRESS. The images acquired by ECOSTRESS are the most detailed temperature images of the surface ever acquired from space and can be used to measure the temperature of an individual farmers field.
More details about ECOSTRESS and its associated products can be found on the ECOSTRESS website and ECOSTRESS product pages hosted by the Land Processes Distributed Active Archive Center (LP DAAC).
The EMIT instrument is an imaging spectrometer that measures light in visible and infrared wavelengths. These measurements display unique spectral signatures that correspond to the composition on the Earth’s surface. The EMIT mission focuses specifically on mapping the composition of minerals to better understand the effects of mineral dust throughout the Earth system and human populations now and in the future. In addition, the EMIT instrument can be used in other applications, such as mapping of greenhouse gases, snow properties, and water resources.
More details about EMIT and its associated products can be found on the EMIT website and EMIT product pages hosted by the LP DAAC.
Requirements
- NASA Earthdata Account
- No Python setup requirements if connected to the workshop cloud instance!
- Local Only Set up Python Environment - See setup_instructions.md in the /setup/
folder to set up a local compatible Python environment
Learning Objectives
- How to use earthaccess
to find concurrent EMIT and ECOSTRESS data.
- How to export a list of files and download them programmatically.
Tutorial Outline
- Setup
- Searching for EMIT and ECOSTRESS Data
- Organizing and Filtering Results
- Visualizing Intersecting Coverage
- Creating a list of Results and Desired Asset URLs
- Streaming or Downloading Data
1. Setup
Import the required Python libraries.
1.2 NASA Earthdata Login Credentials
To download or stream NASA data you will need an Earthdata account, you can create one here. We will use the login
function from the earthaccess
library for authentication before downloading at the end of the notebook. This function can also be used to create a local .netrc
file if it doesn’t exist or add your login info to an existing .netrc
file. If no Earthdata Login credentials are found in the .netrc
you’ll be prompted for them. This step is not necessary to conduct searches but is needed to download or stream data.
2. Searching for ECOSTRESS and EMIT Data
In this example we wish to find concurrent (coincident) EMIT_L2A_RFL and ECOSTRESS_L2T_LSTE products available from the NASA Earthdata Cloud. Conceptually, we know almost all scenes should have a corresponding scene from the other instrument since they are on the same platform. To search for available granules (scenes), we will use the earthaccess Python library, which is designed to simplify authentication and searching for NASA earth science data.
After finding the available scenes matching our search critera, we will convert our results to a GeoDataFrame
and then do some filtering using the available metadata to narrow down our scenes to only those with corresponding scenes collected at the same time from the other instrument.
2.1 Define Spatial Region of Interest
For this example, our spatial region of interest (ROI) will be the a region near Santa Barbara, CA that contains the Jack and Laura Dangermond Preserve and the Sedgwick Reserve.
We will create a rectangular ROI surrounding these two reserves as well as some of the agricultural region between. Even though the shape is rectangular, we elect to search using a polygon rather than a standard bounding box in earthaccess
because typically, bounding boxes will typically have a larger spatial extent, capturing a lot of area we may not be interested in. This becomes more important for searches with larger ROIs than our example here. To search using a polygon with earthaccess, we need to format our ROI as a counter-clockwise list of coordinate pairs.
Open the geojson
file containing the Dangermond and Sedgwick boundaries as a geodataframe
, and check the coordinate reference system (CRS) of the data.
= gpd.read_file('../data/agu_workshop_roi.geojson')
polygon polygon.crs
<Geographic 2D CRS: EPSG:4326>
Name: WGS 84
Axis Info [ellipsoidal]:
- Lat[north]: Geodetic latitude (degree)
- Lon[east]: Geodetic longitude (degree)
Area of Use:
- name: World.
- bounds: (-180.0, -90.0, 180.0, 90.0)
Datum: World Geodetic System 1984 ensemble
- Ellipsoid: WGS 84
- Prime Meridian: Greenwich
The CRS is EPSG:4326 (WGS84), which is also the CRS we want the data in to submit for our search.
Next, let’s examine our polygon a bit closer.
polygon
Name | Acreage | geometry | |
---|---|---|---|
0 | Sedgwick Reserve | 5874.210000 | POLYGON ((-120.04041 34.74342, -120.04122 34.7... |
1 | Dangermond Preserve | 24458.615397 | POLYGON ((-120.47367 34.56987, -120.47427 34.5... |
We can see this geodataframe
consists of two polygons, that we want to include in our study site. We need to create an exterior boundary polygon containing these, and make sure the vertices are in counterclockwise order to submit them in our query. To do this, create a polygon consisting of all the geometries, then create a bounding rectangle. This will give us a simple exterior polygon around our two ROIs. After that, use the orient
function to place our coordinate pairs in counterclockwise order.
# Merge all Polygon geometries and create external boundary
= polygon.union_all().envelope
roi_poly # Re-order vertices to counterclockwise
= orient(roi_poly, sign=1.0) roi_poly
Make a GeoDataFrame
consisting of the bounding box geometry.
= pd.DataFrame({"Name":["ROI Bounding Box"]})
df = gpd.GeoDataFrame({"Name":["ROI Bounding Box"], "geometry":[roi_poly]},crs="EPSG:4326")
agu_bbox agu_bbox
Name | geometry | |
---|---|---|
0 | ROI Bounding Box | POLYGON ((-120.49929 34.4423, -120.01175 34.44... |
We can write this bounding box to a file for use in future notebooks.
'../data/roi_bbox.geojson', driver='GeoJSON') agu_bbox.to_file(
= polygon.total_bounds minx, miny, maxx, maxy
minx
np.float64(-120.499289029747)
We can go ahead and visualize our region of interest and the exterior boundary polygon containing ROIs.
= Figure(width="750px", height="375px")
fig_roi = folium.Map(tiles='https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', attr='Google', name='Google')
map_roi
fig_roi.add_child(map_roi)
# Add Bounding Box Polygon
folium.GeoJson(agu_bbox,='bounding_box',
name
).add_to(map_roi)
# Add roi geodataframe
polygon.explore("Name",
=True,
popup=True,
categorical='Set3',
cmap=dict(opacity=0.7, fillOpacity=0.4),
style_kwds="Regions of Interest",
name=map_roi
m
)
# Add Layer Control
map_roi.add_child(folium.LayerControl())# Convert BBOX ROI
= polygon.total_bounds
minx, miny, maxx, maxy # Set Map Bounds
=((miny, minx), (maxy, maxx)))
map_roi.fit_bounds(bounds fig_roi
Above we can see our regions of interest (ROIs) and the exterior boundary polygon containing the ROIs that we opened. We can hover over different areas to see the name of each ROI.
Lastly, we need to convert our polygon to a list of coordinate pairs, so it will be accepted as a ‘polygon’ search parameter in earthaccess
, as it expects a list of coordinate pairs in a counterclockwise order.
# Set ROI as list of exterior polygon vertices as coordinate pairs
= list(roi_poly.exterior.coords) roi
2.2 Define Collections of Interest
We need to specify which products we want to search for. The best way to do this is using their concept-id. As mentioned above, we will conduct our search using the EMIT Level 2A Reflectance (EMITL2ARFL) and ECOSTRESS Level 2 Tiled Land Surface Temperature and Emissivity (ECO_L2T_LSTE). We can do some quick collection queries using earthaccess
to retrieve the concept-id for each dataset.
Note: Here we use the Tiled ECOSTRESS LSTE Product. This will also work with the gridded LSTE and the swath; however, the swath product does not have a browse image for the visualization in section 4 and will require additional processing for subsequent analysis.
# EMIT Collection Query
= earthaccess.collection_query().keyword('EMIT L2A Reflectance')
emit_collection_query 'ShortName','EntryTitle','Version']).get() emit_collection_query.fields([
[{
"meta": {
"concept-id": "C2408750690-LPCLOUD",
"granule-count": 151308,
"provider-id": "LPCLOUD"
},
"umm": {
"ShortName": "EMITL2ARFL",
"EntryTitle": "EMIT L2A Estimated Surface Reflectance and Uncertainty and Masks 60 m V001",
"Version": "001"
}
}]
# ECOSTRESS Collection Query
= earthaccess.collection_query().keyword('ECOSTRESS L2 Tiled LSTE')
eco_collection_query 'ShortName','EntryTitle','Version']).get() eco_collection_query.fields([
[{
"meta": {
"concept-id": "C2076090826-LPCLOUD",
"granule-count": 12900789,
"provider-id": "LPCLOUD"
},
"umm": {
"ShortName": "ECO_L2T_LSTE",
"EntryTitle": "ECOSTRESS Tiled Land Surface Temperature and Emissivity Instantaneous L2 Global 70 m V002",
"Version": "002"
}
},
{
"meta": {
"concept-id": "C2076114664-LPCLOUD",
"granule-count": 686028,
"provider-id": "LPCLOUD"
},
"umm": {
"ShortName": "ECO_L2_LSTE",
"EntryTitle": "ECOSTRESS Swath Land Surface Temperature and Emissivity Instantaneous L2 Global 70 m V002",
"Version": "002"
}
},
{
"meta": {
"concept-id": "C2090073749-LPCLOUD",
"granule-count": 1748239,
"provider-id": "LPCLOUD"
},
"umm": {
"ShortName": "ECO_L2T_STARS",
"EntryTitle": "ECOSTRESS Tiled Ancillary NDVI and Albedo L2 Global 70 m V002",
"Version": "002"
}
}]
If your search returns multiple products, be sure to select the right concept-id For this example it will be the first one. We want to use the LPCLOUD
ECOSTRESS Tiled Land Surface Temperature and Emissivity (concept-id: “C2076090826-LPCLOUD”). Create a list of these concept-ids for our data search.
# Data Collections for our search
# EMIT/ECO Tiled
= ['C2408750690-LPCLOUD', 'C2076090826-LPCLOUD']
concept_ids # # EMIT/ECO Swath
# concept_ids = ['C2408750690-LPCLOUD', 'C2076114664-LPCLOUD']
2.3 Define Date Range
For our date range, we’ll look at data collected between January and October 2023. The date_range
can be specified as a pair of dates, start and end (up to, not including).
# Define Date Range
= ('2023-01-01','2023-11-01') date_range
2.4 Searching
Submit a query using earthaccess
.
= earthaccess.search_data(
results =concept_ids,
concept_id=roi,
polygon=date_range,
temporal
)print(f"Granules Found: {len(results)}")
Granules Found: 632
Show the first 2 items from the list to see the format of the results.
2] results[:
[Collection: {'ShortName': 'ECO_L2T_LSTE', 'Version': '002'}
Spatial coverage: {'HorizontalSpatialDomain': {'Geometry': {'BoundingRectangles': [{'WestBoundingCoordinate': -120.295181, 'EastBoundingCoordinate': -119.065819, 'NorthBoundingCoordinate': 35.225021, 'SouthBoundingCoordinate': 34.210026}]}}}
Temporal coverage: {'RangeDateTime': {'BeginningDateTime': '2023-01-01T15:52:48.650Z', 'EndingDateTime': '2023-01-01T15:53:40.620Z'}}
Size(MB): 2.49626
Data: ['https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01_water.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01_cloud.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01_view_zenith.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01_height.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01_QC.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01_LST.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01_LST_err.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0710_01_EmisWB.tif'],
Collection: {'ShortName': 'ECO_L2T_LSTE', 'Version': '002'}
Spatial coverage: {'HorizontalSpatialDomain': {'Geometry': {'BoundingRectangles': [{'WestBoundingCoordinate': -120.29518354417259, 'EastBoundingCoordinate': -119.06581640006657, 'NorthBoundingCoordinate': 35.22502010920582, 'SouthBoundingCoordinate': 34.210027193369996}]}}}
Temporal coverage: {'RangeDateTime': {'BeginningDateTime': '2023-01-01T15:52:48.650Z', 'EndingDateTime': '2023-01-01T15:53:40.620Z'}}
Size(MB): 2.38
Data: ['https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02_water.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02_cloud.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02_view_zenith.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02_height.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02_QC.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02_LST.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02_LST_err.tif', 'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02/ECOv002_L2T_LSTE_25460_016_11SKU_20230101T155248_0712_02_EmisWB.tif']]
3. Organizing and Filtering Results
As we can see from above, the results object contains a list of objects with metadata and links. We can convert this to a more readable format, a dataframe. In addition, we can make it a GeoDataFrame
by taking the spatial metadata and creating a shapely polygon representing the spatial coverage, and further customize which information we want to use from other metadata fields.
First, we’ll define a couple functions to help us make the conversion. One to extract the geometry from the metadata, one to extract the browse image (quicklook) url, and one to make our conversion to a GeoDataFrame
.
# Functions to Build Dataframe
def get_shapely_object(result: earthaccess.results.DataGranule):
"""
Create a shapely polygon of spatial coverage from results metadata.
Will work for BoundingRectangles or GPolygons.
"""
# Get Geometry Keys
= result["umm"]["SpatialExtent"]["HorizontalSpatialDomain"]["Geometry"]
geo = geo.keys()
keys
if "BoundingRectangles" in keys:
= geo["BoundingRectangles"][0]
bounding_rectangle # Create bbox tuple
= (
bbox_coords "WestBoundingCoordinate"],
bounding_rectangle["SouthBoundingCoordinate"],
bounding_rectangle["EastBoundingCoordinate"],
bounding_rectangle["NorthBoundingCoordinate"],
bounding_rectangle[
)# Create shapely geometry from bbox
= box(*bbox_coords, ccw=True)
shape elif "GPolygons" in keys:
= geo["GPolygons"][0]["Boundary"]["Points"]
points # Create shapely geometry from polygons
= Polygon([[p["Longitude"], p["Latitude"]] for p in points])
shape else:
raise ValueError(
"Provided result does not contain bounding boxes/polygons or is incompatible."
)return shape
def get_png(result: earthaccess.results.DataGranule):
"""
Retrieve a png browse image if it exists or first jpg in list of urls
"""
= [link for link in result.dataviz_links() if "https" in link]
https_links if len(https_links) == 1:
= https_links[0]
browse elif len(https_links) == 0:
= "no browse image"
browse else:
= [png for png in https_links if ".png" in png][0]
browse if any(browse) == 'no browse image':
print("Some granules have no browse imagery.")
return browse
def results_to_gdf(results: list):
"""
Takes a list of results from earthaccess and converts to a geodataframe.
"""
# Create Dataframe of Results Metadata
= pd.json_normalize(results)
results_df # Shorten column Names
= [
results_df.columns ".")[-1] if "." in col else col for col in results_df.columns
col.split(
]# Create shapely polygons for result
= [
geometries for index in results_df.index.to_list()
get_shapely_object(results[index])
]# Convert to GeoDataframe
= gpd.GeoDataFrame(results_df, geometry=geometries, crs="EPSG:4326")
gdf # Add browse imagery and data links and product shortname
"browse"] = [get_png(granule) for granule in results]
gdf["shortname"] = [
gdf["umm"]["CollectionReference"]["ShortName"] for result in results
result[
]"data"] = [granule.data_links() for granule in results]
gdf[return gdf
Use our functions to create a GeoDataFrame
.
= results_to_gdf(results) gdf
Preview our geodataframe to get an idea what it looks like.
'display.max_columns', None)
pd.set_option( gdf.head()
size | concept-type | concept-id | revision-id | native-id | collection-concept-id | provider-id | format | revision-date | BeginningDateTime | EndingDateTime | OrbitCalculatedSpatialDomains | GranuleUR | AdditionalAttributes | MeasuredParameters | BoundingRectangles | ProviderDates | ShortName | Version | PGEVersion | RelatedUrls | DayNightFlag | Identifiers | ProductionDateTime | ArchiveAndDistributionInformation | Platforms | URL | Name | Version | GPolygons | PGEName | CloudCover | geometry | browse | shortname | data | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2.496260 | granule | G2581836170-LPCLOUD | 2 | ECOv002_L2T_LSTE_25460_016_11SKU_20230101T1552... | C2076090826-LPCLOUD | LPCLOUD | application/echo10+xml | 2024-01-20T15:57:11.266Z | 2023-01-01T15:52:48.650Z | 2023-01-01T15:53:40.620Z | [{'BeginOrbitNumber': 25460, 'EndOrbitNumber':... | ECOv002_L2T_LSTE_25460_016_11SKU_20230101T1552... | [{'Name': 'identifier_product_doi', 'Values': ... | [{'ParameterName': 'L2T_LSTE'}] | [{'WestBoundingCoordinate': -120.295181, 'East... | [{'Date': '2023-01-07T04:45:46.314Z', 'Type': ... | ECO_L2T_LSTE | 002 | v1.4.8 | [{'URL': 'https://data.lpdaac.earthdatacloud.n... | Day | [{'Identifier': 'ECOv002_L2T_LSTE_25460_016_11... | 2023-01-02T10:54:07.207Z | [{'Name': 'Not provided', 'Size': 2.49626, 'Si... | [{'ShortName': 'ISS', 'Instruments': [{'ShortN... | https://cdn.earthdata.nasa.gov/umm/granule/v1.6.6 | UMM-G | 1.6.6 | NaN | NaN | NaN | POLYGON ((-119.06582 34.21003, -119.06582 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
1 | 2.380000 | granule | G3452958201-LPCLOUD | 1 | ECOv002_L2T_LSTE_25460_016_11SKU_20230101T1552... | C2076090826-LPCLOUD | LPCLOUD | application/echo10+xml | 2025-03-17T21:23:46.945Z | 2023-01-01T15:52:48.650Z | 2023-01-01T15:53:40.620Z | [{'BeginOrbitNumber': 25460, 'EndOrbitNumber':... | ECOv002_L2T_LSTE_25460_016_11SKU_20230101T1552... | [{'Name': 'identifier_product_doi', 'Values': ... | [{'ParameterName': 'ECO_L2T_LSTE'}] | [{'WestBoundingCoordinate': -120.2951835441725... | [{'Date': '2025-01-14T22:26:07.174Z', 'Type': ... | ECO_L2T_LSTE | 002 | v1.12.4 | [{'URL': 'https://data.lpdaac.earthdatacloud.n... | Day | [{'Identifier': 'ECOv002_L2T_LSTE_25460_016_11... | 2025-01-14T22:26:07.174Z | [{'Name': 'Not provided', 'Size': 2.38, 'SizeU... | [{'ShortName': 'ISS', 'Instruments': [{'ShortN... | https://cdn.earthdata.nasa.gov/umm/granule/v1.6.6 | UMM-G | 1.6.6 | NaN | NaN | NaN | POLYGON ((-119.06582 34.21003, -119.06582 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
2 | 0.292531 | granule | G2586136519-LPCLOUD | 2 | ECOv002_L2T_LSTE_25486_005_10SGD_20230103T0743... | C2076090826-LPCLOUD | LPCLOUD | application/echo10+xml | 2024-01-20T17:35:56.995Z | 2023-01-03T07:43:30.510Z | 2023-01-03T07:44:22.480Z | [{'BeginOrbitNumber': 25486, 'EndOrbitNumber':... | ECOv002_L2T_LSTE_25486_005_10SGD_20230103T0743... | [{'Name': 'identifier_product_doi', 'Values': ... | [{'ParameterName': 'L2T_LSTE'}] | [{'WestBoundingCoordinate': -120.828926, 'East... | [{'Date': '2023-01-10T15:47:57.390Z', 'Type': ... | ECO_L2T_LSTE | 002 | v1.4.8 | [{'URL': 'https://data.lpdaac.earthdatacloud.n... | Night | [{'Identifier': 'ECOv002_L2T_LSTE_25486_005_10... | 2023-01-04T15:18:13.022Z | [{'Name': 'Not provided', 'Size': 0.292531, 'S... | [{'ShortName': 'ISS', 'Instruments': [{'ShortN... | https://cdn.earthdata.nasa.gov/umm/granule/v1.6.6 | UMM-G | 1.6.6 | NaN | NaN | NaN | POLYGON ((-119.59844 34.20719, -119.59844 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
3 | 0.290000 | granule | G3452806592-LPCLOUD | 1 | ECOv002_L2T_LSTE_25486_005_10SGD_20230103T0743... | C2076090826-LPCLOUD | LPCLOUD | application/echo10+xml | 2025-03-17T19:18:49.303Z | 2023-01-03T07:43:30.511Z | 2023-01-03T07:44:22.481Z | [{'BeginOrbitNumber': 25486, 'EndOrbitNumber':... | ECOv002_L2T_LSTE_25486_005_10SGD_20230103T0743... | [{'Name': 'identifier_product_doi', 'Values': ... | [{'ParameterName': 'ECO_L2T_LSTE'}] | [{'WestBoundingCoordinate': -120.8289272688636... | [{'Date': '2025-01-15T00:21:58.289Z', 'Type': ... | ECO_L2T_LSTE | 002 | v1.12.4 | [{'URL': 'https://data.lpdaac.earthdatacloud.n... | Night | [{'Identifier': 'ECOv002_L2T_LSTE_25486_005_10... | 2025-01-15T00:21:58.289Z | [{'Name': 'Not provided', 'Size': 0.29, 'SizeU... | [{'ShortName': 'ISS', 'Instruments': [{'ShortN... | https://cdn.earthdata.nasa.gov/umm/granule/v1.6.6 | UMM-G | 1.6.6 | NaN | NaN | NaN | POLYGON ((-119.59844 34.20719, -119.59844 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
4 | 8.495430 | granule | G2586136993-LPCLOUD | 2 | ECOv002_L2T_LSTE_25486_006_11SKU_20230103T0744... | C2076090826-LPCLOUD | LPCLOUD | application/echo10+xml | 2024-01-20T17:36:01.364Z | 2023-01-03T07:44:22.480Z | 2023-01-03T07:45:14.450Z | [{'BeginOrbitNumber': 25486, 'EndOrbitNumber':... | ECOv002_L2T_LSTE_25486_006_11SKU_20230103T0744... | [{'Name': 'identifier_product_doi', 'Values': ... | [{'ParameterName': 'L2T_LSTE'}] | [{'WestBoundingCoordinate': -120.295181, 'East... | [{'Date': '2023-01-10T15:47:58.210Z', 'Type': ... | ECO_L2T_LSTE | 002 | v1.4.8 | [{'URL': 'https://data.lpdaac.earthdatacloud.n... | Night | [{'Identifier': 'ECOv002_L2T_LSTE_25486_006_11... | 2023-01-04T15:18:08.548Z | [{'Name': 'Not provided', 'Size': 8.49543, 'Si... | [{'ShortName': 'ISS', 'Instruments': [{'ShortN... | https://cdn.earthdata.nasa.gov/umm/granule/v1.6.6 | UMM-G | 1.6.6 | NaN | NaN | NaN | POLYGON ((-119.06582 34.21003, -119.06582 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
There are a lot of columns with data that is not relevant to our goal, so we can drop those. To do that, list the names of columns.
# List Column Names
gdf.columns
Index(['size', 'concept-type', 'concept-id', 'revision-id', 'native-id',
'collection-concept-id', 'provider-id', 'format', 'revision-date',
'BeginningDateTime', 'EndingDateTime', 'OrbitCalculatedSpatialDomains',
'GranuleUR', 'AdditionalAttributes', 'MeasuredParameters',
'BoundingRectangles', 'ProviderDates', 'ShortName', 'Version',
'PGEVersion', 'RelatedUrls', 'DayNightFlag', 'Identifiers',
'ProductionDateTime', 'ArchiveAndDistributionInformation', 'Platforms',
'URL', 'Name', 'Version', 'GPolygons', 'PGEName', 'CloudCover',
'geometry', 'browse', 'shortname', 'data'],
dtype='object')
Now create a list of columns to keep and use it to filter the dataframe.
# Create a list of columns to keep
= [
keep_cols "native-id",
"collection-concept-id",
"BeginningDateTime",
"EndingDateTime",
"CloudCover",
"DayNightFlag",
"geometry",
"browse",
"shortname",
"data",
]# Remove unneeded columns
= gdf[gdf.columns.intersection(keep_cols)]
gdf gdf.head()
native-id | collection-concept-id | BeginningDateTime | EndingDateTime | DayNightFlag | CloudCover | geometry | browse | shortname | data | |
---|---|---|---|---|---|---|---|---|---|---|
0 | ECOv002_L2T_LSTE_25460_016_11SKU_20230101T1552... | C2076090826-LPCLOUD | 2023-01-01T15:52:48.650Z | 2023-01-01T15:53:40.620Z | Day | NaN | POLYGON ((-119.06582 34.21003, -119.06582 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
1 | ECOv002_L2T_LSTE_25460_016_11SKU_20230101T1552... | C2076090826-LPCLOUD | 2023-01-01T15:52:48.650Z | 2023-01-01T15:53:40.620Z | Day | NaN | POLYGON ((-119.06582 34.21003, -119.06582 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
2 | ECOv002_L2T_LSTE_25486_005_10SGD_20230103T0743... | C2076090826-LPCLOUD | 2023-01-03T07:43:30.510Z | 2023-01-03T07:44:22.480Z | Night | NaN | POLYGON ((-119.59844 34.20719, -119.59844 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
3 | ECOv002_L2T_LSTE_25486_005_10SGD_20230103T0743... | C2076090826-LPCLOUD | 2023-01-03T07:43:30.511Z | 2023-01-03T07:44:22.481Z | Night | NaN | POLYGON ((-119.59844 34.20719, -119.59844 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
4 | ECOv002_L2T_LSTE_25486_006_11SKU_20230103T0744... | C2076090826-LPCLOUD | 2023-01-03T07:44:22.480Z | 2023-01-03T07:45:14.450Z | Night | NaN | POLYGON ((-119.06582 34.21003, -119.06582 35.2... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... |
With our dataframe cleaned up a bit, we can clearly see the available metadata needed to do some filtering. Before conducting our concurrent data filtering, first we’ll want to do some product specific filtering.
Separate the results into two dataframes – one for ECOTRESS and one for EMIT, and print the number of results for each so we can monitor how many granules remain in our results.
# View unique Products
'shortname'].unique() gdf[
array(['ECO_L2T_LSTE', 'EMITL2ARFL'], dtype=object)
# Split into two dataframes - ECO and EMIT
= gdf[gdf['shortname']=='ECO_L2T_LSTE']
eco_gdf = gdf[gdf['shortname']=='EMITL2ARFL']
emit_gdf print(f'{eco_gdf["shortname"].iloc[0]} Granules: {eco_gdf.shape[0]} \n{emit_gdf["shortname"].iloc[0]} Granules: {emit_gdf.shape[0]}')
ECO_L2T_LSTE Granules: 597
EMITL2ARFL Granules: 35
Let’s take a look at ECOSTRESS specific filtering first. Since we’re using the ECOSTRESS tiled data (ECO_L2T LSTE) data, there are two tiles available for our area of interest because its located where the tiles overlap and we’ll want to choose a tile if it fully contains our region of interest. Additionally, a caveat with this ECOSTRESS dataset is that there are currently multiple builds available for a scene (as of 2025-05-12). For this use case, we will select the newest build available, and eliminate duplicate granules from older builds.
Select a single tile tile, then remove duplicate scenes produced with older software builds from our results.
# Select an ECOSTRESS Tile
= eco_gdf[eco_gdf["native-id"].str.contains("10SGD")]
eco_gdf
# Remove Duplicate Granules from Older Builds
def filter_eco_duplicates(gdf):
"""
ECOSTRESS data currently have some duplicate granules in the archive
from different software builds. This removes the duplicates a dataframe of reuslts.
Based on ECO file naming conventions.
"""
= gdf["native-id"].str.rsplit("_", n=2, expand=True)
df = ["base", "build_major", "build_minor"]
df.columns "build_major"] = df["build_major"].astype(int)
df["build_minor"] = df["build_minor"].astype(int)
df["native-id"] = gdf["native-id"]
df[# Group and keep latest
= df.groupby("base")[["build_major", "build_minor"]].idxmax().build_major
idx = gdf[gdf["native-id"].isin(df.loc[idx, "native-id"])]
gdf return gdf
= filter_eco_duplicates(eco_gdf)
eco_gdf
# Show change in results quantity
print(f'{eco_gdf["shortname"].iloc[0]} Granules: {eco_gdf.shape[0]} \n{emit_gdf["shortname"].iloc[0]} Granules: {emit_gdf.shape[0]}')
ECO_L2T_LSTE Granules: 163
EMITL2ARFL Granules: 35
Notice that this reduced our ECOSTRESS results quantity of by quite a bit.
Now, lets do some filtering of EMIT scenes as well. For EMIT we can utilize the CloudCover
field from the results metadata to eliminate scenes with a large proportion of cloud-cover. This field is not available for ECOSTRESS.
# Cloud Filter using EMIT Metadata - Not available for ECOSTRESS
= emit_gdf[emit_gdf["CloudCover"] <= 80]
emit_gdf print(f'{eco_gdf["shortname"].iloc[0]} Granules: {eco_gdf.shape[0]} \n{emit_gdf["shortname"].iloc[0]} Granules: {emit_gdf.shape[0]}')
ECO_L2T_LSTE Granules: 163
EMITL2ARFL Granules: 21
Finally, we want to apply our concurrent filtering to ensure each entry in our ECOSTRESS dataframe has a corresponding entry in the EMIT dataframe. Since the EMIT acquisition mask has been added to ECOSTRESS, in most cases if EMIT is collecting data, so will ECOSTRESS. This means we can use the time window from the metadata provided by the BegginningDateTime
and EndDateTime
columns to help us accomplish this. But, since we are working with the tiled ECOSTRESS product, there are cases where the tiles may not spatially intersect with an EMIT scene, even though the acquisition time windows intersect. We need to:
- Ensure the temporal extents of granules from each instrument intersect.
- Ensure that the spatial extents of granules from each instrument intersect (due to the tiling scheme).
First write a function to convert the BeginningDateTime
and EndDateTime
to a datetime
object.
You may have noticed that the date format is similar for ECOSTRESS and EMIT, but the ECOSTRESS data also includes fractional seconds. If using an different version of
pandas
, you may need to drop theformat='ISO8601'
argument to theto_datetime
function, as shown in the commented-out line.
# Define Function to Convert Columns to datetime
def convert_col_dt(df:pd.DataFrame, columns:list, fmt="ISO8601"):
"""
Convert specified columns from string to a datetime. Default format of ISO8601.
"""
for col in columns:
= pd.to_datetime(df[col], format=fmt)
df[col] return df
Now we can write a function to find spatial and temporal interections of the data. To do this, we’ll merge the dataframes spatially to find intersecting geometries from our emit_gdf
and eco_gdf
, providing a suffix for each GeoDataFrame
so columns will be named in a meaninful way. Then we’ll use the temporal extents defined by BeginningDateTime
and EndingDateTime
to evaluate if granules from each data product intersect temporally.
Finding intersections between temporal extents of a granule works great for finding concurrent ECOSTRESS and EMIT because they are both on the ISS and acquiring data within seconds of each other, but for other applications we may want to find data from another orbiting instrument where an overpass falls within an hour or day of acquisition. We can accomplish this by manually providing a timedelta, then using the temporal extent from the metadata to see if the temporal extent from each data product falls within the provided timedelta. Since this may be a common use case, we’ve added that parameter to the function below.
One more addition to the function below is keeping all of our geometries. By default, the gpd.sjoin
function only keeps the left dataframe geometry. In the function this has been modified to keep both left and right geometries, as well as a merged geometry. This will allow us to verify our results and also visualize these intersections.
Note that the spatial merge and temporal filtering can be computationally intensive. If the area of interest is very large or initial search parameters have a long temporal range, breaking down the initial search into sections spatially or temporally is recommended.
def find_concurrent_scenes(lgdf, rgdf, time_delta=None):
"""
Finds the concurrent scenes.
lgdf: a geodataframe of earthaccess results
rgdf: a second geodataframe ofearthaccess results
lsuffix: appended to lgdf column names
rsuffix: appended to rgdf column names
timedelta (seconds): optional - an acceptable time-range between temporal
extents of both products
"""
# Get left and right suffixes
= lgdf["shortname"].iloc[0]
lsuffix = rgdf["shortname"].iloc[0]
rsuffix
# Copy lgdf, rgdf Geometry
f"geometry_{lsuffix}"] = lgdf.geometry
lgdf.loc[:,f"geometry_{rsuffix}"] = rgdf.geometry
rgdf.loc[:,
# Conduct Spatial Join
= gpd.sjoin(
joined
lgdf,
rgdf,="inner",
how="intersects",
predicate=lsuffix,
lsuffix=rsuffix,
rsuffix
)
# Convert Datetime fields
= [
date_columns f"BeginningDateTime_{lsuffix}",
f"BeginningDateTime_{rsuffix}",
f"EndingDateTime_{lsuffix}",
f"EndingDateTime_{rsuffix}",
]= convert_col_dt(joined, date_columns)
joined
# Filter Based on Temporal Extents
if time_delta:
= timedelta(seconds=time_delta)
td f"MidDateTime_{lsuffix}"] = (
joined[f"BeginningDateTime_{lsuffix}"]
joined[+ (
f"EndingDateTime_{lsuffix}"]
joined[- joined[f"BeginningDateTime_{lsuffix}"]
)/ 2
)f"MidDateTime_{rsuffix}"] = (
joined[f"BeginningDateTime_{rsuffix}"]
joined[+ (
f"EndingDateTime_{rsuffix}"]
joined[- joined[f"BeginningDateTime_{rsuffix}"]
)/ 2
)= joined[
concurrent_df abs(joined[f"MidDateTime_{lsuffix}"] - joined[f"MidDateTime_{rsuffix}"])
<= td
]
else:
= joined[
concurrent_df
(f"BeginningDateTime_{lsuffix}"]
joined[<= joined[f"EndingDateTime_{rsuffix}"]
)& (
f"EndingDateTime_{lsuffix}"]
joined[>= joined[f"BeginningDateTime_{rsuffix}"]
)
]
# Combine Geometry
"geometry"] = concurrent_df.apply(
concurrent_df.loc[:, lambda row: MultiPolygon(
f"geometry_{lsuffix}"], row[f"geometry_{rsuffix}"]]
[row[
),=1,
axis
)
return concurrent_df
Run the concurrent_df
function. Since we’re working with ECOSTRESS and EMIT, we don’t need to provide the timedelta
argument.
# Find Concurrent Scenes
= find_concurrent_scenes(eco_gdf, emit_gdf , time_delta=60*60*12)
concurrent_df concurrent_df.head()
native-id_ECO_L2T_LSTE | collection-concept-id_ECO_L2T_LSTE | BeginningDateTime_ECO_L2T_LSTE | EndingDateTime_ECO_L2T_LSTE | DayNightFlag_ECO_L2T_LSTE | CloudCover_ECO_L2T_LSTE | geometry | browse_ECO_L2T_LSTE | shortname_ECO_L2T_LSTE | data_ECO_L2T_LSTE | geometry_ECO_L2T_LSTE | index_EMITL2ARFL | native-id_EMITL2ARFL | collection-concept-id_EMITL2ARFL | BeginningDateTime_EMITL2ARFL | EndingDateTime_EMITL2ARFL | DayNightFlag_EMITL2ARFL | CloudCover_EMITL2ARFL | browse_EMITL2ARFL | shortname_EMITL2ARFL | data_EMITL2ARFL | geometry_EMITL2ARFL | MidDateTime_ECO_L2T_LSTE | MidDateTime_EMITL2ARFL | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
70 | ECOv002_L2T_LSTE_26223_011_10SGD_20230219T2028... | C2076090826-LPCLOUD | 2023-02-19 20:28:51.310000+00:00 | 2023-02-19 20:29:43.280000+00:00 | Day | NaN | MULTIPOLYGON (((-119.59844 34.20719, -119.5984... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-119.59844 34.20719, -119.59844 35.2... | 71 | EMIT_L2A_RFL_001_20230219T202939_2305013_002 | C2408750690-LPCLOUD | 2023-02-19 20:29:39+00:00 | 2023-02-19 20:29:51+00:00 | Day | 47.0 | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | EMITL2ARFL | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-120.70791 35.54348, -121.19894 34.9... | 2023-02-19 20:29:17.295000+00:00 | 2023-02-19 20:29:45+00:00 |
70 | ECOv002_L2T_LSTE_26223_011_10SGD_20230219T2028... | C2076090826-LPCLOUD | 2023-02-19 20:28:51.310000+00:00 | 2023-02-19 20:29:43.280000+00:00 | Day | NaN | MULTIPOLYGON (((-119.59844 34.20719, -119.5984... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-119.59844 34.20719, -119.59844 35.2... | 74 | EMIT_L2A_RFL_001_20230219T202951_2305013_003 | C2408750690-LPCLOUD | 2023-02-19 20:29:51+00:00 | 2023-02-19 20:30:14+00:00 | Day | 67.0 | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | EMITL2ARFL | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-120.04838 35.03646, -120.54523 34.4... | 2023-02-19 20:29:17.295000+00:00 | 2023-02-19 20:30:02.500000+00:00 |
72 | ECOv002_L2T_LSTE_26223_012_10SGD_20230219T2029... | C2076090826-LPCLOUD | 2023-02-19 20:29:43.280000+00:00 | 2023-02-19 20:30:35.250000+00:00 | Day | NaN | MULTIPOLYGON (((-119.59844 34.20719, -119.5984... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-119.59844 34.20719, -119.59844 35.2... | 71 | EMIT_L2A_RFL_001_20230219T202939_2305013_002 | C2408750690-LPCLOUD | 2023-02-19 20:29:39+00:00 | 2023-02-19 20:29:51+00:00 | Day | 47.0 | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | EMITL2ARFL | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-120.70791 35.54348, -121.19894 34.9... | 2023-02-19 20:30:09.265000+00:00 | 2023-02-19 20:29:45+00:00 |
72 | ECOv002_L2T_LSTE_26223_012_10SGD_20230219T2029... | C2076090826-LPCLOUD | 2023-02-19 20:29:43.280000+00:00 | 2023-02-19 20:30:35.250000+00:00 | Day | NaN | MULTIPOLYGON (((-119.59844 34.20719, -119.5984... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-119.59844 34.20719, -119.59844 35.2... | 74 | EMIT_L2A_RFL_001_20230219T202951_2305013_003 | C2408750690-LPCLOUD | 2023-02-19 20:29:51+00:00 | 2023-02-19 20:30:14+00:00 | Day | 67.0 | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | EMITL2ARFL | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-120.04838 35.03646, -120.54523 34.4... | 2023-02-19 20:30:09.265000+00:00 | 2023-02-19 20:30:02.500000+00:00 |
167 | ECOv002_L2T_LSTE_26799_003_10SGD_20230328T2212... | C2076090826-LPCLOUD | 2023-03-28 22:12:39.510000+00:00 | 2023-03-28 22:13:31.480000+00:00 | Day | NaN | MULTIPOLYGON (((-119.59844 34.20719, -119.5984... | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | ECO_L2T_LSTE | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-119.59844 34.20719, -119.59844 35.2... | 168 | EMIT_L2A_RFL_001_20230328T221244_2308715_001 | C2408750690-LPCLOUD | 2023-03-28 22:12:44+00:00 | 2023-03-28 22:12:56+00:00 | Day | 31.0 | https://data.lpdaac.earthdatacloud.nasa.gov/lp... | EMITL2ARFL | [https://data.lpdaac.earthdatacloud.nasa.gov/l... | POLYGON ((-120.69176 34.82279, -121.46434 34.2... | 2023-03-28 22:13:05.495000+00:00 | 2023-03-28 22:12:50+00:00 |
When viewing our concurrent dataframe, we can see that all of the info from both our EMIT and ECO geodataframes is still there, including geomtries from each, and our new multipolygon geometry.
4. Visualizing Intersecting Coverage
We can visualize our results on a basemap using folium
. First define the column names associated with unique granule ids for each product, in this case, native-id_ECO_L2T_LSTE
and native-id_EMITL2ARFL
. Next, grab the suffix to use in creating hover tooltips for the visualization.
# Identify Columns with Granule Names (native-id) for Each Product
= tuple([c for c in concurrent_df.columns if c.startswith("native-id_")])
prod1, prod2 # Define Suffixes
= prod1.split("_", 1)[1]
p1_suffix = prod2.split("_", 1)[1] p2_suffix
Now, create a color map, setting a unique color for each unique ECO_L2T_LSTE
id. We only do this for ECOSTRESS, because our goal is to visualize an ECOSTRESS scene and the concurrently acquired EMIT scenes using the ECOSTRESS scene as a key in the legend. We’ve elected to use ECOSTRESS as a key in this example because the spatial footprints are larger, but this can be reversed where EMIT is used as the key.
# Define a Color for Each Granule Grouping
= plt.get_cmap("tab20").colors
palette = concurrent_df[prod1].unique()
unique_ids = [mcolors.to_hex(c) for c in palette]
hex_colors # Build Color Map
= {
color_map % len(hex_colors)]
uid: hex_colors[i for i, uid in enumerate(unique_ids)
}
Using our color map, plot using folium
. This cell may get a bit confusing, but essentially, we iterate over our dataframe, building FeatureGroups
consisting of rows from our dataframe,grouped by granule-id from ECOSTRESS. Within this loop, we define a color for the group, add the ECOSTRESS footprint, add an image overlay of ECOSTRESS browse imagery, then add EMIT footprints.
After that we add our initial bounding box and region of interest polygons. Note that by default, our concurrent feature groups are not drawn, they must be added by checking boxes in the LayerControl
.
# Create Figure and Select Background Tiles
= Figure(width="750", height="375")
fig = folium.Map(tiles="https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}", attr="Google", name='Google')
m
fig.add_child(m)
# Iterate through GeoDataFrame row
for p1_id, group in concurrent_df.groupby(prod1):
= folium.FeatureGroup(name=p1_id, show=False)
fg = group.iloc[0]
p1_row = color_map[p1_id]
color
# Create a tooltip for hovering over polygons. Other columns can be added.
= folium.Tooltip(
p1_tooltip f"""
<b>{p1_suffix} Granule-ID: </b> {p1_id}<br>
<b>{p1_suffix} BeginningDateTime: </b> {p1_row[f"BeginningDateTime_{p1_suffix}"]}<br>
""")
# Add the Data
folium.GeoJson(= p1_row[f"geometry_{p1_suffix}"].__geo_interface__,
data = p1_tooltip,
tooltip =lambda feature, color=color: {
style_function"color": color,
"weight": 2,
"fillOpacity": 0
}
).add_to(fg)
# Optional Image Overlay
if p1_row[f"browse_{p1_suffix}"] != "no browse image":
= p1_row[f"geometry_{p1_suffix}"].bounds
bounds
folium.raster_layers.ImageOverlay(=p1_row[f"browse_{p1_suffix}"],
image=[[bounds[1],bounds[0]], [bounds[3],bounds[2]]],
bounds=0.75,
opacity=1,
zindex
).add_to(fg)
for _, p2_row in group.iterrows():
= p2_row[prod2]
p2_id
= folium.Tooltip(f"""
p2_tooltip <b> {p2_suffix} Granule-ID:</b> {p2_id}<br>
<b> {p2_suffix} BeginningDateTime: </b> {p2_row[f"BeginningDateTime_{p2_suffix}"]}<br>
""")
folium.GeoJson(=p2_row[f"geometry_{p2_suffix}"].__geo_interface__,
data=p2_tooltip,
tooltip=lambda feature, color=color: {
style_function"color": color,
"weight": 2,
"fillOpacity": 0
}
).add_to(fg)
m.add_child(fg)
# Plot Region of Interest
polygon.explore(=False,
popup=dict(fillOpacity=0, width=2),
style_kwds="Region of Interest",
name=m,
m
)# Add Our Bounding Box
folium.GeoJson(
roi_poly,="bounding_box",
name=lambda feature: {
style_function"weight": 2,
"fill": False
},
).add_to(m)
# Add Layer Control and set zoom.
= polygon.total_bounds
minx, miny, maxx, maxy =((miny,minx), (maxy,maxx)))
m.fit_bounds(bounds
m.add_child(folium.LayerControl()) display(fig)
In the figure above, we start without loading any concurrent layers. Add or remove layers to visualized the concurrent scenes. Each layer will contain an EMIT scene footprint, concurrent ECOSTRESS scene footprint, and ECOSTRESS browse scene. There can be duplicate granules if multiple scenes intersect with a single scene.
5. Generating a list of URLs
With our concurrent geodataframe we can pull the links to scenes out of our data_EMIT
and data_ECO
column. To do this, define a function that groups the data by the native-id_EMIT
and builds a dictionary of links corresponding to each. We use a sublist to help remove duplicates and lump multiple corresponding scenes to a single native-id_EMIT
scene.
# Define Function to retrieve list of files intersecting with emit scene
def get_file_list(concurrent_df):
"""
Retrieve a dictionary of EMIT scenes with a list of files from both instruments
acquired at the location and time of lgdf scene.
"""
= (
files f"data_{p1_suffix}", f"data_{p2_suffix}"]]
concurrent_df.groupby(prod1)[[apply(
.lambda group: list(
dict.fromkeys(
for sublist in group[f"data_{p1_suffix}"] for item in sublist]
[item + [item for sublist in group[f"data_{p2_suffix}"] for item in sublist]
)
)
)
.to_dict()
)return files
Use the function to retrieve the dictionary of links.
# Retrieve Links
= get_file_list(concurrent_df) links_dict
# List EMIT Scenes (Keys)
= list(links_dict.keys())
eco_keys eco_keys
['ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01',
'ECOv002_L2T_LSTE_26223_012_10SGD_20230219T202943_0710_01',
'ECOv002_L2T_LSTE_26799_003_10SGD_20230328T221239_0711_02',
'ECOv002_L2T_LSTE_26860_001_10SGD_20230401T203733_0712_02',
'ECOv002_L2T_LSTE_26921_001_10SGD_20230405T190258_0712_02',
'ECOv002_L2T_LSTE_27185_010_10SGD_20230422T195836_0710_01',
'ECOv002_L2T_LSTE_27745_005_10SGD_20230528T220401_0711_02',
'ECOv002_L2T_LSTE_27806_005_10SGD_20230601T202619_0711_02',
'ECOv002_L2T_LSTE_28238_012_10SGD_20230629T170416_0711_02',
'ECOv002_L2T_LSTE_28859_005_10SGD_20230808T173811_0712_02',
'ECOv002_L2T_LSTE_28859_006_10SGD_20230808T173903_0712_02',
'ECOv002_L2T_LSTE_29576_005_10SGD_20230923T232104_0712_02',
'ECOv002_L2T_LSTE_29901_007_10SGD_20231014T223936_0712_02',
'ECOv002_L2T_LSTE_29962_012_10SGD_20231018T210208_0712_02']
# Show links associated with Scene
0]] links_dict[eco_keys[
['https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01_water.tif',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01_cloud.tif',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01_view_zenith.tif',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01_height.tif',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01_QC.tif',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01_LST.tif',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01_LST_err.tif',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01_EmisWB.tif',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202939_2305013_002/EMIT_L2A_RFL_001_20230219T202939_2305013_002.nc',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202939_2305013_002/EMIT_L2A_RFLUNCERT_001_20230219T202939_2305013_002.nc',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202939_2305013_002/EMIT_L2A_MASK_001_20230219T202939_2305013_002.nc',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202951_2305013_003/EMIT_L2A_RFL_001_20230219T202951_2305013_003.nc',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202951_2305013_003/EMIT_L2A_RFLUNCERT_001_20230219T202951_2305013_003.nc',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202951_2305013_003/EMIT_L2A_MASK_001_20230219T202951_2305013_003.nc']
Granules often have several assets associated with them, for example, ECO_L2T_LSTE
has several assets: - Water Mask (water) - Cloud Mask (cloud) - Quality (QC) - Land Surface Temperature (LST) - Land Surface Temperature Error (LST_err) - Wide Band Emissivity (EmisWB) - Height (height) and EMIT_L2A_RFL
has: - Reflectance - Reflectance Uncertainty - Masks
We can use string matching to iterate through our dictionary and remove assets we don’t want. Carefully choose some strings that can be used to identify desired assets, then use list comprehension to remove assets that don’t contain the strings from the desired assets.
# Identify Strings
= ['_RFL_', '_MASK_', '_LST.']
desired_assets
# Iterate through dictionary and filter
= {
filtered_links_dict for asset in assets if any(desired_asset in asset.split('/')[-1] for desired_asset in desired_assets)]
key: [asset for key, assets in links_dict.items()
}list(filtered_links_dict.keys())
['ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01',
'ECOv002_L2T_LSTE_26223_012_10SGD_20230219T202943_0710_01',
'ECOv002_L2T_LSTE_26799_003_10SGD_20230328T221239_0711_02',
'ECOv002_L2T_LSTE_26860_001_10SGD_20230401T203733_0712_02',
'ECOv002_L2T_LSTE_26921_001_10SGD_20230405T190258_0712_02',
'ECOv002_L2T_LSTE_27185_010_10SGD_20230422T195836_0710_01',
'ECOv002_L2T_LSTE_27745_005_10SGD_20230528T220401_0711_02',
'ECOv002_L2T_LSTE_27806_005_10SGD_20230601T202619_0711_02',
'ECOv002_L2T_LSTE_28238_012_10SGD_20230629T170416_0711_02',
'ECOv002_L2T_LSTE_28859_005_10SGD_20230808T173811_0712_02',
'ECOv002_L2T_LSTE_28859_006_10SGD_20230808T173903_0712_02',
'ECOv002_L2T_LSTE_29576_005_10SGD_20230923T232104_0712_02',
'ECOv002_L2T_LSTE_29901_007_10SGD_20231014T223936_0712_02',
'ECOv002_L2T_LSTE_29962_012_10SGD_20231018T210208_0712_02']
By selecting a key, we can show the urls from concurrent scenes and download or work with the data.
0]] filtered_links_dict[eco_keys[
['https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/ECO_L2T_LSTE.002/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01/ECOv002_L2T_LSTE_26223_011_10SGD_20230219T202851_0710_01_LST.tif',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202939_2305013_002/EMIT_L2A_RFL_001_20230219T202939_2305013_002.nc',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202939_2305013_002/EMIT_L2A_MASK_001_20230219T202939_2305013_002.nc',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202951_2305013_003/EMIT_L2A_RFL_001_20230219T202951_2305013_003.nc',
'https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2ARFL.001/EMIT_L2A_RFL_001_20230219T202951_2305013_003/EMIT_L2A_MASK_001_20230219T202951_2305013_003.nc']
We can preserve this dictionary format by writing this dictionary out to a .json file to use later.
with open('../data/search_results.json', 'w') as f:
=4) json.dump(filtered_links_dict, f, indent
6. Streaming or Downloading Data
For the workshop, we will stream the data, but either method can be used, and each has trade-offs based on the internet speed, storage space, or use case. The EMIT files are very large due to the number of bands, so operations can take some time if streaming with a slower internet connection. Since the workshop is hosted in a Cloud workspace, we can stream the data directly to the workspace.
6.1 Streaming Data Workflow
For an example of streaming both netCDF and Cloud Optimized GeoTIFF (COG) data please see notebook 2, Working With EMIT Reflectance and ECOSTRESS LST.
If you plan to stream the data, you can stop here and move to the next notebook.
6.2 Downloading Data Workflow
To download the scenes, we can use the earthaccess
library to authenticate then download the files.
First, log into Earthdata using the login
function from the earthaccess
library. The persist=True
argument will create a local .netrc
file if it doesn’t exist, or add your login info to an existing .netrc
file. If no Earthdata Login credentials are found in the .netrc
you’ll be prompted for them. As mentioned in section 1.2, this step is not necessary to conduct searches, but is needed to download or stream data.
We’ve included the canopy water content files in the repository to simplify the notebooks so users don’t need to perform that operation for the examples in the repository. This means that only 3 granules from our list are required to execute the notebooks and walk through the notebooks in the repository. These are included in a separate text file, required_granules.txt
.
These can be downloading by uncommenting and running the following cells.
Note: If interested users can download all of the files using the cell below and recreate all of the canopy water content files following a workflow similar to the example in notebooks 2 and 3 for all of the necessary scenes. To do this, uncomment the
file_list
object with thesearch_results.txt
filepath to download all of the results rather than just what is required.
# Authenticate using earthaccess
=True) earthaccess.login(persist
<earthaccess.auth.Auth at 0x1809a847620>
The commented cell below downloads all assets from our dictionary. For the subsequent tutorial notebooks, all of these files are not required. Instead, uncomment and run the final cell in the notebook to download a minimal subset required to do the rest of the tutorials.
# # Get requests https Session using Earthdata Login Info
# fs = earthaccess.get_requests_https_session()
# # Retrieve granule asset ID from URL (to maintain existing naming convention)
# for granule, assets in links_dict.items():
# out_dir = os.path.abspath(os.path.join(os.getcwd(), os.pardir, "data"))
# os.makedirs(out_dir, exist_ok=True)
# for asset in assets:
# out_fn = f"{out_dir}{os.sep}{asset.split('/')[-1]}"
# if not os.path.isfile(out_fn):
# with fs.get(asset,stream=True) as src:
# with open(out_fn,'wb') as dst:
# for chunk in src.iter_content(chunk_size=64*1024*1024):
# dst.write(chunk)
# # Download Required Granules
# # Open Text File and Read Lines
# file_list = '../data/required_granules.txt'
# with open(file_list) as f:
# urls = [line.rstrip('\n') for line in f]
# # Get requests https Session using Earthdata Login Info
# fs = earthaccess.get_requests_https_session()
# # Retrieve granule asset ID from URL (to maintain existing naming convention)
# for url in urls:
# granule_asset_id = url.split('/')[-1]
# # Define Local Filepath
# fp = f'../data/{granule_asset_id}'
# # Download the Granule Asset if it doesn't exist
# if not os.path.isfile(fp):
# with fs.get(url,stream=True) as src:
# with open(fp,'wb') as dst:
# for chunk in src.iter_content(chunk_size=64*1024*1024):
# dst.write(chunk)
Congratulations, now you have downloaded concurrent data from the ECOSTRESS and EMIT instruments on the ISS.
Contact Info:
Email: LPDAAC@usgs.gov
Voice: +1-866-573-3222
Organization: Land Processes Distributed Active Archive Center (LP DAAC)¹
Website: https://www.earthdata.nasa.gov/centers/lp-daac
¹Work performed under USGS contract 140G0121D0001 for NASA contract NNG14HH33I.