import pandas as pd
import requests
import time
import folium
import hvplot.pandas
import holoviews as hv
import matplotlib.colors as mcolors
import matplotlib.pyplot as pltFrom the PO.DAAC Cookbook, to access the GitHub version of the notebook, follow this link.
Note: This notebook uses the default SWOT RiverSP collection available through Hydrocron. At the time of writing, the default collection corresponds to Version D SWOT data.
Hydrocron API: Getting Started with SWOT Time Series
Summary:
Hydrocron is an API that repackages the SWOT_L2_HR_RiverSP_D dataset into CSV or GeoJSON formats that make time series analysis easier. This notebook will highlight how to utilize Hydrocron and convert its output into a readable dataframe from multiple SWOT reaches identified from the SWORD Database.
Requirements:
Any compute environment, local or the cloud.
Learning Objectives:
- Obtain a list of SWORD IDs for a region of interest
- Access SWOT river vector product attributes for multiple reach IDs via the Hydrocron API
- Convert accessed time series data into readable dataframe
- Plot one time series variable from multiple reaches
Cite the Hydrocron API via the following:
Frank Greguska, Nikki Tebaldi, Victoria McDonald, Vanesa Gomez Gonzalez, & Cassie Nickles. (2024). podaac/hydrocron: 1.2.0 (1.2.0). Zenodo. https://doi.org/10.5281/zenodo.11176234
Import Packages
Obtain SWORD reach IDs
In this section, we identify a list of river reach IDs from the SWOT River Database (SWORD). One river reach ID has the format CBBBBBRRRRT and a node ID has the format, CBBBBBRRRRNNNT where C stands for continent, B for basin, R for reach, N for node, and T for type. The first 6 digits of the id (CBBBBB) are the HydroBASINS Pfafstetter level 6 basin code, where the first digit represents one of nine continental regions (1 = Africa, 2 = Europe, 3 = Siberia, 4 = Asia, 5 = Oceania, 6 = South America, 7 = North America, 8 = Arctic, 9 = Greenland), and the remaining digits are nested basin levels 2–6.
In this example, we will use river reach IDs from the Skagit River in Washington. In December 2025, major flooding occurred in western Washington driven by mountain rainfall in early December and a widespread precipitation event on December 8-11.
To identify the river reach IDs for Skagit River, go to SWORD, select the ‘North America’ tab, and select the basin that covers Washington State (basin 78). After a few moments, the individual river reaches will load on the map, as shown in the screenshot below. Zoom in to the Skagit River in northern Washington, click on the individual reaches, and note the reach IDs.

