# Install Python packages if not available
#!pip install --quiet ipywidgets nodejs traitlets numpy pandas matplotlib
Accessing, Analyzing, & Visualizing TEMPO data through ArcGIS Image Services Programmatically
Overview
Selected TEMPO data have been processed into free, publicly available ArcGIS image services that provide pre-filtered, analysis-ready imagery/data.
This notebook illustrates the following:
- Choose a TEMPO image service to query
- Select time period and point (X,Y) of interest
- View data values for point of interest in a table
- Chart returned values for point of interest
- View imagery for the time period of interest in interactive mapper
Why ArcGIS image services? Each TEMPO ArcGIS image service is hosted at a service URL, which has several built-in functions provided through the ArcGIS image service REST API. These functionalities can be accessed via webpage interfaces or called programatically, providing ways to access, analyze, and display the TEMPO data.
Prerequisites
Note: ESRI software/licenses are NOT required to access the services via the interfaces or programatically. No GIS software is required to access these TEMPO image services (although there are many methods to use these services in GIS).
Required: - Basic Python knowledge (variables, loops, functions) - Familiarity with TEMPO instrument and data products (from previous ARSET presentations)
Python Libraries: - matplotlib - for creating plots and visualizations - numpy - for numerical operations - ipyleaflet - for visualization in interactive mapper - requests - for sending HTTP requests to service API
Data & Scope
Each TEMPO ArcGIS image service has a portal page with detailed descriptions on the service, the filtering applied, geographic and temporal coverage, as well as access to the online map viewer to view the image service. It is strongly recommended to read over the service description to ensure understanding of the data.
The TEMPO image services are available in the Esri Living Atlas of the World: * NO2 * HCHO * Ozone Total
The example in this notebook uses: - Product: TEMPO_NO2_L3_V03 (Level-3 gridded NO₂ tropopsheric column) - Resolution: approximately 2.1 km × 4.4 km, hourly during daylight - Coverage: North America - Example region: Colorado, United States
Methods apply to other TEMPO products (formaldehyde, ozone, etc.) and regions within North America.
1. Setup
Install Python packages, as necessary (NOTE: Google Colab appears to have all the packages pre-installed)
Import Python libraries
# For accessing data and creating chart
import requests
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import datetime as dt
from datetime import datetime, timezone
# For creating interactive mapper
from ipyleaflet import Map, ImageService, basemaps, WidgetControl
from ipywidgets import SelectionSlider, Layout, Label, VBox
from ipywidgets import Output, HTML
# Set dataframe view options to ensure all rows appear (optional)
"display.max_rows", None) pd.set_option(
The TEMPO image services store the timestamp of each data scan as a Unix timestamp (e.g., 1752582321), which is the number of seconds since January 1, 1970 UTC. As these integers are not intuitive, we will use two custom functions to convert between Unix timestamps and human-readable date time strings.
# function to take input time as string and convert to integer of seconds since unix epoch UTC (Jan 1, 1970)
def convert_to_milliseconds(date_time_str):
"""Converts a date-time string in 'YYYY-MM-DD HH:MM:SS' format to milliseconds since epoch."""
= dt.datetime.strptime(date_time_str, "%Y-%m-%d %H:%M:%S")
inputDate return int(inputDate.replace(tzinfo=timezone.utc).timestamp() * 1000)
# function to take input time as integer of seconds since unix epoch UTC (Jan 1, 1970) and convert to string in 'YYYY-MM-DDTHH:MM:SSZ' format
def convert_from_milliseconds(milliseconds_since_epoch):
"""Converts milliseconds since epoch to a date-time string in 'YYYY-MM-DDTHH:MM:SSZ' format."""
= datetime.fromtimestamp((milliseconds_since_epoch) / 1000, tz=timezone.utc)
inputDateMilli return inputDateMilli.strftime("%Y-%m-%dT%H:%M:%SZ")
2. User selections
Users can select variable, time, and point (X,Y) of interest or use default options.
2.1 Choose TEMPO product/variable of interest
The default is TEMPO NO2 tropospheric column. Users may instead select formaldehyde (HCHO) total column or total column ozone (only one service/variable can be selected at a time).
# Select service URL and variable
"""Note: Only one image_service_url and corresponding variable_name can be used at a time. The other options should be commented out."""
# Option 1: NO2 image service
= "https://gis.earthdata.nasa.gov/image/rest/services/C2930763263-LARC_CLOUD/TEMPO_NO2_L3_V03_HOURLY_TROPOSPHERIC_VERTICAL_COLUMN/ImageServer"
image_service_url = "NO2_Troposphere"
variable_name
# Option 2: Formaldehyde image service
# image_service_url = "https://gis.earthdata.nasa.gov/image/rest/services/C2930761273-LARC_CLOUD/TEMPO_HCHO_L3_V03_HOURLY_VERTICAL_COLUMN/ImageServer"
# variable_name = "HCHO"
# Option 3: Ozone image service
# image_service_url = "https://gis.earthdata.nasa.gov/image/rest/services/C2930764281-LARC_CLOUD/TEMPO_O3TOT_L3_V03_HOURLY_OZONE_COLUMN_AMOUNT/ImageServer"
# variable_name = "Ozone_Column_Amount"
2.2 Choose time period of interest
There are two options: * Option 1 (default): Time period is yesterday (last 24 hours from present) * Option 2: Manually select any time period within scope of TEMPO mission (August 2, 2023 - present)
NOTE: User must comment out (place a # at the beginning of the code line) the option that is not in use. By default, Option 2 is commented out.
# Choose starting and ending dates to run against
= dt.datetime.today() - dt.timedelta(days=1)
yesterday = dt.datetime.today()
today
# Option 1 (Default): Yesterday - today NOTE: converts local computer time to UTC
"""Note: If using Option 2, comment out the two lines below:"""
= str(dt.datetime(yesterday.year, yesterday.month, yesterday.day))
start_date_time_str = str(dt.datetime(today.year, today.month, today.day, today.hour))
end_date_time_str
# OR
# Option 2: Select specifc time period of interest'''
"""Note: If using Option 1, comment out the two lines below:"""
# start_date_time_str = "2025-05-06 0:01:00" #in 'YYYY-MM-DD HH:MM:SS' format "2025-04-20 12:00:00"
# end_date_time_str = "2025-05-08 05:00:00" #in 'YYYY-MM-DD HH:MM:SS' format "2025-05-25 12:00:00"
# Convert user input dates to milliseconds since epoch
= convert_to_milliseconds(start_date_time_str)
start_time = convert_to_milliseconds(end_date_time_str)
end_time
print(
f"The time period of interest has been defined as: Start = {start_date_time_str} ({start_time}); End: = {end_date_time_str} ({end_time})"
)
The time period of interest has been defined as: Start = 2025-07-24 00:00:00 (1753315200000); End: = 2025-07-25 17:00:00 (1753462800000)
2.3 Choose point of interest
User may select one coordinate pair (X,Y) as a point of interest. Data values for this point will be returned when the image service is queried. (X = longitude, Y = latitude)
# User chooses X,Y point of interest
"""Note: Replace coordinate with desired point.
Coordinate pair should be within quotation marks and have a comma between longitude and latitude values. Ex: "-84,37"
This variable is for the parameter "geometry" in the the URL service call below."""
= "-104.676, 39.856" # Denver Airport coor_pts
3. Identify number and timestamp of TEMPO scans in time period of interest
The timestamp of each TEMPO scan is stored as a dimension in the image service and can be accessed by sending a Multidimenaional Info request to the service URL.
# Create url for multidimensional info request
= f"{image_service_url}/multidimensionalInfo"
dim_info_url
# Make request to service API
= requests.get(dim_info_url, params={"f": "json"}).json()
dim_info = dim_info["multidimensionalInfo"]["variables"][0]["dimensions"][0]["values"]
all_times
# Filter to timestamps within the desired range and print count of scans found
= [t for t in all_times if start_time <= t <= end_time]
timestamps print("Number of TEMPO scans:", len(timestamps))
# Iterate through TEMPO scans and print timestamps as Unix epoch and date string
for t in timestamps:
= convert_from_milliseconds(t)
date_strings print(t, " ", date_strings)
Number of TEMPO scans: 18
1753317429000 2025-07-24T00:37:09Z
1753319846000 2025-07-24T01:17:26Z
1753353348000 2025-07-24T10:35:48Z
1753355765000 2025-07-24T11:16:05Z
1753358182000 2025-07-24T11:56:22Z
1753360599000 2025-07-24T12:36:39Z
1753363016000 2025-07-24T13:16:56Z
1753366616000 2025-07-24T14:16:56Z
1753370216000 2025-07-24T15:16:56Z
1753373816000 2025-07-24T16:16:56Z
1753377416000 2025-07-24T17:16:56Z
1753381016000 2025-07-24T18:16:56Z
1753384616000 2025-07-24T19:16:56Z
1753388216000 2025-07-24T20:16:56Z
1753391816000 2025-07-24T21:16:56Z
1753395416000 2025-07-24T22:16:56Z
1753399016000 2025-07-24T23:16:56Z
1753401433000 2025-07-24T23:57:13Z
4. Retreive data values for point of interest for selected time period
Data values for a selected X,Y point can be accessed by sending a Get Samples request to the service URL and returned in a json. The data are iterated through and values added to a dataframe. The dataframe is then viewed as a table.
4.1 Get Samples request
The user provided information above (variable, time, X,Y point) are used to create a Get Samples request, which is sent to the service API. The data response is then stored in a json to access.
# Create URL for Get Samples request
= image_service_url + "/getSamples/"
base_url = {
params "geometry": coor_pts, # Parameter that uses user's chosen lat/lon point
"geometryType": "esriGeometryPoint",
"sampleDistance": "",
"sampleCount": "",
"mosaicRule": f'{{"multidimensionalDefinition":[{{"variableName":"{variable_name}"}}]}}', # Parameter that uses user's chosen service variable
"pixelSize": "",
"returnFirstValueOnly": "false",
"interpolation": "RSP_BilinearInterpolation",
"outFields": "",
"sliceId": "",
"time": f"{start_time},{end_time}", # Parameter that uses user's chosen time period
"f": "pjson",
}
# Make the request to the service API
= requests.get(base_url, params=params)
response = response.json() data
4.2 Extract data into a dataframe
The returned json contains the variable, timestamps, and data values for the TEMPO scans in the selected time period. Not all scans in the selected time period may have data for the selected X,Y point. The retrieved data are iterated through to find which scans had data for the selected X,Y point and adding those data values with their corresponding timestamps to a dataframe. The dataframe is displayed in a table format.
# Extract relevant information into a DataFrame
= []
samples for sample in data.get("samples", []):
= sample.get("attributes", {})
attributes = attributes.get(variable_name)
var_value # Only include the sample if it has a valid value for the variable of interest
"""Note: this will result in timeslices being excluded if there are no data for the point of interest.
Code may be modified to see all timestamps (i.e., include TEMPO scans where there are no data)."""
if var_value:
samples.append(
{"StdTime": attributes["StdTime"],
float(var_value), # Convert to float
variable_name:
}
)
# Convert the list to a DataFrame
= pd.DataFrame(samples)
df
# Check if dataframe is empty. If not empty, convert StdTime from Unix timestamp (milliseconds) to datetime and print dataframe
if df.empty:
print(
f"No {variable_name} data found between {start_date_time_str} - {end_date_time_str} for point ({coor_pts})."
)else:
"StdTime"] = pd.to_datetime(df["StdTime"], unit="ms")
df[print(df)
StdTime NO2_Troposphere
0 2025-07-24 14:16:56 1.518008e+16
1 2025-07-24 15:16:56 1.269226e+16
2 2025-07-24 16:16:56 4.188970e+15
3 2025-07-24 17:16:56 2.033510e+15
4 2025-07-24 18:16:56 3.852906e+15
4.3 Display the data in a chart
The data in the dataframe can be displayed in a chart. The chart can be exported for later use (this option is commented out by default).
# Plotting
=(10, 6))
plt.figure(figsize"StdTime"], df[variable_name], marker="o", linestyle="-")
plt.plot(df[
# Set title and labels--user may change
f"{variable_name} Over Time") # User may change title as desired
plt.title("Time (UTC)")
plt.xlabel(
plt.ylabel(f"{variable_name} (molecules/cm^2)"
# Change unit as needed for variable seelcted (e.g., Ozone total is Dobson units)
)
# Set grid, tick marks, and format
True)
plt.grid(= plt.gca()
ax "%Y-%m-%d %H:%M:%S"))
ax.xaxis.set_major_formatter(mdates.DateFormatter(=45)
plt.xticks(rotation
plt.tight_layout()
# Optional: Save the plot to a local folder (include file path). Format set to PNG as default but can be changed.
# plt.savefig("outputGraph.png", format="png")
# Show plot in notebook
plt.show()
5. Create an interactive mapper
This mapper includes all of the TEMPO scans within the selected time period. The viewer shows the entire scans (not just around the point of interest). The scans can be stepped through using the time slider. Users may zoom in/out on the map. Users can hover over the map to see coordinates. Users can click on the map to have the coordinate point display below the mapper.
NOTE: There is a known bug in Google Colab that limits the time slider to 5 or fewer timesteps in the slider. This notebook has two options for the slider. Option 1 (default), which is hardcoded to only show the first 5 timestamps to avoid this issue, and Option 2, which will show all of the timestamps in the user’s selected time period.
# A handler that will update the map everytime the user moves the slider
def update_image(change):
= [change.new, timestamps[4]]
tempo_image_service.time
# Function to define interactive map behavior
def on_click(**kwargs):
"""When a user clicks on the map, print coordinates below map"""
if kwargs.get("type") == "click":
print(str(kwargs.get("coordinates")))
"""When a user hovers mouse over map, display coordinates within map"""
if kwargs.get("type") == "mousemove":
= kwargs.get("coordinates")
latlng = latlng
lat, lng = f"Coordinates: ({lat:.5f}, {lng:.5f})" coordinates_label.value
# Initialize the map
= Map(center=(47, -122), zoom=3, basemap=basemaps.Esri.WorldTopoMap)
m
# Set parameters for calling TEMPO image service
"""Note: The rendering_rule rasterFunction holds the colormap associated with the image service.
Replace with the appropriate colormap for best visualization depending on selected variable.
NO2: rendering_rule={"rasterFunction":"matter_RGB"},
HCHO: rendering_rule={"rasterFunction":"haline_RGB"},
Ozone Tot: rendering_rule={"rasterFunction":"batlow_RGB"}, """
= ImageService(
tempo_image_service =image_service_url,
url={"rasterFunction": "matter_RGB"},
rendering_rule=timestamps,
timeformat="jpgpng",
=0.5,
opacity
)
# Create a list with the user selected UTC times with time_values for easy visualization of time
= [convert_from_milliseconds(t) for t in timestamps]
time_strings
# Create a list of tuples to input in SelectionSlider's options for easy visualization of time
# Option 1: If using Google Colab, use this time_options to account for a bug that occurs if more than 5 timeslices are called at a time
= [(time_strings[i], timestamps[i]) for i in range(5)]
time_options
# Option 2: If not using Google Colab, use this time_options to call all timeslices
# time_options = [(time_strings[i], timestamps[i]) for i in range(len(timestamps))]
# Create the slider
= SelectionSlider(
slider ="Time:", options=time_options, layout=Layout(width="700px", height="20px")
description
)# slider = SelectionSlider(description='Time:', options=timestamps, layout=Layout(width='700px', height='20px'))
# Create a Label for the VBox
= Label(value="Time Slider")
time_label
# Listens to the slider's user input and helps update the map
"value")
slider.observe(update_image,
# create a VBox to contain the slider and be placed in the map
= VBox([slider, time_label])
vbox
# Slider placed in bottomleft of the map
= WidgetControl(widget=vbox, position="bottomleft")
control
# Output widget to listen to the user's mouse hovering over the map
= Output()
output = WidgetControl(widget=output, position="topright")
controloutput
# Label widget to display coordinates
= HTML(value="Coordinates: ")
coordinates_label = WidgetControl(widget=coordinates_label, position="bottomright")
coordinates_control
# Add all widgets to the map
m.add(tempo_image_service)
m.add(control)
m.add(controloutput)
m.add(coordinates_control)
# When user hovers over the map coordinates_label gets updated and prints the coordinates where clicked
m.on_interaction(on_click)
# Call map
m