Assessing Overlap in NISAR and ESA BIOMASS Datasets

Date: February 4, 2026

Authors: Harshini Girish (UAH), Rajat Shinde (UAH), Alex Mandel (Development Seed), Samantha Niemoeller (JPL)

Description: This notebook queries NISAR L2 GCOV granules (via earthaccess) and ESA BIOMASS satellite items (via the ESA MAAP STAC API, e.g., BiomassLevel1b) for a chosen AOI and time settings. It converts returned items to footprint polygons and plots them on a single interactive Folium map as two toggleable layers. An optional overlap layer highlights where NISAR and BIOMASS footprints intersect (bbox-only or true geometry). The result quickly shows where data coincides spatially to support fusion workflows.

Run This Notebook

To access and run this tutorial within MAAP’s Algorithm Development Environment (ADE), please refer to the “Getting started with the MAAP” section of our documentation.

Disclaimer: it is highly recommended to run a tutorial within MAAP’s ADE, which already includes packages specific to MAAP, such as maap-py. Running the tutorial outside of the MAAP ADE may lead to errors. Additionally, it is recommended to use the Pangeo workspace within the ADE, since certain packages relevant to this tutorial are already installed.

Additional Resources

Import and Install Packages

[1]:
import pystac_client
import geopandas as gpd
import pandas as pd
import folium
from folium import GeoJson

Inputs

This section defines the parameters used throughout the notebook to search both catalogs and compute overlaps.

  • BBOX defines the area of interest as (min_lon, min_lat, max_lon, max_lon) and can be used to spatially filter both datasets.

  • NISAR_DT sets the datetime range for the NISAR STAC search (tighten this first to avoid timeouts).

  • BIOMASS_DT sets the datetime range for the BIOMASS STAC search.

  • NISAR_STAC_URL is the STAC endpoint used to query NISAR items (CMR-STAC / ASF).

  • NISAR_COLLECTION is the NISAR collection ID used in the STAC search (e.g., NISAR_L2_GSLC_BETA_V1_1).

  • BIOMASS_STAC_URL is the STAC endpoint used to query BIOMASS items (ESA MAAP STAC).

  • BIOMASS_COLLECTION is the BIOMASS collection name used in the STAC search (e.g., BiomassLevel1b).

[2]:
# Common query parameters (edit to your AOI/time window)
BBOX = [-180, -90, 180, 90]                 # [minx, miny, maxx, maxy]
NISAR_DT = "2025-10-01/2025-12-31"          # tighten first to avoid timeouts
BIOMASS_DT = "2024-01-01/.."                # adjust if needed

NISAR_STAC_URL = "https://cmr.earthdata.nasa.gov/stac/ASF"
NISAR_COLLECTION = "NISAR_L2_GSLC_BETA_V1_1"

BIOMASS_STAC_URL = "https://catalog.maap.eo.esa.int/catalogue/"
BIOMASS_COLLECTION = "BiomassLevel1b"

Query STAC and Convert Items to a GeoDataFrame

1) NISAR data

This section connects to the CMR/ASF STAC API and runs a STAC search for NISAR items using the same spatial/temporal filters used in the notebook (bbox and datetime). The returned STAC Items are converted into gdf_nisar, a GeoDataFrame whose geometry column contains the true NISAR footprint polygons and whose ID/title field is kept for labeling and later joins—no data files are downloaded, only metadata footprints are used.

[3]:
nisar_catalog = pystac_client.Client.open(NISAR_STAC_URL)

nisar_search = nisar_catalog.search(
    collections=[NISAR_COLLECTION],
    bbox=BBOX,
    datetime=NISAR_DT,
    max_items=500,
)
nisar_items = list(nisar_search.items())
print("NISAR items returned:", len(nisar_items))

# Convert STAC Items → GeoDataFrame
nisar_features = []
for it in nisar_items:

    nisar_features.append({
        "type": "Feature",
        "geometry": it.geometry,
        "properties": {
            "nisar_id": it.id,
            **(it.properties or {}),
        },
    })

gdf_nisar = gpd.GeoDataFrame.from_features(nisar_features, crs="EPSG:4326")
gdf_nisar = gdf_nisar[["nisar_id", "geometry"]]
gdf_nisar.head()