# Define the reach IDs and the Hydrocron URL
# reach IDs for Skagit River in Washington, US
reach_ids = ['78310800191','78310800181','78310800171','78310800161','78310800081','78310800071',
'78310800061','78310800051','78310800041','78310800031','78310800021','78310800015']
HYDROCRON_URL = "https://soto.podaac.earthdatacloud.nasa.gov/hydrocron/v1/timeseries"Query Hydrocron for time series data
Depending on how many reaches you are querying for, this section could take between seconds to minutes.
All parameter options are listed in the Hydrocron documentation: https://podaac.github.io/hydrocron/timeseries/
Information about the fields (attributes that are available in the SWOT river shapefiles) can be found here: https://podaac.github.io/hydrocron/user-guide/fields/
# define a start and end time for retrieving SWOT riverSP data
start_time = "2025-10-01T00:00:00Z"
end_time = "2026-01-31T00:00:00Z"
# define the fields you want to retrieve
fields = "reach_id,time_str,wse,wse_u,geometry"
def query_hydrocron(query_url, reach_id, start_time, end_time, fields):
"""Query Hydrocron for reach-level time series data and geometry."""
params = {
"feature": "Reach",
"feature_id": reach_id,
"output": "geojson",
"start_time": start_time,
"end_time": end_time,
"fields": fields
}
response = requests.get(query_url, params=params)
response.raise_for_status()
response = response.json()
if "results" in response:
geojson_data = response["results"]["geojson"]
rows = []
for feature in geojson_data["features"]:
rows.append(feature["properties"])
df = pd.DataFrame(rows)
else:
print(f"No results for reach {reach_id}: {response}")
geojson_data = None
df = pd.DataFrame()
return df, geojson_data
t0 = time.perf_counter()
results = []
geojson_results = {}
for reach in reach_ids:
df_reach, geojson_data = query_hydrocron(
HYDROCRON_URL,
reach,
start_time,
end_time,
fields
)
if not df_reach.empty:
results.append(df_reach)
if geojson_data is not None:
geojson_results[reach] = geojson_data
if results:
df = pd.concat(results, ignore_index=True)
df["wse"] = pd.to_numeric(df["wse"], errors="coerce") # errors="coerce" replaces values with NaN if they can't be converted to the indicated type rather than throwing an error
df["wse_u"] = pd.to_numeric(df["wse_u"], errors="coerce")
df["time_str"] = pd.to_datetime(df["time_str"], errors="coerce")
df = df.dropna(subset=["time_str"])
else:
df = pd.DataFrame()
print(f"Query time: {time.perf_counter()-t0}")
display(df)Query time: 29.016685999988113
| reach_id | time_str | wse | wse_u | wse_units | wse_u_units | |
|---|---|---|---|---|---|---|
| 1 | 78310800191 | 2025-10-09 18:44:58+00:00 | 132.3752 | 0.09629 | m | m |
| 2 | 78310800191 | 2025-10-13 08:06:53+00:00 | 132.4446 | 0.09850 | m | m |
| 3 | 78310800191 | 2025-10-20 17:07:51+00:00 | 132.1659 | 0.17033 | m | m |
| 5 | 78310800191 | 2025-10-30 15:30:05+00:00 | 132.5509 | 0.09756 | m | m |
| 6 | 78310800191 | 2025-11-03 04:51:59+00:00 | 132.7871 | 0.10375 | m | m |
| ... | ... | ... | ... | ... | ... | ... |
| 235 | 78310800015 | 2025-12-25 20:44:51+00:00 | 2.1087 | 0.09587 | m | m |
| 236 | 78310800015 | 2026-01-02 05:45:57+00:00 | 1.3743 | 0.25852 | m | m |
| 237 | 78310800015 | 2026-01-12 04:08:09+00:00 | 1.3693 | 0.09339 | m | m |
| 238 | 78310800015 | 2026-01-15 17:29:55+00:00 | 2.2662 | 0.09627 | m | m |
| 239 | 78310800015 | 2026-01-23 02:31:01+00:00 | 1.7769 | 0.21807 | m | m |
193 rows × 6 columns
Note: Interactive Folium maps and hvPlot visualizations may not render in GitHub’s notebook preview.
Run the notebook locally in JupyterLab to view these outputs.
# Map the river reaches
if not geojson_results:
raise ValueError("No GeoJSON results were returned. Cannot create map.")
# generate colors for each reach
cmap = plt.colormaps.get_cmap("tab20").resampled(len(geojson_results))
colors = [
mcolors.to_hex(cmap(i))
for i in range(len(geojson_results))
]
color_dict = dict(zip(geojson_results.keys(), colors))
reach_map = folium.Map(tiles="cartodbpositron")
for reach_id, geojson_data in geojson_results.items():
folium.GeoJson(
geojson_data,
name=f"Reach ID {reach_id}",
style_function=lambda feature, reach_id=reach_id: {
"color": color_dict[reach_id],
"weight": 4
},
tooltip=folium.GeoJsonTooltip(
fields=["reach_id"],
aliases=["Reach ID:"]
)
).add_to(reach_map)
folium.LayerControl().add_to(reach_map)
reach_map.fit_bounds(reach_map.get_bounds(), padding=(30, 30))
reach_mapIn hvPlot, the asterisk (*) operator is used to overlay objects on the same axis. Here, we use this operator to combine multiple individual line and scatter plots into a single visual.
# Plot the time series results
# remove fill values
df_plot = df[df["wse"] > -9999].copy()
line_plots = []
scatter_plots = []
for reach_id in reach_ids:
df_reach = df_plot[df_plot["reach_id"].astype(str) == str(reach_id)]
color = color_dict[reach_id]
line = df_reach.hvplot(
x="time_str",
y="wse",
kind="line",
color=color,
label=f"Reach ID {reach_id}"
).opts(tools=[]) # tools=[] removes tooltips from the line segments
scatter = df_reach.hvplot(
x="time_str",
y="wse",
kind="scatter",
color=color,
hover_cols=["reach_id"]
)
line_plots.append(line)
scatter_plots.append(scatter)
# build up one large overlay plot from individual line and scatter plots
if not line_plots:
raise ValueError("No valid data available to plot.")
plot = line_plots[0]
for p in line_plots[1:]:
plot *= p
for p in scatter_plots:
plot *= p
plot.opts(
width=900,
height=500,
xlabel="Date",
ylabel="Water Surface Elevation (m)",
title="WSE by Reach ID and Observation Date",
xrotation=90,
legend_position="right",
show_grid=True
)The plotted results show an increase in water surface elevation on December 11, 2025 for the reach IDs with measurements on that date, coinciding with the time period of flooding.
However, for reach IDs 78310800031 and 78310800021, there appear to be some anomalous data points, in particular for 2025-12-04 and 2026-01-02. We can investigate these data points further using the water surface elevation uncertainty (wse_u) parameter. wse_u is the total uncertainty (random and systematic) in the reach WSE and has units of meters. High wse_u values are not automatically invalid, but they can help identify measurements that may be less reliable and worth reviewing before interpretation.
# Plot the water surface elevation uncertainty
# remove fill values
df_plot = df[
(df["wse"] > -9999) &
(df["wse_u"] > -9999)
].copy()
scatter_plots = []
for reach_id in reach_ids:
df_reach = df_plot[df_plot["reach_id"].astype(str) == str(reach_id)]
scatter = df_reach.hvplot(
x="time_str",
y="wse_u",
kind="scatter",
color=color_dict[reach_id],
label=f"Reach ID {reach_id}",
hover_cols=["reach_id"]
)
scatter_plots.append(scatter)
# build up one large overlay plot from individual scatter plots
if not scatter_plots:
raise ValueError("No valid uncertainty data available to plot.")
plot = scatter_plots[0]
for p in scatter_plots[1:]:
plot *= p
# add a horizontal line corresponding to an uncertainty level of 0.5 m
threshold = hv.HLine(0.5).opts(line_dash="dashed", color="gray")
# place annotation above horizontal line
x_pos = df_plot["time_str"].min()
y_pos = 0.52
label = hv.Text(x_pos, y_pos, "0.5 m").opts(text_align="left", text_color="gray", text_font_size="10pt")
# add horizontal line and annotation to the plot
plot = plot * threshold * label
plot.opts(
width=900,
height=500,
xlabel="Date",
ylabel="WSE Uncertainty (m)",
title="WSE Uncertainty per Measurement",
xrotation=90,
legend_position="right",
yformatter="%.2f",
yticks=5,
ylim=(0,1.0),
show_grid=True
)# Plot the uncertainty-filtered time series results
# set water surface elevation uncertainty threshold
WSE_U_THRESHOLD = 0.5
# remove fill values and filter out data points with high uncertainty
df_plot = df[
(df["wse"] > -9999) &
(df["wse_u"] > -9999) &
(df["wse_u"] <= WSE_U_THRESHOLD)
].copy()
line_plots = []
scatter_plots = []
for reach_id in reach_ids:
df_reach = df_plot[df_plot["reach_id"].astype(str) == str(reach_id)]
color = color_dict[reach_id]
line = df_reach.hvplot(
x="time_str",
y="wse",
kind="line",
color=color,
label=f"Reach ID {reach_id}"
).opts(tools=[])
scatter = df_reach.hvplot(
x="time_str",
y="wse",
kind="scatter",
color=color,
hover_cols=["wse_u","reach_id"]
)
line_plots.append(line)
scatter_plots.append(scatter)
# build up one large overlay plot from individual line and scatter plots
if not line_plots:
raise ValueError("No valid data available to plot.")
plot = line_plots[0]
for p in line_plots[1:]:
plot *= p
for p in scatter_plots:
plot *= p
plot.opts(
width=900,
height=500,
xlabel="Date",
ylabel="Water Surface Elevation (m)",
title="WSE After Removing High Uncertainty Data Points",
xrotation=90,
legend_position="right",
show_grid=True
)