diff --git a/docs/source/technical_tutorials/visualization/visualizing_NISAR_BIOMASS.ipynb b/docs/source/technical_tutorials/visualization/visualizing_NISAR_BIOMASS.ipynb new file mode 100644 index 00000000..28dfc824 --- /dev/null +++ b/docs/source/technical_tutorials/visualization/visualizing_NISAR_BIOMASS.ipynb @@ -0,0 +1,987 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "374aa2b1", + "metadata": {}, + "source": [ + "# NISAR and ESA BIOMASS: OVERLAPP\n", + "\n", + "Date: Febuary 2,2026\n", + "\n", + "Authors: Harshini Girish(UAH), Rajat Shinde (UAH), Alex Mandel (Development Seed), Samantha Niemoeller (JPL)\n", + "\n", + "\n", + "Description: This notebook queries NISAR L2 GCOV granules (via earthaccess) and ESA CCI BIOMASS V5.01 tiles (via ESA MAAP STAC) 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.\n", + "An optional overlap layer highlights where NISAR and BIOMASS footprints intersect (bbox or true geometry). The result quickly shows where data coincides spatially to support fusion workflows." + ] + }, + { + "cell_type": "markdown", + "id": "93b44b7d-f5a5-4048-80e5-737db0998a43", + "metadata": {}, + "source": [ + "## Run This Notebook\n", + "\n", + "To access and run this tutorial within MAAP's Algorithm Development Environment (ADE), please refer to the [\"Getting started with the MAAP\"](https://docs.maap-project.org/en/latest/getting_started/getting_started.html) section of our documentation.\n", + "\n", + "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." + ] + }, + { + "cell_type": "markdown", + "id": "399aa805-c518-4cde-812d-8729c5e888d9", + "metadata": {}, + "source": [ + "## Additional Resources\n", + "- [NISAR](https://nisar.jpl.nasa.gov/)\n", + "- [BIOMASS](https://docs.maap-project.org/en/develop/science/ESA_CCI/ESA_CCI_V5_Token_Access.html)\n" + ] + }, + { + "cell_type": "markdown", + "id": "a328ae66-198a-4906-b2f5-ffc387ee44b1", + "metadata": {}, + "source": [ + "## Import and Install Packages" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "86047982", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import stat\n", + "import getpass\n", + "import pathlib\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "import earthaccess\n", + "from pystac_client import Client\n", + "\n", + "from shapely.geometry import Polygon, box, mapping, shape\n", + "from collections import Counter\n", + "\n", + "from folium import Map, GeoJson, LayerControl\n", + "\n", + "plt.rcParams[\"figure.figsize\"] = (6, 6)\n", + "plt.rcParams[\"axes.grid\"] = False\n" + ] + }, + { + "cell_type": "markdown", + "id": "80529155", + "metadata": {}, + "source": [ + "## Inputs\n", + "This “Inputs” section defines the search settings used later in the notebook: BBOX sets the area of interest as (min_lon, min_lat, max_lon, max_lat) and can be used to spatially filter both datasets, `NISAR_TEMPORAL = (\"2025-10-01\", \"2025-12-31\")` restricts the NISAR search to granules acquired within that date range, and `NISAR_COUNT = 6` limits how many NISAR granules (and footprints) will be plotted. For BIOMASS, `BIOMASS_YEAR = 2010` selects a specific annual layer, and BIOMASS_DT converts it into a STAC datetime range (2010-01-01/2010-12-31) used in the BIOMASS query; the tip warns that choosing a year outside the collection’s indexed range (often 2010–2021) will return zero results.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "8ed53932-c0b5-4223-ba86-aed95cbd65d3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NISAR_TEMPORAL: ('2025-10-01', '2025-12-31')\n", + "BIOMASS_DT: 2010-01-01/2010-12-31\n" + ] + } + ], + "source": [ + "NISAR_TEMPORAL = (\"2025-10-01\", \"2025-12-31\")\n", + "\n", + "# How many NISAR granules to plot\n", + "NISAR_COUNT = 6\n", + "\n", + "# BIOMASS year (choose explicitly from available years: 2010..2021)\n", + "BIOMASS_YEAR = 2010\n", + "BIOMASS_DT = f\"{BIOMASS_YEAR}-01-01/{BIOMASS_YEAR}-12-31\"\n", + "BIOMASS_LIMIT = 500 \n", + "\n", + "print(\"NISAR_TEMPORAL:\", NISAR_TEMPORAL)\n", + "print(\"BIOMASS_DT:\", BIOMASS_DT)\n" + ] + }, + { + "cell_type": "markdown", + "id": "e2dc36db-f686-41e7-8cbf-c152fcbf84fc", + "metadata": {}, + "source": [ + "## Access the Data\n" + ] + }, + { + "cell_type": "markdown", + "id": "5b6c52d4", + "metadata": {}, + "source": [ + "### 1) NISAR data" + ] + }, + { + "cell_type": "markdown", + "id": "9a8b1506-d6a1-422b-984c-92e172e298b7", + "metadata": {}, + "source": [ + "This cell sets the NISAR collection short name (`NISAR_L2_GCOV_BETA_V1`), logs in to Earthdata via `earthaccess.login()`, and then queries CMR for matching NISAR granules within the specified `NISAR_TEMPORAL` window, limited to `NISAR_COUNT` results and filtered to cloud-hosted items. Finally, it prints how many granules were returned by the search.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a7711773", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NISAR granules found: 6\n" + ] + } + ], + "source": [ + "NISAR_SHORT_NAME = \"NISAR_L2_GCOV_BETA_V1\"\n", + "\n", + "earthaccess.login()\n", + "\n", + "nisar_results = earthaccess.search_data(\n", + " short_name=NISAR_SHORT_NAME,\n", + " cloud_hosted=True,\n", + " temporal=NISAR_TEMPORAL,\n", + " count=NISAR_COUNT,\n", + ")\n", + "\n", + "print(\"NISAR granules found:\", len(nisar_results))\n" + ] + }, + { + "cell_type": "markdown", + "id": "642c4607-928f-4319-bbfc-6407c84267d0", + "metadata": {}, + "source": [ + "This cell loops through the NISAR search results and converts each granule into a GeoJSON footprint feature using `nisar_granule_to_feature(g)`, skipping any granules that don’t contain usable footprint metadata. It then bundles all successfully created footprint features into a GeoJSON `FeatureCollection` called `nisar_fc`. Finally, it prints how many NISAR footprint polygons were added to the collection for mapping.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "d2c4f32a-66c6-48d3-81aa-005bed1ebfd1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NISAR footprints in FeatureCollection: 6\n" + ] + } + ], + "source": [ + "# Build NISAR FeatureCollection for mapping\n", + "nisar_features = []\n", + "for g in nisar_results:\n", + " try:\n", + " nisar_features.append(nisar_granule_to_feature(g))\n", + " except Exception as e:\n", + " print(\"Skipping granule (no footprint):\", e)\n", + "\n", + "nisar_fc = {\"type\": \"FeatureCollection\", \"features\": nisar_features}\n", + "print(\"NISAR footprints in FeatureCollection:\", len(nisar_fc[\"features\"]))\n" + ] + }, + { + "cell_type": "markdown", + "id": "883e8a62-08b4-46e7-8d98-9edd787f6cb9", + "metadata": {}, + "source": [ + "This cell defines helper functions used before visualization. `_get_umm(g)` safely extracts the UMM metadata dictionary from an `earthaccess` granule. `nisar_granule_to_feature(g)` then converts a single NISAR granule into a GeoJSON Feature by reading its spatial geometry (preferring a polygon from `GPolygons` and falling back to a `BoundingRectangles` box if needed), extracting the granule’s start/end times, and attaching an ID/title in the feature properties. The output Feature objects are later collected into a FeatureCollection and plotted on the interactive map.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0961138d", + "metadata": {}, + "outputs": [], + "source": [ + "def _get_umm(g):\n", + " try:\n", + " return g.get(\"umm\", {})\n", + " except Exception:\n", + " return {}\n", + "\n", + "\n", + "def nisar_granule_to_feature(g):\n", + " umm = _get_umm(g)\n", + "\n", + " geom = (\n", + " umm.get(\"SpatialExtent\", {})\n", + " .get(\"HorizontalSpatialDomain\", {})\n", + " .get(\"Geometry\", {})\n", + " )\n", + "\n", + " poly = None\n", + "\n", + " # Prefer polygon boundary\n", + " gpolys = geom.get(\"GPolygons\", [])\n", + " if gpolys:\n", + " pts = gpolys[0].get(\"Boundary\", {}).get(\"Points\", [])\n", + " if pts:\n", + " coords = [(p[\"Longitude\"], p[\"Latitude\"]) for p in pts]\n", + " if coords and coords[0] != coords[-1]:\n", + " coords = coords + [coords[0]]\n", + " poly = Polygon(coords)\n", + "\n", + " # Fallback to bounding rectangle\n", + " if poly is None:\n", + " rects = geom.get(\"BoundingRectangles\", [])\n", + " if rects:\n", + " r = rects[0]\n", + " poly = box(\n", + " r[\"WestBoundingCoordinate\"],\n", + " r[\"SouthBoundingCoordinate\"],\n", + " r[\"EastBoundingCoordinate\"],\n", + " r[\"NorthBoundingCoordinate\"],\n", + " )\n", + "\n", + " if poly is None:\n", + " raise ValueError(\"Could not extract footprint geometry from NISAR granule metadata\")\n", + "\n", + " # Time\n", + " time_range = (\n", + " umm.get(\"TemporalExtent\", {})\n", + " .get(\"RangeDateTime\", {})\n", + " )\n", + " t0 = time_range.get(\"BeginningDateTime\")\n", + " t1 = time_range.get(\"EndingDateTime\")\n", + "\n", + " # Title/ID-like field\n", + " title = umm.get(\"GranuleUR\") or umm.get(\"Title\") or \"NISAR granule\"\n", + "\n", + " return {\n", + " \"type\": \"Feature\",\n", + " \"geometry\": mapping(poly),\n", + " \"properties\": {\"title\": title, \"t0\": t0, \"t1\": t1},\n", + " }\n" + ] + }, + { + "cell_type": "markdown", + "id": "e94fa9b5", + "metadata": {}, + "source": [ + "### 2) ESA BIOMASS data\n", + "This cell manages your ESA MAAP access token by storing it in a local file (`~/.config/esa_maap/ticket`). If the token file doesn’t exist, it securely prompts you to paste the token, saves it, and sets strict permissions (read/write only for you). It then verifies the file permissions are exactly `600` for security and finally reads the token into `ESA_TOKEN`, confirming where it was loaded from for use in BIOMASS/ESA authenticated requests.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "8c979f77", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Token loaded from file: /projects/.config/esa_maap/ticket\n" + ] + } + ], + "source": [ + "#ESA token file (used if/when you need Authorization for assets) \n", + "TOKEN_FILE = pathlib.Path.home() / \".config\" / \"esa_maap\" / \"ticket\"\n", + "TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)\n", + "\n", + "if not TOKEN_FILE.exists():\n", + " tok = getpass.getpass(\"Paste ESA portal token (hidden): \").strip()\n", + " if not tok:\n", + " raise ValueError(\"Empty token.\")\n", + " TOKEN_FILE.write_text(tok, encoding=\"utf-8\")\n", + " TOKEN_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)\n", + "\n", + "st = TOKEN_FILE.stat()\n", + "if (st.st_mode & 0o777) != 0o600:\n", + " raise PermissionError(f\"{TOKEN_FILE} must have mode 600. Fix with: chmod 600 {TOKEN_FILE}\")\n", + "\n", + "ESA_TOKEN = TOKEN_FILE.read_text(encoding=\"utf-8\").strip()\n", + "print(\"Token loaded from file:\", TOKEN_FILE)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "72448e29-8942-40ec-8ab6-e38af08380ae", + "metadata": {}, + "outputs": [], + "source": [ + "USE_BIOMASS_BBOX = False # set True to restrict BIOMASS to AOI, False for more items" + ] + }, + { + "cell_type": "markdown", + "id": "590dcbe8-cd62-4c94-ac11-8d6257f6c8f2", + "metadata": {}, + "source": [ + "This cell queries the ESA MAAP STAC catalog for BIOMASS V5.01 items. It opens the STAC endpoint, builds a `search_kwargs` dictionary with the BIOMASS collection, the selected year/time window (`BIOMASS_DT`), and a result cap (`BIOMASS_LIMIT = 25`). If `USE_BIOMASS_BBOX` is enabled, it also adds your AOI bounding box to restrict results spatially; otherwise it searches more broadly. Finally, it executes the STAC search, collects the returned items into `biomass_items`, and prints how many BIOMASS items were found and whether the bbox filter was applied.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "a8d0f7bb-19b8-41c3-99b1-cde5b9d081b7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "BIOMASS items found: 619\n" + ] + } + ], + "source": [ + "STAC_URL = \"https://catalog.maap.eo.esa.int/catalogue/\"\n", + "BIOMASS_COLLECTION = \"CCIBiomassV5.01\"\n", + "\n", + "BIOMASS_LIMIT = 25 \n", + "\n", + "api = Client.open(STAC_URL)\n", + "\n", + "search_kwargs = dict(\n", + " collections=[BIOMASS_COLLECTION],\n", + " datetime=BIOMASS_DT,\n", + " limit=BIOMASS_LIMIT,\n", + ")\n", + "\n", + "if USE_BIOMASS_BBOX:\n", + " search_kwargs[\"bbox\"] = list(BBOX)\n", + "\n", + "search = api.search(**search_kwargs)\n", + "\n", + "biomass_items = list(search.get_items())\n", + "print(\"BIOMASS items found:\", len(biomass_items))" + ] + }, + { + "cell_type": "markdown", + "id": "83a14cb4-1685-4218-8655-424aa2847203", + "metadata": {}, + "source": [ + "This cell converts the BIOMASS STAC search results (`biomass_items`) into a GeoJSON `FeatureCollection` for mapping. It defines `biomass_item_to_feature()` to package each STAC item’s footprint geometry (`item.geometry`) and key metadata fields (item id and temporal fields like `start_datetime`/`end_datetime`) into a GeoJSON Feature. It then applies this conversion to every BIOMASS item, stores the results in `biomass_fc`, and prints how many BIOMASS footprint features are available to plot on the interactive map (here, 619).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "5bfaa8f5-82d9-4a19-aadb-fe5a75c75f81", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "BIOMASS footprints in FeatureCollection: 619\n" + ] + } + ], + "source": [ + "# Build BIOMASS FeatureCollection for mapping\n", + "def biomass_item_to_feature(item):\n", + " return {\n", + " \"type\": \"Feature\",\n", + " \"geometry\": item.geometry,\n", + " \"properties\": {\n", + " \"id\": item.id,\n", + " \"start_datetime\": item.properties.get(\"start_datetime\"),\n", + " \"end_datetime\": item.properties.get(\"end_datetime\"),\n", + " \"datetime\": item.properties.get(\"datetime\"),\n", + " },\n", + " }\n", + "\n", + "biomass_features = [biomass_item_to_feature(it) for it in biomass_items]\n", + "biomass_fc = {\"type\": \"FeatureCollection\", \"features\": biomass_features}\n", + "\n", + "print(\"BIOMASS footprints in FeatureCollection:\", len(biomass_fc[\"features\"]))\n" + ] + }, + { + "cell_type": "markdown", + "id": "729cd4a4", + "metadata": {}, + "source": [ + "## Interactive map: NISAR and BIOMASS footprint layers\n", + "\n", + "This section creates a single interactive Leaflet/Folium map centered on the midpoint of the AOI `BBOX`. It then overlays two GeoJSON layers: one showing the footprint polygons for all discovered NISAR granules (`nisar_fc`) with tooltips for the granule title and start/end times, and another showing the footprint polygons for BIOMASS items (`biomass_fc`) with tooltips for tile ID and temporal coverage. Finally, it adds a `LayerControl` so you can toggle the NISAR and BIOMASS layers on/off to visually compare their spatial overlap.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "b85cc1fe", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "