from contextlib import closing
from pathlib import Path
import time
from urllib.parse import urlparse
import earthaccess
import geopandas as gpd
import holoviews as hv
import hvplot.pandas
import hvplot.xarray
import numpy as np
import pandas as pd
from rasterio.enums import Resampling
import rioxarray
import xarray as xr
from shapely.geometry import box
import os
from vitals import emit_tools as et
DATA_DIR = Path("data")
hv.extension("bokeh")Temporal Aggregation of EMIT Mineralogy vs. Ground Alunite Geochemistry
This notebook is part of a series presented at the Workshop on Remote Sensing and Critical Mineral Discovery presented at the University of Utah on September 29, 2026.
Authors: K. Dana Chadwick1, Philip G. Brodrick1, Erik A. Bolch2, Rupsesh Shrestha3, and Noah J. Christensen4
- NASA Jet Propulsion Laboratory, California Institute of Technology.
- KBR Inc., contractor to the USGS Earth Observation Research and Science Center, NASA Land Processes Distributed Active Archive Center.
- Oak Ridge National Laboratory Distributed Active Archive Center.
- Utah Geological Survey.
Summary
The EMIT visible-to-shortwave-infrared (VSWIR) imaging spectrometer measures reflected sunlight across many adjacent wavelength bands in the 380 - 2500 nm range. In this notebook, we will use the earthaccess python library to search for and access EMIT L2B Mineral Identification, Band Depth and Uncertainty product (EMITL2BMIN.001) for observations acquired in southwestern Utah in Beaver and Iron Counties. We will then aggregate the mineral data across those scenes and compare the results against surface geochemistry data in a small region with an alunite exposure and geochemical samples provided by the Utah Geological Survey (UGS).
Background
Every EMIT scene a look at the Earth’s surface at one moment in time. When EMIT data are used to estimate surface mineralogy using the Tetracorder expert system, the ability to correctly identify minerals in each scene can be affected by illumination geometry, the exact area contained within the observation footprint, thin cirrus, residual atmospheric correction error, seasonal vegetation, or snow cover. As a result, mineral identifications can vary from scene to scene and be absent intermittantly. Temporal aggregation can help mitigate these scene-specific effects and reveal the persistent mineralogy over time. EMIT has an inconsistent revisit rate due to its location on the ISS, and since mid-2022 it has built up repeat coverage of most of the mid-latitudes. If we classify every clear scene over an area and stack the observations, we can ask ‘what is the most common mineral identification in this location?’ This aggregated mineralogy raster provides a more consistent product than any single scene. In this notebook we will examine an aggregated mineralogy raster, and the average band depths, and compare the with ground measurements provided by the Utah Geological Survey. For our example area of interest we have geochemical data of 138 surface samples with major-oxide chemistry, and therefore two independent stoichiometric estimates of alunite abundance: one from K₂O and one from SO₃. After aggregating our EMIT data, we bring in the ground samples for comparison.
References and Resources on the regional geology and EMIT data
- EMIT L2B Mineralogy: Green, R. et al. (2024). EMIT L2B Estimated Mineral Identification and Band Depth and Uncertainty 60 m V001. NASA LP DAAC. https://doi.org/10.5067/EMIT/EMITL2BMIN.001
- EMIT L2A Reflectance & Mask: NASA LP DAAC. https://doi.org/10.5067/EMIT/EMITL2ARFL.001
- Utah Geological Survey, Open-File Report 671 (geologic map and accompanying surface geochemical dataset), in
data/ofr-671/ - Lindsey, D.A. & Osmonson, L.M. (1978). Mineral potential of altered rocks near Blawn Mountain, Wah Wah Range, Utah. USGS Open-File Report 78-114.
- Barkoff, D.W. (2022). PhD dissertation, University of Nevada Las Vegas. https://doi.org/10.34917/35777457
- Portela, B. et al. (2025). Ore Geology Reviews, 182, 106673.
Contents
Learning objectives
- List the EMIT collections in NASA’s CMR and pin the collection and version used.
- Search for EMIT mineralogy granules and matching quality masks over the alunite study area.
- Retain all scenes with full study-area coverage, without ranking or mosaicking.
- Group spectral-library entries into five indicator classes and apply consistent quality thresholds.
- Align scene grids and calculate mineral modes, valid-observation counts, alunite persistence, and mean alunite band depth.
- Compare the aggregate with UGS sample estimates while recognizing the different spatial scales and limits of the comparison.
Prerequisites
A NASA Earthdata account and a compatible Python environment are required.
1. Setup
We use EMIT mineral-identification products from multiple acquisitions to map recurring alteration-mineral detections in the alunite study area. earthaccess provides access to the remote files, geopandas handles vector boundaries, and xarray holds the aligned raster observations. A NASA Earthdata Login account is required to read the EMIT files.
Granules are streamed directly from the archive rather than cached locally, so rerunning the processing cells later in the notebook will transfer the data again. The number of scenes retained depends on the cloud-cover and full-coverage criteria applied in Section 3; there is no fixed scene limit.
2. Geological context and study area
Two boundaries are used here: a regional AOI for geological context, and a smaller rectangle around an alunite exposure for analysis.
2.1 Define the regional AOI and alunite study area
The regional AOI covers Southern Pine Valley in the southern Wah Wah Range, Beaver and Iron Counties, Utah. The alunite study area boundary contained in Alunite_zoom.kmz is what we will use for searching, processing scenes, and comparison against ground samples in the sections that follow.
# Regional boundary for the geological context map.
aoi_gdf = gpd.read_file(DATA_DIR / "AOI" / "AOI.zip").to_crs(4326)
regional_bbox = tuple(float(v) for v in aoi_gdf.total_bounds)
# Smaller study-area rectangle for searching, raster analysis, and sample comparison.
alunite_gdf = gpd.read_file(DATA_DIR / "AOI" / "Alunite_zoom.kmz").to_crs(4326)
study_bbox = tuple(float(v) for v in alunite_gdf.total_bounds)
study_poly = box(*study_bbox)
study_area_gdf = gpd.GeoDataFrame(geometry=[study_poly], crs=4326)
pd.DataFrame(
[regional_bbox, study_bbox],
columns=["west", "south", "east", "north"],
index=["Regional context", "Alunite search and study area"],
)| west | south | east | north | |
|---|---|---|---|---|
| Regional context | -113.737629 | 38.016881 | -113.474612 | 38.227452 |
| Alunite search and study area | -113.675708 | 38.038942 | -113.633664 | 38.069785 |
2.2 Visualize the geological context
The UGS Southern Pine Valley geologic map (Open-File Report 671) shows the mapped rock units around the study area. We clip it to the regional AOI and outline the alunite study area within it.
Figure 1. Geological context: mapped rock units, the regional boundary, and the alunite study-area rectangle.
import warnings
warnings.filterwarnings('ignore', message='invalid value encountered in create_collection')GEO_DIR = DATA_DIR / "ofr-671"
geo = gpd.read_file(
GEO_DIR
/ "OFR-671DM_SouthernPineValleyArea_Shapefiles"
/ "SouthernPineValleyArea_GeologicUnits.shp"
).to_crs(aoi_gdf.crs)
# GeoPandas clipping needs matching CRSs; hvPlot handles the map projection.
geo_aoi = gpd.clip(geo, aoi_gdf)
geology_map = geo_aoi.hvplot.polygons(
geo=True, tiles="EsriWorldTopo", c="Grouping", cmap="tab20",
fill_alpha=0.65, line_color="#4d4d4d", line_width=0.3,
hover_cols=["UnitSymbol", "Grouping"], legend="right",
)
aoi_outline = aoi_gdf.hvplot.polygons(
geo=True, fill_alpha=0, line_color="red", line_width=2,
label="Regional AOI", hover=False,
)
study_outline = study_area_gdf.hvplot.polygons(
geo=True, fill_alpha=0, line_color="cyan", line_width=2,
line_dash="dashed", label="Alunite study area", hover=False,
)
geology_figure = (geology_map * aoi_outline * study_outline).opts(
title="Southern Pine Valley: geological context",
width=750, height=620, padding=0.06,
xaxis=None, yaxis=None,
)
geology_figure2.3 Describe the geologic units
The table lists the units that intersect the alunite study area, based on the clipped geometry.
geo_attr = pd.read_csv(
GEO_DIR / "OFR-671DM_SouthernPineValley_GeologicUnit_AttributeTable.csv",
encoding="latin-1",
)
# Select descriptions using actual intersections with the alunite study area.
geo_study = gpd.clip(geo, study_area_gdf)
units_present = geo_study["UnitSymbol"].dropna().unique()
tbl = (
geo_attr.loc[
geo_attr["UnitSymbol"].isin(units_present),
["UnitSymbol", "UnitName", "Age", "Grouping"],
]
.sort_values("UnitSymbol")
.reset_index(drop=True)
)
tbl| UnitSymbol | UnitName | Age | Grouping | |
|---|---|---|---|---|
| 0 | Qa | Alluvium | Quaternary | Alluvial deposits |
| 1 | Ti | Isom Formation | Oligocene | Isom Formation |
| 2 | Tl | Lund Formation | Oligocene | Needles Range Group |
| 3 | Tsr | Rhyolite member of Steamboat Mountain Formation | Miocene | Steamboat Mountain Formation |
| 4 | Tt | Rhyolite tuffs and related clastic deposits, u... | Miocene | Miocene volcanic and volcaniclastic rocks |
| 5 | Tv | Volcanic rocks, undivided | Miocene | Miocene volcanic rocks |
| 6 | Twi | Intracaldera member of Wah Wah Springs Formation | Oligocene | Needles Range Group |
3. Search and retain EMIT observations
In this section we search for EMIT V001 mineral-identification granules intersecting the alunite study-area bounding box, filtering out scenes with reported cloud cover greater than 25%. We keep every granule whose catalog footprint fully covers the study area, with no ranking or scene-count limit, and skip granules that only partially cover it rather than mosaicking adjacent scenes together, so an acquisition split across two adjacent granules is left out even if, combined, they would cover the area. Per-pixel quality flags are still applied during processing, since catalog coverage and scene-level cloud cover don’t guarantee a usable observation at every pixel. The result is a set of observations based on clear sky granule coverage that may pull from any time point during EMIT’s obsevational history.
3.1 Authenticate and understand the products
Authenticate with Earthdata before reading remote files. EMIT L2BMIN V001 supplies mineral identifications and band depths, with fit information in its companion uncertainty file. We pair it with the standalone L2AMASK V002 collection for per-pixel quality flags.
| Product | Collection | Use |
|---|---|---|
| Mineral identification and uncertainty | EMITL2BMIN, V001 |
Mineral IDs, band depths, and fit values |
| Pixel-quality masks | EMITL2AMASK, V002 |
Additional cloud, cirrus, and spacecraft screening |
Tetracorder compares reflectance spectra with spectral-library entries. The product records up to two identifications per pixel: Group 1 emphasizes the visual and near infrared, and Group 2 the short wave infrared. A mineral ID identifies a library entry, band depth describes absorption strength, and fit describes the spectral match. These are not mineral percentages. ID 0 means no library match in that group; it is distinct from missing or quality-masked data.
earthaccess.login(persist=True)<earthaccess.auth.Auth at 0x12719de80>
3.2 Search for the EMIT collections
NASA’s Common Metadata Repository (CMR) organizes data into collections (datasets) that contain granules (a specific geospatial EMIT scene processed to a given product level). Before searching for granules, we list the available EMIT collections with earthaccess.search_datasets and the keyword argument. Each collection is identified by a short_name, a version, and a concept_id that is unique to that collection and version.
collections = earthaccess.search_datasets(keyword="EMIT")
print(f"Collections found: {len(collections)}")
# short_name, version, and concept_id are the fields used to target a collection in a granule search.
collections_info = pd.DataFrame(
[
{
"short_name": c["umm"]["ShortName"],
"version": c["umm"]["Version"],
"concept_id": c["meta"]["concept-id"],
"entry_title": c["umm"]["EntryTitle"],
}
for c in collections
]
)
# The keyword search also matches related records; keep the EMIT collections themselves.
emit_collections = (
collections_info.loc[collections_info["short_name"].str.startswith("EMIT")]
.sort_values(["short_name", "version"])
.reset_index(drop=True)
)
pd.set_option("display.max_colwidth", 90)
emit_collectionsCollections found: 72
| short_name | version | concept_id | entry_title | |
|---|---|---|---|---|
| 0 | EMITL0 | 001 | C2407897135-LPCLOUD | EMIT L0 Telemetry and Compressed Raw Instrument Data V001 |
| 1 | EMITL1ARAW | 001 | C2407975601-LPCLOUD | EMIT L1A Reassembled Raw Image Cube 60 m V001 |
| 2 | EMITL1BATT | 001 | C2408031090-LPCLOUD | EMIT L1B Corrected Spacecraft Attitude and Ephemeris V001 |
| 3 | EMITL1BATT | 002 | C4079607609-LPCLOUD | EMIT L1B Corrected Spacecraft Attitude and Ephemeris V002 |
| 4 | EMITL1BRAD | 001 | C2408009906-LPCLOUD | EMIT L1B At-Sensor Calibrated Radiance and Geolocation Data 60 m V001 |
| 5 | EMITL1BRAD | 002 | C4079829720-LPCLOUD | EMIT L1B At-Sensor Calibrated Radiance and Geolocation Data 60 m V002 |
| 6 | EMITL2AMASK | 002 | C3882545269-LPCLOUD | EMIT L2A Masks 60 m V002 |
| 7 | EMITL2AMASK | 003 | C4279547358-LPCLOUD | EMIT L2A Masks 60 m V003 |
| 8 | EMITL2ARFL | 001 | C2408750690-LPCLOUD | EMIT L2A Estimated Surface Reflectance and Uncertainty and Masks 60 m V001 |
| 9 | EMITL2ARFL | 002 | C4079844428-LPCLOUD | EMIT L2A Surface Reflectance and Uncertainty 60 m V002 |
| 10 | EMITL2BCH4ENH | 002 | C3242680113-LPCLOUD | EMIT L2B Methane Enhancement Data 60 m V002 |
| 11 | EMITL2BCH4ENH | 003 | C4303752968-LPCLOUD | EMIT L2B Methane Enhancement Data 60 m V003 |
| 12 | EMITL2BCH4PLM | 002 | C3242707413-LPCLOUD | EMIT L2B Estimated Methane Plume Complexes 60 m V002 |
| 13 | EMITL2BCO2ENH | 002 | C3243477145-LPCLOUD | EMIT L2B Carbon Dioxide Enhancement Data 60 m V002 |
| 14 | EMITL2BCO2ENH | 003 | C4303752974-LPCLOUD | EMIT L2B Carbon Dioxide Enhancement Data 60 m V003 |
| 15 | EMITL2BCO2PLM | 002 | C3244277342-LPCLOUD | EMIT L2B Estimated Carbon Dioxide Plume Complexes 60 m V002 |
| 16 | EMITL2BFRCOV | 001 | C3911089796-LPCLOUD | EMIT L2B Fractional Cover and Uncertainty 60 m V001 |
| 17 | EMITL2BFRCOV | 002 | C4303752957-LPCLOUD | EMIT L2B Fractional Cover and Uncertainty 60 m V002 |
| 18 | EMITL2BMIN | 001 | C2408034484-LPCLOUD | EMIT L2B Estimated Mineral Identification and Band Depth and Uncertainty 60 m V001 |
| 19 | EMITL2BMIN | 002 | C4079846859-LPCLOUD | EMIT L2B Estimated Mineral Identification and Band Depth and Uncertainty 60 m V002 |
| 20 | EMITL3ASA | 001 | C2408752948-LPCLOUD | EMIT L3 Aggregated Mineral Spectral Abundance and Uncertainty 0.5 Deg V001 |
| 21 | EMITL3ASA | 002 | C3147269019-LPCLOUD | EMIT L3 Aggregated Mineral Spectral Abundance and Uncertainty 0.5 Deg V002 |
| 22 | EMITL3RFL | 002 | C4284742737-LPCLOUD | EMIT L3 Gridded Surface Reflectance and Uncertainty 60 m V002 |
| 23 | EMITL4ESM | 001 | C2408755900-LPCLOUD | EMIT L4 Earth System Model Products V001 |
Several short names appear more than once because multiple versions of a collection are currently archived. In the future, after back-processing of scenes for the most recent version is completed, the older version will be removed from the archive. Since both versions are currently available, we want to explicitly pass version below.
emit_collections.loc[emit_collections["short_name"].isin(["EMITL2BMIN", "EMITL2AMASK"])]| short_name | version | concept_id | entry_title | |
|---|---|---|---|---|
| 6 | EMITL2AMASK | 002 | C3882545269-LPCLOUD | EMIT L2A Masks 60 m V002 |
| 7 | EMITL2AMASK | 003 | C4279547358-LPCLOUD | EMIT L2A Masks 60 m V003 |
| 18 | EMITL2BMIN | 001 | C2408034484-LPCLOUD | EMIT L2B Estimated Mineral Identification and Band Depth and Uncertainty 60 m V001 |
| 19 | EMITL2BMIN | 002 | C4079846859-LPCLOUD | EMIT L2B Estimated Mineral Identification and Band Depth and Uncertainty 60 m V002 |
For this notebook we will use EMITL2BMIN V001 and EMITL2AMASK V002 because the newer version processing has not yet delivered the scenes used in this notebook.
3.3 Search for mineralogy granules
A granule is one spatiotemporal file within a collection. Use earthaccess.search_data to search the alunite study-area bounding box with a maximum reported scene cloud cover of 25%, and retrieve all matching granules before applying the footprint check.
Granule searches accept several kinds of criteria:
| Dataset origin and location | Spatiotemporal parameters | Collection metadata parameters |
|---|---|---|
archive_center |
bounding_box |
concept_id |
data_center |
temporal |
entry_title |
daac |
point |
granule_name |
provider |
polygon |
version |
cloud_hosted |
line |
short_name |
Note that not every collection reports cloud cover in its metadata, so cloud_cover is not a universal filter; EMIT L2B mineralogy does report it. The value is a whole-scene percentage, which is why per-pixel quality flags are still used later to quantify the number of valid surface observations.
CLOUD_MAX = 25
emit_results = earthaccess.search_data(
short_name="EMITL2BMIN",
version="001",
bounding_box=study_bbox,
cloud_cover=(0, CLOUD_MAX),
)
print(f"Granules meeting the search criteria: {len(emit_results)}")Granules meeting the search criteria: 14
Each result is a metadata record that also carries the asset links for that granule. Inspect one to see the fields used below: the granule ID, acquisition time, and reported cloud cover drive the selection table, while data_links() supplies the mineral-identification and uncertainty files that Section 4.2 streams.
example = emit_results[0]
print("Granule: ", example["umm"]["GranuleUR"])
print("Acquired:", example["umm"]["TemporalExtent"]["RangeDateTime"]["BeginningDateTime"])
print("Cloud: ", example["umm"]["CloudCover"], "%")
print("Assets:")
for url in example.data_links():
print(" ", url)Granule: EMIT_L2B_MIN_001_20220822T192123_2223413_013
Acquired: 2022-08-22T19:21:23Z
Cloud: 23 %
Assets:
https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2BMIN.001/EMIT_L2B_MIN_001_20220822T192123_2223413_013/EMIT_L2B_MIN_001_20220822T192123_2223413_013.nc
https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2BMIN.001/EMIT_L2B_MIN_001_20220822T192123_2223413_013/EMIT_L2B_MINUNCERT_001_20220822T192123_2223413_013.nc
3.4 Retain scenes with full coverage
A bounding-box search returns any granule that overlaps part of the study area, so this step narrows that down: keep only granules whose individual footprint covers the entire study-area rectangle, then sort what’s left chronologically. As in Section 3, this omits partial-coverage granules rather than mosaicking neighbors together, even when adjacent granules together would cover the area. Every scene that passes this check is processed in Section 4; the chronological sort only controls the order scenes are displayed and processed in.
if not emit_results:
raise ValueError("No granules meet the search criteria.")
# Retrieve Granule Geometries.
footprints = gpd.GeoDataFrame(
{
"granule": [r["umm"]["GranuleUR"] for r in emit_results],
"date": [
r["umm"]["TemporalExtent"]["RangeDateTime"]["BeginningDateTime"][:10]
for r in emit_results
],
"cloud_pct": [float(r["umm"]["CloudCover"]) for r in emit_results],
"result": emit_results,
},
geometry=gpd.GeoSeries(emit_results, crs=4326),
)
# Keep every fully covering scene; sort chronologically without limiting the count.
footprints["covers_study_area"] = footprints.geometry.covers(study_poly)
picks = (
footprints.loc[footprints["covers_study_area"]]
.sort_values(["date", "granule"])
.reset_index(drop=True)
)
if picks.empty:
raise ValueError("No granules meet the cloud-cover and study-area coverage criteria.")
footprints["selected"] = footprints["granule"].isin(picks["granule"])
print(f"Retained {len(picks)} of {len(footprints)} granules with full study-area coverage.")
print(f"Omitted {len(footprints) - len(picks)} partial-coverage granules; no mosaicking is applied.")
picks[["date", "cloud_pct", "granule"]]Retained 9 of 14 granules with full study-area coverage.
Omitted 5 partial-coverage granules; no mosaicking is applied.
| date | cloud_pct | granule | |
|---|---|---|---|
| 0 | 2023-04-20 | 7.0 | EMIT_L2B_MIN_001_20230420T200006_2311013_012 |
| 1 | 2023-04-24 | 12.0 | EMIT_L2B_MIN_001_20230424T182248_2311412_013 |
| 2 | 2023-06-27 | 8.0 | EMIT_L2B_MIN_001_20230627T170626_2317811_012 |
| 3 | 2023-10-09 | 11.0 | EMIT_L2B_MIN_001_20231009T165914_2328211_009 |
| 4 | 2023-10-16 | 6.0 | EMIT_L2B_MIN_001_20231016T210319_2328914_012 |
| 5 | 2025-06-20 | 4.0 | EMIT_L2B_MIN_001_20250620T182509_2517112_014 |
| 6 | 2025-08-01 | 4.0 | EMIT_L2B_MIN_001_20250801T184807_2521312_012 |
| 7 | 2026-01-29 | 12.0 | EMIT_L2B_MIN_001_20260129T191031_2602912_012 |
| 8 | 2026-04-15 | 11.0 | EMIT_L2B_MIN_001_20260415T200247_2610513_018 |
3.5 Visualize retained footprints
Use hvplot to show the footprints on a map with our alunite study area.
Figure 2. Selected EMIT scene footprints: the granules retained in Section 3.4, colored by granule, over the alunite study area.
selected_map = picks.hvplot.polygons(
geo=True, tiles="EsriWorldTopo", c="granule", line_color="granule",
cmap="Category20", fill_alpha=0.15, line_alpha=1, line_width=2,
hover_cols=["granule", "date", "cloud_pct"], legend=False,
)
scene_coverage_figure = (selected_map * study_outline).opts(
title="Selected EMIT scenes and alunite study area",
width=750, height=550,
)
scene_coverage_figure3.6 Find matching pixel-quality masks
Search the EMIT L2AMASK V002 collection over the same alunite study area, then match results to the retained L2B mineralogy granules using the timestamp embedded in each filename. The cloud-cover filter is skipped here, since coverage is already constrained by the mineralogy search above.
def asset_url(result, product):
"""Find one netCDF asset by its product filename marker."""
urls = [u for u in result.data_links() if product in Path(urlparse(u).path).name and u.endswith(".nc")]
if len(urls) != 1:
raise ValueError(f"Expected one {product} asset; found {len(urls)}.")
return urls[0]
def scene_key(result):
"""Timestamp, orbit, and scene suffix shared by companion asset filenames."""
url = next(u for u in result.data_links() if u.endswith(".nc"))
return "_".join(Path(urlparse(url).path).stem.split("_")[-3:])
MASK_COLLECTION = "EMITL2AMASK"
MASK_VERSION = "002"
mask_results = earthaccess.search_data(
short_name=MASK_COLLECTION, version=MASK_VERSION,
bounding_box=study_bbox,
temporal=(picks["date"].min(), picks["date"].max()),
count=-1,
)
mask_by_scene = {scene_key(r): r for r in mask_results}
missing = [scene_key(r) for r in picks["result"] if scene_key(r) not in mask_by_scene]
if missing:
raise ValueError(f"No matching quality-mask granule for: {missing}")
print(f"Matched masks for all {len(picks)} retained mineral granules.")Matched masks for all 9 retained mineral granules.
4. Prepare and classify the scenes
Prepare each retained scene with the same mineral grouping, quality flags, and thresholds. Keep three layers per acquisition: indicator class, usable-observation mask, and detection band depth.
4.1 Group mineral mixtures into indicator classes
The supplied mineral-grouping table links EMIT library IDs to spectral-library names. We group those names into five tutorial classes: alunite, pyrophyllite, kaolinite, dickite, and white mica. This is a first pass categorization and for detailed examination of the spectral end members, one should refer back to the full library names and groupings in the L2BMIN product and examine the mineral identification information and notes in the spectral library.
When a pixel’s library match could fall into more than one class, we resolve it with a fixed precedence: alunite first, then dickite and kaolinite; muscovite–pyrophyllite mixtures are assigned to white mica. This is a choice for visualization purposes - some of these identifications contain mixtures of these groups (e.g. Alunite0.5+Kaol_KGa-1 AMX3 W1R1Bb, which is half Alunite, half Kaolinite). We choose this order because in this tutorial, we’re explicitly interested the Alunite, so we layer it on top to see more of it on the map. Any 1D grouped classification slice will be slightly deceiving; in reality many pixels are mixtures.
Library index 0 means “no identification” in the L2B_MIN product but isn’t itself a row in the supplied table, so we add it explicitly and carry it through the same lookup rather than leaving it as an unchecked default.
The displayed table lists only the alunite variants and mixtures; the full lookup remains available internally for all five classes. Class 0 means no qualifying indicator detection, and the separate observation mask is what identifies unavailable pixels.
INDICATOR_ORDER = ["Alunite", "Pyrophyllite", "Kaolinite", "Dickite", "White mica"]
INDICATOR_COLORS = ["#d7191c", "#fdae61", "#ffffbf", "#abd9e9", "#2c7bb6"]
ALUNITE_CLASS = 1
def classify_name(name):
name = str(name).lower()
if "alunite" in name:
return "Alunite"
if "dickite" in name:
return "Dickite"
if "kaolin" in name:
return "Kaolinite"
if "pyrophyl" in name and "musc" not in name:
return "Pyrophyllite"
if any(term in name for term in ("muscovite", "illite", "sericite")):
return "White mica"
return None
matrix = pd.read_csv(DATA_DIR / "mineral_grouping_matrix_20230503.csv")
matrix["indicator"] = matrix["Name"].map(classify_name)
matrix["Filename"] = matrix["Filename"].map(os.path.basename)
# Index 0 is reserved by the product for "no identification" and has no row in the supplied
# table; add it explicitly so it is visible in the reference table rather than only implied by
# the lookup array's default fill value below.
no_match = pd.DataFrame([{"Index": 0, "Name": "No_Match", "indicator": None}])
matrix = (
pd.concat([no_match, matrix], ignore_index=True)
.sort_values("Index")
.reset_index(drop=True)
)
matrix["class_id"] = matrix["indicator"].map(
{name: i for i, name in enumerate(INDICATOR_ORDER, start=1)}
).fillna(0).astype("int16")
# Lookup table: EMIT spectral-library Index -> indicator class. classify_scene reads this as
# id_to_class[mineral_id] instead of testing membership in each class's ID list per pixel.
id_to_class = np.zeros(int(matrix["Index"].max()) + 1, dtype="int16")
id_to_class[matrix["Index"].to_numpy()] = matrix["class_id"].to_numpy()Let’s carefully examine the contents of each mapped class, so we undertsand any of the mixtures that might be present
# ALUNITE
# Display the spectral-library entries assigned to alunite
# Also show Tetracorder's Filename, which is indicative of how the given sample is utilized
matrix.loc[matrix["indicator"] == "Alunite", ["Index", "Name", "Filename"]].reset_index(drop=True)| Index | Name | Filename | |
|---|---|---|---|
| 0 | 109 | Alunite GDS97 K Syn (150C) W2R4Na | sulfate_kalun150c |
| 1 | 110 | Alunite GDS96 K Syn (250C) W2R4Na | sulfate_kalun250c |
| 2 | 111 | Alunite RES-2 K Syn (450C) W2R4Na | sulfate_kalun450c |
| 3 | 112 | Alunite GDS95 Na Syn (150C) W2R4Na | sulfate_naalun150c |
| 4 | 113 | Alunite RES-4 Na Syn (300C) W2R4Na | sulfate_naalun300c |
| 5 | 114 | Alunite RES-3 Na Syn (450C) W2R4Na | sulfate_naalun450c |
| 6 | 115 | Alunite GDS82 Na82 W1R1Bb | sulfate_na82alun100c |
| 7 | 116 | Alunite+Dickite MV99-6-26b W1R1Fc | sulfate+kaolingrp_natroalun+dickite |
| 8 | 117 | Alunite GDS83 Na63 W1R1Bb | sulfate_na63alun300c |
| 9 | 118 | Alunite RES-9 Summitv (400C) W2R4Nb | sulfate_na40alun400c |
| 10 | 119 | Alunite GDS84 Na03 W2R4Na | sulfate_alunNa03 |
| 11 | 120 | Alunite RES10 NA56% Syn 450C W2R4Na | sulfate_alunNa56450c |
| 12 | 121 | Alunite RES12 NA78% Syn 450C W2R4NaIS | sulfate_alunNa78.450c |
| 13 | 122 | Alunite0.35K+.65Na CU91-217H W2R4Nb | sulfate_alun35K65Na.low |
| 14 | 123 | Alunite0.73K+.27Na CU91-217D W2R4Na | sulfate_alun73K27Na.low |
| 15 | 124 | Alunite0.66K+.34Na CU91-217A W2R4Nb | sulfate_alun66K34Na.low |
| 16 | 158 | Alunite+Pyrophyl SD1093A W1R1Bb | alunite+pyrophyl |
| 17 | 162 | Alunite0.5+Kaol_KGa-1 AMX3 W1R1Bb | alunite.5+kaol.5 |
| 18 | 175 | Alunite.5+MuscCU91-250A AMX4 W1R1Ba | alunite.5+musc.5 |
| 19 | 182 | Alunite_NH4+Jaro NMNH145596A W1R1Bb | sulfate_ammonalunite |
# KAOLINITE
matrix.loc[matrix["indicator"] == "Kaolinite", ["Index", "Name", "Filename"]].reset_index(drop=True)| Index | Name | Filename | |
|---|---|---|---|
| 0 | 133 | Kaolinite CM9 (wxl) W1R1Hb | kaolgrp_kaolinite_wxl |
| 1 | 134 | Kaolinite KGa-2 (pxl) W1R1Hb | kaolgrp_kaolinite_pxl |
| 2 | 139 | Kaolin_Smect H89-FR-2 .5Kaol W1R1Bb | kaolin.5+smect.5 |
| 3 | 140 | Kaolin_Smect H89-FR-5 .3Kaol W1R1Bb | kaolin.3+smect.7 |
# MICA
matrix.loc[matrix["indicator"] == "White mica", ["Index", "Name", "Filename"]].reset_index(drop=True)| Index | Name | Filename | |
|---|---|---|---|
| 0 | 17 | Chlorite+Muscovite CU93-65A W1R1Ba | fe2+_chlor+muscphy |
| 1 | 125 | Muscovite CU91-250A med Al W2R4NbS | micagrp_muscovite-med-Al |
| 2 | 127 | Muscovite GDS113 Ruby W1R1Bb splib06 3348 | micagrp_muscovite-medhigh-Al |
| 3 | 129 | Illite IMt-1.b <2um W1R1BaS | micagrp_illite |
| 4 | 130 | Illite GDS4.2 Marblehead_WI W1R1H_ | micagrp_illite.gds4 |
| 5 | 131 | Muscovite CU93-1 low-Al Phyl W2R4Nb | micagrp_muscovite-low-Al |
| 6 | 132 | Muscovite GDS116 Tanzania W1R1Ba | micagrp_muscoviteFerich |
| 7 | 159 | Pyrophyl+Muscovite JH_PYRM1 W1R1Fb | pyroph+tr.musc |
| 8 | 160 | Muscovite+Pyrophyl JH_PYRP1 W1R1Fb | musc+pyroph |
| 9 | 177 | Muscovite+Chlorite CU91-253D W2R4Na | muscovite+chlorite |
| 10 | 178 | Ammonio-Illite/Smectit GDS87 W1R1Hb | smectite_ammonillsmec |
4.2 Read, mask, and subset the products
Build functions to open each remote file with earthaccess, apply the quality mask, subset to the alunite study area, and orthorectify onto geographic coordinates. Streaming avoids keeping local copies of the granules.
For masking we use the Cloud, Cirrus, and Spec-Tf-Cloud flags (bands 0, 1, and 9). Their names are printed when reading each mask.
Band depth and fit are the two per-identification quality measures Tetracorder reports for each group. Band depth describes how strong the absorption feature is; fit describes how closely the shape of that absorption matches the reference spectral-library entry, and comes from the companion EMITL2BMINUNCERT product. The convention for a good match is 2.0 * fit >= 0.40 because the group_*_fit values in EMITL2BMINUNCERT are stored at half scale so we set our minimum fit at 0.20.
CLOUD_FLAG_BANDS = [0, 1, 9] # 0 Cloud, 1 Cirrus, 9 Spec-Tf-Cloud Flag (this index will change in Mask v003)
BAND_DEPTH_MIN = 0.02
# Same numerical threshold as multiplying the stored fit by 2 and comparing to 0.40.
FIT_MIN_STORED = 0.20
def read_quality_mask(url):
with closing(earthaccess.open([url], provider="LPCLOUD")[0]) as file:
return et.quality_mask(file, CLOUD_FLAG_BANDS)
def read_study_subset(url, quality_mask):
with closing(earthaccess.open([url], provider="LPCLOUD")[0]) as file:
raw = et.emit_xarray(file, ortho=False, qmask=quality_mask)
try:
subset = et.spatial_subset(raw, study_area_gdf)
return et.ortho_xr(subset).load()
finally:
raw.close()4.3 Define the per-scene classifier
We design our classify function to retain indicator identifications that meet the band-depth and fit thresholds defined above. Where both mineral groups qualify, we keep the one with the deeper band depth, and ties keep Group 1. Class zero means no indicator passed the rules; the observation mask distinguishes that outcome from missing or masked data since a usable observation with no match still counts toward the observation total.
def classify_scene(result):
mask_result = mask_by_scene[scene_key(result)]
quality_mask = read_quality_mask(asset_url(mask_result, "_L2A_MASK_"))
minerals = read_study_subset(asset_url(result, "_L2B_MIN_"), quality_mask)
uncertainty = read_study_subset(asset_url(result, "_L2B_MINUNCERT_"), quality_mask)
# Companion products must refer to the same output pixels.
minerals, uncertainty = xr.align(minerals, uncertainty, join="exact")
shape = minerals["group_1_band_depth"].shape
classes = np.zeros(shape, dtype="int16")
best_depth = np.zeros(shape, dtype="float32")
observed = np.zeros(shape, dtype=bool)
for group in (1, 2):
ids = minerals[f"group_{group}_mineral_id"].values
depth = minerals[f"group_{group}_band_depth"].values
fit = uncertainty[f"group_{group}_fit"].values
# Exclude missing, fractional, and out-of-range IDs before indexing the lookup.
valid_id = np.isfinite(ids) & (ids >= 0) & (ids < id_to_class.size)
valid_id &= ids == np.floor(ids)
safe_ids = np.where(valid_id, ids, 0).astype("int64")
indicator_class = id_to_class[safe_ids]
# Include valid zero-depth observations in the persistence denominator.
usable = np.isfinite(depth) & (depth >= 0)
observed |= usable
passes = (
usable & (indicator_class > 0)
& np.isfinite(fit) & (depth >= BAND_DEPTH_MIN) & (fit >= FIT_MIN_STORED)
)
# Keep the deeper of the two groups' qualifying detections; ties keep group 1.
keep = passes & (depth > best_depth)
classes[keep] = indicator_class[keep]
best_depth[keep] = depth[keep]
dims = ("latitude", "longitude")
return xr.Dataset(
{
"mineral_class": (dims, classes),
"observed": (dims, observed),
"band_depth": (dims, np.where(classes > 0, best_depth, np.nan)),
},
coords={"latitude": minerals.latitude, "longitude": minerals.longitude},
)4.4 Process all retained scenes
Apply the same preparation and classification steps to every selected acquisition. Keep only the three small study-area layers needed for aggregation. Scene IDs and acquisition dates remain attached to the results so individual observations can be inspected.
A processing error stops execution for inspection rather than silently omitting an acquisition.
scenes = []
for row in picks.itertuples(index=False):
print(f"Processing {row.date}: {row.granule}")
# Streamed reads are not retried by earthaccess, so retry the scene on transient errors.
for attempt in range(3):
try:
scene = classify_scene(row.result)
break
except Exception as error:
if attempt == 2:
raise
print(f" Retrying after error: {error}")
time.sleep(2 ** attempt)
if not scene.observed.any().item():
raise ValueError(f"No usable study-area pixels in {row.granule}.")
scene = scene.expand_dims(scene=[row.granule])
scene = scene.assign_coords(
date=("scene", [row.date]),
cloud_pct=("scene", [row.cloud_pct]),
)
scenes.append(scene)
print(f"Processed {len(scenes)} scenes.")Processing 2023-04-20: EMIT_L2B_MIN_001_20230420T200006_2311013_012
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Processing 2023-04-24: EMIT_L2B_MIN_001_20230424T182248_2311412_013
Retrying after error: 502, message='Bad Gateway', url='https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2AMASK.002/EMIT_L2A_MASK_002_20230424T182248_2311412_013/EMIT_L2A_MASK_002_20230424T182248_2311412_013.nc'
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Processing 2023-06-27: EMIT_L2B_MIN_001_20230627T170626_2317811_012
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Processing 2023-10-09: EMIT_L2B_MIN_001_20231009T165914_2328211_009
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Processing 2023-10-16: EMIT_L2B_MIN_001_20231016T210319_2328914_012
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Processing 2025-06-20: EMIT_L2B_MIN_001_20250620T182509_2517112_014
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Processing 2025-08-01: EMIT_L2B_MIN_001_20250801T184807_2521312_012
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Retrying after error: 502, message='Bad Gateway', url='https://data.lpdaac.earthdatacloud.nasa.gov/lp-prod-protected/EMITL2BMIN.001/EMIT_L2B_MIN_001_20250801T184807_2521312_012/EMIT_L2B_MINUNCERT_001_20250801T184807_2521312_012.nc'
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Processing 2026-01-29: EMIT_L2B_MIN_001_20260129T191031_2602912_012
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Processing 2026-04-15: EMIT_L2B_MIN_001_20260415T200247_2610513_018
Flags used: ['Cloud Flag' 'Cirrus Flag' 'SpecTf-Cloud Flag']
Processed 9 scenes.
4.5 Inspect individual scenes
Figure 3. Individual-scene mineral classes: compare up to eight acquisitions on their original orthorectified grids before temporal aggregation. Later, we will merge all of these scenes together. Transparent pixels include both missing observations and pixels with no qualifying indicator detection, which includes mapping other mineral classes; the observation-count map distinguishes areas with limited data support.
def class_map(data, title):
return data.where(data > 0).hvplot.image(rasterize=True, dynamic=False,
x="longitude", y="latitude", geo=True, tiles="EsriImagery",
cmap=INDICATOR_COLORS, clim=(0.5, 5.5),
width=375, height=330, title=title,
).opts("Image", color_levels=5, cticks=list(enumerate(INDICATOR_ORDER, start=1)), xrotation=45)
scene_panel_figure = hv.Layout([
class_map(scene.mineral_class.isel(scene=0, drop=True), str(scene.date.values[0]))
for scene in scenes[:8]
]).cols(2)
scene_panel_figure5. Align and aggregate observations
Combine the classified acquisitions on a common study-area grid to summarize recurring detections and their observational support.
5.1 Align scenes to a common grid
Scene grids can vary slightly in both origin and pixel size, so we use the first selected scene as the reference grid and reproject each layer onto it with rioxarray’s reproject_match, using nearest-neighbor resampling to preserve our category and boolean layers. This warps each source grid onto the target’s exact resolution and extent rather than only matching existing coordinate values, so it also holds up if per-scene ground sample distance differs. Locations without a nearby source pixel are filled with each layer’s “no data” value. The reference grid comes from the first chronologically retained scene, so changing the selected dates will also change the output grid.
target = scenes[0].rio.write_crs(4326).rio.set_spatial_dims(x_dim="longitude", y_dim="latitude")
fill_values = {"mineral_class": 0, "observed": 0, "band_depth": np.nan}
aligned = []
for scene in scenes:
scene = scene.rio.write_crs(4326).rio.set_spatial_dims(x_dim="longitude", y_dim="latitude")
matched = xr.Dataset({
"mineral_class": scene.mineral_class.rio.reproject_match(
target, resampling=Resampling.nearest, nodata=fill_values["mineral_class"]
),
"observed": scene.observed.astype("uint8").rio.reproject_match(
target, resampling=Resampling.nearest, nodata=fill_values["observed"]
).astype(bool),
"band_depth": scene.band_depth.rio.reproject_match(
target, resampling=Resampling.nearest, nodata=fill_values["band_depth"]
),
}).rename(x="longitude", y="latitude")
aligned.append(matched)
stack = xr.concat(aligned, dim="scene", join="exact")
print(dict(stack.sizes))
stack{'scene': 9, 'latitude': 57, 'longitude': 79}
<xarray.Dataset> Size: 285kB
Dimensions: (scene: 9, latitude: 57, longitude: 79)
Coordinates:
* scene (scene) object 72B 'EMIT_L2B_MIN_001_20230420T200006_23110...
cloud_pct (scene) float64 72B 7.0 12.0 8.0 11.0 6.0 4.0 4.0 12.0 11.0
date (scene) <U10 360B '2023-04-20' '2023-04-24' ... '2026-04-15'
* latitude (latitude) float64 456B 38.07 38.07 38.07 ... 38.04 38.04
* longitude (longitude) float64 632B -113.7 -113.7 ... -113.6 -113.6
spatial_ref int64 8B 0
Data variables:
mineral_class (scene, latitude, longitude) int16 81kB 0 0 0 0 0 ... 0 0 0 0
observed (scene, latitude, longitude) bool 41kB True True ... True
band_depth (scene, latitude, longitude) float32 162kB nan nan ... nan5.2 Calculate temporal summaries
For each pixel, calculate the number of usable observations, the two most frequent detected indicator classes, the fraction of usable observations classified as alunite, and the mean band depth of the alunite detections. The band-depth summary is shown where alunite is the most frequent detected indicator. For this tutorial we require that three usable observations are retrieved for the reported quantitative summaries, but this could be adjusted depending on user preferences.
Modal classes exclude class zero and therefore describe the most frequent detected indicator, even if detections are uncommon. Ties are resolved by class order, with alunite first. The second mode describes the next most common identification when there is one, it doesn’t necessarily indicate mineral mixing. Persistence is an indication of repeated detection, and band depth measures spectral absorption strength, not mineral abundance.
MIN_OBS = 3
# Count votes for each detected indicator, excluding class zero.
class_index = pd.Index(range(1, 6), name="class_id")
votes = xr.concat(
[(stack.mineral_class == i).sum("scene") for i in class_index],
dim=class_index,
)
agree1 = votes.max("class_id")
mode1 = votes.idxmax("class_id").where(agree1 > 0, 0)
remaining_votes = votes.where(votes.class_id != mode1, 0)
mode2 = remaining_votes.idxmax("class_id").where(remaining_votes.max("class_id") > 0, 0)
# Normalize detections by usable observations at each pixel.
n_obs = stack.observed.sum("scene")
enough_observations = n_obs >= MIN_OBS
alunite_detected = stack.mineral_class == ALUNITE_CLASS
alunite_count = alunite_detected.sum("scene")
persistence = (alunite_count / n_obs.where(n_obs > 0)).where(enough_observations)
mean_depth = stack.band_depth.where(alunite_detected).mean("scene")
mean_depth = mean_depth.where(enough_observations & (mode1 == ALUNITE_CLASS))
aggregate = xr.Dataset({
"n_obs": n_obs,
"mode1": mode1,
"mode2": mode2,
"mode1_votes": agree1,
"alunite_persistence": persistence,
"mean_alunite_band_depth": mean_depth,
})
aggregate<xarray.Dataset> Size: 199kB
Dimensions: (longitude: 79, latitude: 57)
Coordinates:
* longitude (longitude) float64 632B -113.7 -113.7 ... -113.6
* latitude (latitude) float64 456B 38.07 38.07 ... 38.04 38.04
spatial_ref int64 8B 0
Data variables:
n_obs (latitude, longitude) int64 36kB 9 9 9 9 ... 8 8 8
mode1 (latitude, longitude) int64 36kB 0 0 0 0 ... 0 0 0
mode2 (latitude, longitude) int64 36kB 0 0 0 0 ... 0 0 0
mode1_votes (latitude, longitude) int64 36kB 0 0 0 0 ... 0 0 0
alunite_persistence (latitude, longitude) float64 36kB 0.0 0.0 ... 0.0
mean_alunite_band_depth (latitude, longitude) float32 18kB nan nan ... nan5.3 Map modal mineral classes
Figure 4. Modal mineral classes: the most frequent and second-most-frequent detected indicator from our selected classes over the alunite study area, masking pixels with fewer than three usable observations. Both panels cover the study area only, since raster processing in Section 4 was restricted to that extent.
supported = aggregate.n_obs >= MIN_OBS
modal_class_figure = (
class_map(aggregate.mode1.where(supported), "Most frequent detected indicator")
+ class_map(aggregate.mode2.where(supported), "Second most frequent indicator")
).cols(2)
modal_class_figure5.4 Map observation support, persistence, and band depth
Figure 4b. Quantitative summaries: show usable-observation counts, the fraction of those observations classified as alunite, and mean alunite detection band depth. Interpret persistence together with observation counts. Band depth is shown only where alunite is the most frequent detected indicator and the minimum observation count is met.
map_options = dict(
x="longitude", y="latitude", geo=True, tiles="EsriImagery",
width=350, height=330,
)
observation_map = aggregate.n_obs.hvplot.image(rasterize=True, dynamic=False,
**map_options, cmap="viridis", clim=(0, len(scenes)),
title="Usable observations", clabel="Observations",
alpha=0.7
)
persistence_map = aggregate.alunite_persistence.where(aggregate.alunite_persistence > 0).hvplot.image(rasterize=True, dynamic=False,
**map_options, cmap="magma", clim=(0, 1),
title="Alunite Persistence", clabel="Fraction of Scenes ID'd as Alunite",
)
depth_map = aggregate.mean_alunite_band_depth.hvplot.image(rasterize=True, dynamic=False,
**map_options, cmap="Greys_r",
title="Mean alunite band depth", clabel="Band depth",
)
summary_figure = (observation_map + persistence_map + depth_map).cols(3)
summary_figure6. Compare with UGS surface geochemistry
The UGS sample table gives coordinates and two independent alunite estimates per sample, from K₂O and SO₃ stoichiometry. We compare the spatial pattern of those estimates against EMIT band depth over the same ground. Hand samples and EMIT pixels represent very different spatial scales, so this is a qualitative comparison of pattern, not a measurement of pixel-level accuracy.
6.1 Load and screen the samples
Retain samples inside the alunite study area, then flag those with numeric estimates of at least 3% from both K₂O and SO₃. Samples that don’t clear both thresholds are kept as a separate “other” category rather than dropped; it includes disagreements and incomplete results, not just confirmed absences of alunite.
gchem = pd.read_csv(
DATA_DIR / "geochem" / "alunite_geochem_subset_2026_09_09.csv",
encoding="utf-8-sig",
)
gchem.columns = gchem.columns.str.strip()
gchem_gdf = gpd.GeoDataFrame(
gchem,
geometry=gpd.points_from_xy(gchem["Longitude"], gchem["Latitude"]),
crs=4326,
)
gchem_gdf = gchem_gdf.loc[gchem_gdf.geometry.intersects(study_poly)].copy()
ALUN_COL = "pct_alunite_from_K2O"
ALUN_SO3 = "pct_alunite_from_SO3"
ALUN_MIN_PCT = 5.0
for column in (ALUN_COL, ALUN_SO3):
values = pd.to_numeric(gchem_gdf[column], errors="coerce")
gchem_gdf[column] = values.where(values >= 0)
gchem_gdf["alun_both"] = (
(gchem_gdf[ALUN_COL] >= ALUN_MIN_PCT)
& (gchem_gdf[ALUN_SO3] >= ALUN_MIN_PCT)
)
gchem_gdf["sample_group"] = np.where(
gchem_gdf.alun_both, "Both estimates meet threshold", "Other or incomplete estimates",
)
gchem_gdf["sample_group"].value_counts()sample_group
Both estimates meet threshold 72
Other or incomplete estimates 32
Name: count, dtype: int64
6.2 Compare sample locations and mean band depth
The grayscale raster is the mean EMIT alunite band depth, shown only where alunite is the most frequent detected indicator and with at least three usable observations, stretched between the 5th and 95th percentiles so a few pixels with very strong absorption features don’t wash out the fainter margins of the exposure. The magma-colored circles on top are the K₂O-based estimates for samples meeting both geochemical thresholds, stretched from zero to the 98th percentile of those samples for the same reason. Cyan crosses mark the samples where the hand samples don’t show alunite through both the SO₃ and K₂O analyses.
We are looking for a pattern rather than point-by-point agreement because of the spatial resolution differences between the EMIT observations and the hand samples, and note where the raster is blank because alunite is not the most common detection or observations are too few.
Figure 5. UGS samples over mean EMIT alunite band depth. The K₂O-based estimate is the mapped quantity: it tracks more closely with independent XRPD analysis than the SO₃-based estimate does, for samples corroborated by both (UGS OFR 671). The SO₃-based estimate provides the second screening criterion; neither estimate alone establishes a mineral abundance at EMIT pixel scale.
accepted = gchem_gdf.loc[gchem_gdf.alun_both]
other = gchem_gdf.loc[~gchem_gdf.alun_both]
# Percentile stretches: band depth has no natural 0-1 range, and a few high assays would otherwise
# compress the sample colors into a single shade.
depth_lo, depth_hi = np.nanpercentile(aggregate.mean_alunite_band_depth, [5, 95])
sample_clim = (0.0, float(np.nanpercentile(accepted[ALUN_COL], 98)))
comparison = aggregate.mean_alunite_band_depth.hvplot.image(rasterize=True, dynamic=False,
x="longitude", y="latitude", geo=True, tiles="EsriImagery",
cmap="Greys_r", clim=(float(depth_lo), float(depth_hi)), colorbar=True,
clabel=f"EMIT mean alunite band depth ({len(scenes)}-scene aggregate)",
alpha=0.85, width=760, height=620,
)
if not accepted.empty:
# Bokeh keeps one colorbar per side, so the sample scale goes on the left of the map.
comparison *= accepted.hvplot.points(
geo=True, c=ALUN_COL, cmap="magma", clim=sample_clim,
colorbar=True, clabel="UGS alunite from SO₃ (%)",
size=90, line_color="black",
hover_cols=[ALUN_COL, ALUN_SO3],
label=f"Both estimates meet threshold (n={len(accepted)})",
).opts(colorbar_position="left")
if not other.empty:
comparison *= other.hvplot.points(
geo=True, color="cyan", marker="x", size=70, line_width=2,
hover_cols=[ALUN_COL, ALUN_SO3],
label=f"Other or incomplete estimates (n={len(other)})",
)
comparison = comparison.opts(
title="UGS samples and mean EMIT alunite band depth",
legend_position="top_right",
legend_opts={"background_fill_alpha": 0.85, "label_text_font_size": "9pt"},
)
comparisonWe can also examine how the geochemical information looks relative to our most frequently indicated categories from the EMIT data using a similar plot.
mode1_map = class_map(
aggregate.mode1.where(supported), "Most frequent detected indicator"
).opts(width=760, height=620)
if not accepted.empty:
mode1_map *= accepted.hvplot.points(
geo=True, c=ALUN_COL, cmap="magma", clim=sample_clim,
colorbar=True, clabel="UGS alunite from K₂O (%)",
size=90, line_color="black",
hover_cols=[ALUN_COL, ALUN_SO3],
label=f"Both estimates meet threshold (n={len(accepted)})",
).opts(colorbar_position="left")
if not other.empty:
mode1_map *= other.hvplot.points(
geo=True, color="cyan", marker="x", size=70, line_width=2,
hover_cols=[ALUN_COL, ALUN_SO3],
label=f"Other or incomplete estimates (n={len(other)})",)
mode1_map = mode1_map.opts(
title="UGS samples and most frequent detected indicator",
legend_position="top_right",
legend_opts={"background_fill_alpha": 0.85, "label_text_font_size": "9pt"}, frame_width=600, frame_height=450
)
mode1_map7. Interpretation and next steps
This workflow summarizes repeated mineral detections within the alunite study area. Observation counts describe data support. mode1/mode2 describe the most and second-most frequently detected indicator per pixel across repeat scenes (statistical mode). Persistence describes the frequency of alunite identification, and mean band depth describes the strength of the retained alunite absorptions.
The maps support comparison with UGS sample locations and estimates. They do not directly measure mineral abundance, metal concentration, or fluid pH. Variation among acquisitions can reflect surface conditions, masking, registration, mixtures, and classification thresholds. Interpret the second mode as variation among identifications rather than proof of a particular cause.
Record the retained granule IDs and thresholds when comparing results across runs. Only individually complete catalog footprints are included; partial scenes and acquisitions requiring adjacent-granule mosaics are omitted. Repeating an unrestricted search later can change the observations as the archive grows.
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 140G0126D0001 for NASA contract NNG14HH33I.