NISAR items returned: 6
[3]:
nisar_id geometry
0 NISAR_L2_PR_GSLC_003_005_D_077_4005_DHDH_A_202... POLYGON ((77.30164 24.17615, 76.70841 22.04128...
1 NISAR_L2_PR_GSLC_003_064_D_130_7700_SHNA_A_202... POLYGON ((-2.61271 -81.76059, -6.78497 -82.596...
2 NISAR_L2_PR_GSLC_004_064_D_130_7700_SHNA_A_202... POLYGON ((-2.70717 -81.78256, -6.90256 -82.617...
3 NISAR_L2_PR_GSLC_004_076_A_022_2005_QPDH_A_202... POLYGON ((-88.24687 39.87043, -89.11556 41.970...
4 NISAR_L2_PR_GSLC_005_172_A_008_2005_DHDH_A_202... POLYGON ((42.96535 12.02728, 42.46821 14.11353...

2) ESA BIOMASS

This part connects to the ESA MAAP STAC endpoint (https://catalog.maap.eo.esa.int/catalogue/) and searches the BiomassLevel1b collection using the notebook’s time range and optional bbox filter. The returned BIOMASS STAC Items are converted into gdf_biomass with polygon geometries preserved and a stable id/title column added for display and matching—again, this is footprint metadata only, not raster access.

[4]:
biomass_catalog = pystac_client.Client.open(BIOMASS_STAC_URL)

biomass_search = biomass_catalog.search(
    collections=[BIOMASS_COLLECTION],
    bbox=BBOX,
    datetime=BIOMASS_DT,
    max_items=2000,
    method="GET",
)

biomass_items = list(biomass_search.items())
print("BIOMASS items returned:", len(biomass_items))

biomass_features = []
for it in biomass_items:
    if it.geometry is None:
        raise ValueError(f"Item {it.id} has no geometry (footprint).")
    props = it.properties or {}
    biomass_features.append({
        "type": "Feature",
        "geometry": it.geometry,
        "properties": {
            "biomass_id": it.id,
            "start_datetime": props.get("start_datetime"),
            "end_datetime": props.get("end_datetime"),
            "datetime": props.get("datetime"),
        },
    })

gdf_biomass = gpd.GeoDataFrame.from_features(biomass_features, crs="EPSG:4326")
gdf_biomass = gdf_biomass[["biomass_id", "start_datetime", "end_datetime", "datetime", "geometry"]]
gdf_biomass.head()


BIOMASS items returned: 2000
[4]:
biomass_id start_datetime end_datetime datetime geometry
0 BIO_S2_DGM__1S_20251212T011542_20251212T011603... 2025-12-12T01:15:42.413Z 2025-12-12T01:16:03.140Z 2025-12-12T01:15:42.413Z POLYGON ((-133.8954 -76.78645, -131.65558 -77....
1 BIO_S2_DGM__1S_20251212T011407_20251212T011427... 2025-12-12T01:14:07.171Z 2025-12-12T01:14:27.898Z 2025-12-12T01:14:07.171Z POLYGON ((-125.59866 -71.44219, -123.89382 -71...
2 BIO_S2_DGM__1S_20251212T015732_20251212T015753... 2025-12-12T01:57:32.641Z 2025-12-12T01:57:53.290Z 2025-12-12T01:57:32.641Z POLYGON ((46.75876 49.9374, 45.91077 49.75396,...
3 BIO_S2_DGM__1S_20251212T015538_20251212T015559... 2025-12-12T01:55:38.817Z 2025-12-12T01:55:59.469Z 2025-12-12T01:55:38.817Z POLYGON ((49.76923 43.18403, 49.01818 43.01984...
4 BIO_S2_DGM__1S_20251212T015500_20251212T015521... 2025-12-12T01:55:00.871Z 2025-12-12T01:55:21.523Z 2025-12-12T01:55:00.871Z POLYGON ((50.63847 40.91793, 49.91332 40.7588,...

Interactive map: NISAR and BIOMASS footprint layers

Here the notebook creates an interactive Folium map and overlays the two GeoDataFrames using distinct styles (e.g., NISAR in blue and BIOMASS in orange) so you can visually compare coverage. Tooltips show the granule/item identifiers when you hover, and a layer control lets you toggle the layers, which makes it easy to confirm the footprints are in the right places before doing any intersection.

[38]:
## Ensure both are WGS84 for folium
gdf_nisar = gdf_nisar.to_crs("EPSG:4326")
gdf_biomass = gdf_biomass.to_crs("EPSG:4326")

# Center/zoom using combined bounds
combined = gpd.GeoSeries(list(gdf_nisar.geometry) + list(gdf_biomass.geometry), crs="EPSG:4326")
minx, miny, maxx, maxy = combined.total_bounds
center = [(miny + maxy) / 2, (minx + maxx) / 2]

m = folium.Map(location=center, tiles="OpenStreetMap", zoom_start=3)

def style_nisar(_):
    return {"color": "#1f77b4", "weight": 2, "fillOpacity": 0.15}  # blue

def style_biomass(_):
    return {"color": "#ff7f0e", "weight": 1, "fillOpacity": 0.10}  # orange

GeoJson(
    gdf_nisar.__geo_interface__,
    name=f"NISAR ({len(gdf_nisar)})",
    tooltip=folium.GeoJsonTooltip(fields=["nisar_id"]),
    style_function=style_nisar,
).add_to(m)

GeoJson(
    gdf_biomass.__geo_interface__,
    name=f"BIOMASS ({len(gdf_biomass)})",
    tooltip=folium.GeoJsonTooltip(fields=["biomass_id"]),
    style_function=style_biomass,
).add_to(m)

folium.LayerControl(collapsed=False).add_to(m)
m.fit_bounds([[miny, minx], [maxy, maxx]])
m

[38]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Overlap of BIOMASS tiles intersecting with NISAR granules

This cell uses GeoPandas spatial join to identify which BIOMASS footprint polygons intersect which NISAR footprint polygons. It runs gpd.sjoin() with how="inner" and predicate="intersects" on gdf_nisar and gdf_biomass (after resetting indices for a clean join), producing pairs where each row represents one intersecting NISAR–BIOMASS match. It then prints the total number of intersection pairs found, and builds a compact summary table called matches by keeping only the nisar_id and biomass_id columns, removing any duplicate pairs, resetting the index, and showing the first 25 results so you can quickly see which specific granules/tiles overlap.

[5]:
# Spatial join to find overlapping pairs
pairs = gpd.sjoin(
    gdf_nisar.reset_index(drop=True),
    gdf_biomass.reset_index(drop=True),
    how="inner",
    predicate="intersects",
)

print("Overlap pairs:", len(pairs))

# Compact table of matches (deduped)
matches = pairs[["nisar_id", "biomass_id"]].drop_duplicates().reset_index(drop=True)
matches.head(25)

Overlap pairs: 8
[5]:
nisar_id biomass_id
0 NISAR_L2_PR_GSLC_005_172_A_008_2005_DHDH_A_202... BIO_S1_DGM__1S_20251121T153442_20251121T153503...
1 NISAR_L2_PR_GSLC_005_172_A_008_2005_DHDH_A_202... BIO_S2_DGM__1S_20251214T025234_20251214T025254...
2 NISAR_L2_PR_GSLC_005_172_A_008_2005_DHDH_A_202... BIO_S2_DGM__1S_20251217T025236_20251217T025256...
3 NISAR_L2_PR_GSLC_005_172_A_008_2005_DHDH_A_202... BIO_S2_DGM__1S_20251217T025255_20251217T025310...
4 NISAR_L2_PR_GSLC_006_172_A_008_2005_DHDH_A_202... BIO_S1_DGM__1S_20251121T153442_20251121T153503...
5 NISAR_L2_PR_GSLC_006_172_A_008_2005_DHDH_A_202... BIO_S2_DGM__1S_20251214T025234_20251214T025254...
6 NISAR_L2_PR_GSLC_006_172_A_008_2005_DHDH_A_202... BIO_S2_DGM__1S_20251217T025236_20251217T025256...
7 NISAR_L2_PR_GSLC_006_172_A_008_2005_DHDH_A_202... BIO_S2_DGM__1S_20251217T025255_20251217T025310...

This cell creates the actual overlap polygons and then visualizes only those overlaps on a clean map. It first pulls the matching BIOMASS geometries for each join result using pairs["index_right"], wraps them as a GeoSeries aligned to pairs.index, and computes the geometric intersection with the NISAR geometry in each row (pairs.geometry.intersection(right_geom)), producing overlap_geom. It then builds a new GeoDataFrame called overlap that keeps just the linked identifiers (nisar_id, biomass_id) plus the intersection geometry, and filters out any empty intersections. For visualization, it creates a fresh Folium map (m_overlap) and adds a single GeoJson layer styled in green with a tooltip showing the two IDs on hover; finally, it automatically zooms the map to the extent of the overlap polygons using overlap.total_bounds and fit_bounds, so the view centers directly on where overlap occurs without showing the original NISAR/BIOMASS layers.

[9]:
right_geom = gdf_biomass.loc[pairs["index_right"], "geometry"].values
right_geom = gpd.GeoSeries(right_geom, index=pairs.index, crs="EPSG:4326")

overlap_geom = pairs.geometry.intersection(right_geom)

overlap = gpd.GeoDataFrame(
    pairs[["nisar_id", "biomass_id"]].copy(),
    geometry=overlap_geom,
    crs="EPSG:4326",
)
overlap = overlap[~overlap.geometry.is_empty]

# Map: overlap only
m_overlap = folium.Map(tiles="OpenStreetMap")

def style_overlap(_):
    return {"color": "#2ca02c", "weight": 2, "fillColor": "#2ca02c", "fillOpacity": 0.35}

GeoJson(
    overlap.__geo_interface__,
    name=f"Overlap ({len(overlap)})",
    tooltip=folium.GeoJsonTooltip(fields=["nisar_id", "biomass_id"]),
    style_function=style_overlap,
).add_to(m_overlap)

if len(overlap) > 0:
    minx, miny, maxx, maxy = overlap.total_bounds
    pad = 0.05
    m_overlap.fit_bounds([[miny - pad, minx - pad], [maxy + pad, maxx + pad]])

m_overlap
[9]:
Make this Notebook Trusted to load map: File -> Trust Notebook