%pip install altair plotly polars vegafusion vl-convert-python
GNSS Satellite Ephemeris Positions Tutorial#
This tutorial demonstrates how to retrieve GNSS satellite positions using the EarthScope SDK.
import datetime as dt
import math
import altair as alt
import numpy as np
import plotly.colors as pcolors
import plotly.graph_objects as go
import plotly.io as pio
import polars as pl
from earthscope_sdk import AsyncEarthScopeClient
from earthscope_sdk.client.data_access.models import (
FloatFilter,
GeodeticCoordinate,
SatelliteSystem,
)
alt.data_transformers.enable("vegafusion") # Enable rust backend for altair
pio.renderers.default = "notebook" # required for plotly to render in jupyterbook
es = AsyncEarthScopeClient()
Retrieving Satellite Positions#
You can easily retrieve satellite positions in an Earth-Centered, Earth-Fixed reference frame using the SDK as demonstrated in the following cell.
Choose your desired time range, constellation(s), satellite(s) (optional), and desired sample period.
start_datetime = dt.datetime(2025, 9, 22)
end_datetime = dt.datetime(2025, 9, 23)
system = ["G", "R", "E", "C", "S"]
table = await es.data.gnss_ephemeris_positions(
start_datetime=start_datetime,
end_datetime=end_datetime,
field=["x", "y", "z"],
system=system,
sample_interval=dt.timedelta(minutes=5),
).fetch()
df = pl.from_arrow(table)
df.sort("timestamp")
| timestamp | system | satellite | x | y | z |
|---|---|---|---|---|---|
| datetime[ms, UTC] | str | i8 | f64 | f64 | f64 |
| 2025-09-22 00:00:00 UTC | "S" | 48 | -2.8819e7 | -3.0791e7 | 5330.304 |
| 2025-09-22 00:00:00 UTC | "S" | 28 | 5.1385e6 | 4.1866e7 | -23594.416 |
| 2025-09-22 00:00:00 UTC | "R" | 5 | 9.2172e6 | -7.9632e6 | -2.2404e7 |
| 2025-09-22 00:00:00 UTC | "R" | 6 | -763982.297457 | -2.4121e7 | -8.2510e6 |
| 2025-09-22 00:00:00 UTC | "R" | 7 | -7.3418e6 | -2.3132e7 | 7.7931e6 |
| … | … | … | … | … | … |
| 2025-09-23 00:00:00 UTC | "E" | 23 | -867060.497773 | 2.0556e7 | -2.1280e7 |
| 2025-09-23 00:00:00 UTC | "E" | 21 | -1.7422e7 | 5.1905e6 | -2.3357e7 |
| 2025-09-23 00:00:00 UTC | "E" | 25 | 1.7758e7 | -4.6943e6 | 2.3221e7 |
| 2025-09-23 00:00:00 UTC | "E" | 29 | -1.7563e7 | 1.6301e7 | 1.7372e7 |
| 2025-09-23 00:00:00 UTC | "E" | 8 | -4.7838e6 | -2.3962e7 | 1.6705e7 |
Plotting Satellite Positions#
The following cell defines a function that uses plotly to plot the satellite orbits in an Earth-Centered, Earth-Fixed (ECEF) reference frame.
def plot_satellite_positions(df: pl.DataFrame, system: SatelliteSystem):
"""
Plot satellite positions in ECEF reference frame with an earth-sized ellipsoid.
"""
# Group coordinates by svid
coords = df.filter(pl.col("system") == system).to_dicts()
coords_by_svid = {}
for coord in coords:
svid = coord["satellite"]
if svid not in coords_by_svid:
coords_by_svid[svid] = {"x": [], "y": [], "z": [], "timestamp": []}
coords_by_svid[svid]["x"].append(coord["x"])
coords_by_svid[svid]["y"].append(coord["y"])
coords_by_svid[svid]["z"].append(coord["z"])
coords_by_svid[svid]["timestamp"].append(coord["timestamp"])
# Earth's radii in meters (WGS84)
equatorial_radius = 6378137.0
polar_radius = 6356752.3
# Create the Earth's ellipsoid wireframe
u = np.linspace(0, 2 * np.pi, 100)
v = np.linspace(0, np.pi, 100)
x_earth = equatorial_radius * np.outer(np.cos(u), np.sin(v))
y_earth = equatorial_radius * np.outer(np.sin(u), np.sin(v))
z_earth = polar_radius * np.outer(np.ones(np.size(u)), np.cos(v))
earth_wireframe = go.Surface(
x=x_earth,
y=y_earth,
z=z_earth,
colorscale="Blues",
showscale=False,
opacity=0.2,
name="Earth",
)
# Create a 3D scatter plot for each satellite trajectory
satellite_traces = []
colors = pcolors.qualitative.Plotly
for i, (svid, coords_list) in enumerate(coords_by_svid.items()):
satellite_traces.append(
go.Scatter3d(
x=coords_list["x"],
y=coords_list["y"],
z=coords_list["z"],
mode="lines",
text=coords_list["timestamp"],
hoverinfo="all",
line=dict(color=colors[i % len(colors)], width=2),
name=f"SVID {svid}",
)
)
fig = go.Figure(data=[earth_wireframe] + satellite_traces)
# Update the layout for a cleaner look
fig.update_layout(
title="Satellite XYZ Coordinates (System: {})".format(system),
scene=dict(
xaxis_title="X Coordinate",
yaxis_title="Y Coordinate",
zaxis_title="Z Coordinate",
aspectmode="data",
),
margin=dict(r=20, b=10, l=10, t=40),
)
return fig
fig = plot_satellite_positions(df, "C")
fig.show(height=1500)
Elevation and Azimuth#
You can also provide a reference point to compute the elevation and azimuth to each satellite at a given sample interval.
Optionally, filters may be provided for elevation and azimuth.
In the following examples, we use elevation and azimuth to visualize the SNR in multiple ways.
Joining Elevation and Azimuth with Observations#
With satellite elevation and azimuth, we can analyze observations in a spatial context.
First, let’s query the desired GNSS observations:
Note
Learn more: check out our tutorial on retrieving GNSS observations
system = "G"
satellite = [5, 7, 11, 13]
station_name = "P041"
station_location = GeodeticCoordinate(
latitude=39.7392358,
longitude=-104.990251,
height=1000,
)
obs_df = await es.data.gnss_observations(
start_datetime=start_datetime,
end_datetime=end_datetime,
system=system,
satellite=satellite,
station_name=station_name,
session_name="A",
obs_code="1C",
field="snr",
).fetch()
obs_df = pl.from_arrow(obs_df)
obs_df.sort("timestamp")
| timestamp | satellite | obs_code | snr | system | igs |
|---|---|---|---|---|---|
| datetime[ms, UTC] | u8 | str | f32 | str | str |
| 2025-09-22 00:00:00 UTC | 7 | "1C" | 48.0 | "G" | "P04100USA" |
| 2025-09-22 00:00:15 UTC | 7 | "1C" | 48.0 | "G" | "P04100USA" |
| 2025-09-22 00:00:30 UTC | 7 | "1C" | 47.75 | "G" | "P04100USA" |
| 2025-09-22 00:00:45 UTC | 7 | "1C" | 47.75 | "G" | "P04100USA" |
| 2025-09-22 00:01:00 UTC | 7 | "1C" | 47.75 | "G" | "P04100USA" |
| … | … | … | … | … | … |
| 2025-09-22 23:58:45 UTC | 7 | "1C" | 48.0 | "G" | "P04100USA" |
| 2025-09-22 23:59:00 UTC | 7 | "1C" | 48.25 | "G" | "P04100USA" |
| 2025-09-22 23:59:15 UTC | 7 | "1C" | 48.5 | "G" | "P04100USA" |
| 2025-09-22 23:59:30 UTC | 7 | "1C" | 48.0 | "G" | "P04100USA" |
| 2025-09-22 23:59:45 UTC | 7 | "1C" | 48.25 | "G" | "P04100USA" |
Next, we retrieve satellite elevation and azimuth for the same system and satellites.
table = await es.data.gnss_ephemeris_positions(
start_datetime=start_datetime,
end_datetime=end_datetime,
field=["elevation", "azimuth"],
system=system,
satellite=satellite,
reference_point=station_location,
sample_interval=dt.timedelta(seconds=15),
elevation_filter=FloatFilter(min=0),
).fetch()
azel_df = pl.from_arrow(table)
azel_df.sort("timestamp")
| timestamp | system | satellite | elevation | azimuth |
|---|---|---|---|---|
| datetime[ms, UTC] | str | i8 | f32 | f32 |
| 2025-09-22 00:00:00 UTC | "G" | 7 | 48.274212 | 298.52771 |
| 2025-09-22 00:00:15 UTC | "G" | 7 | 48.352226 | 298.636108 |
| 2025-09-22 00:00:30 UTC | "G" | 7 | 48.43026 | 298.744598 |
| 2025-09-22 00:00:45 UTC | "G" | 7 | 48.508312 | 298.853241 |
| 2025-09-22 00:01:00 UTC | "G" | 7 | 48.58638 | 298.962006 |
| … | … | … | … | … |
| 2025-09-22 23:59:00 UTC | "G" | 7 | 49.233974 | 299.867706 |
| 2025-09-22 23:59:15 UTC | "G" | 7 | 49.312218 | 299.977661 |
| 2025-09-22 23:59:30 UTC | "G" | 7 | 49.39048 | 300.087738 |
| 2025-09-22 23:59:45 UTC | "G" | 7 | 49.468758 | 300.197968 |
| 2025-09-23 00:00:00 UTC | "G" | 7 | 49.547054 | 300.308319 |
Finally, we join the two together and visualize it in different ways.
join_df = obs_df.join(
azel_df,
on=["timestamp", "satellite", "system"],
how="inner",
)
join_df.sort("timestamp")
| timestamp | satellite | obs_code | snr | system | igs | elevation | azimuth |
|---|---|---|---|---|---|---|---|
| datetime[ms, UTC] | u8 | str | f32 | str | str | f32 | f32 |
| 2025-09-22 00:00:00 UTC | 7 | "1C" | 48.0 | "G" | "P04100USA" | 48.274212 | 298.52771 |
| 2025-09-22 00:00:15 UTC | 7 | "1C" | 48.0 | "G" | "P04100USA" | 48.352226 | 298.636108 |
| 2025-09-22 00:00:30 UTC | 7 | "1C" | 47.75 | "G" | "P04100USA" | 48.43026 | 298.744598 |
| 2025-09-22 00:00:45 UTC | 7 | "1C" | 47.75 | "G" | "P04100USA" | 48.508312 | 298.853241 |
| 2025-09-22 00:01:00 UTC | 7 | "1C" | 47.75 | "G" | "P04100USA" | 48.58638 | 298.962006 |
| … | … | … | … | … | … | … | … |
| 2025-09-22 23:58:45 UTC | 7 | "1C" | 48.0 | "G" | "P04100USA" | 49.15575 | 299.757874 |
| 2025-09-22 23:59:00 UTC | 7 | "1C" | 48.25 | "G" | "P04100USA" | 49.233974 | 299.867706 |
| 2025-09-22 23:59:15 UTC | 7 | "1C" | 48.5 | "G" | "P04100USA" | 49.312218 | 299.977661 |
| 2025-09-22 23:59:30 UTC | 7 | "1C" | 48.0 | "G" | "P04100USA" | 49.39048 | 300.087738 |
| 2025-09-22 23:59:45 UTC | 7 | "1C" | 48.25 | "G" | "P04100USA" | 49.468758 | 300.197968 |
Elevation vs. SNR#
join_df.filter(pl.col("elevation") < 30).with_columns(
pl.col("satellite").cast(pl.Utf8)
).plot.point(x="elevation", y="snr", color="satellite").properties(
title=f"{station_name}: Mean SNR by Elevation and Azimuth",
width=650,
)
Polar Heatmap#
# The main heatmap of SNR by elevation and azimuth
heatmap = (
alt.Chart(join_df)
.mark_arc(tooltip=True)
.encode(
alt.Theta(
"azimuth:Q",
bin=alt.Bin(maxbins=180),
scale=alt.Scale(domain=[0, 360]),
),
alt.Radius(
"elevation:Q",
bin=alt.Bin(step=2),
scale=alt.Scale(domain=[90, 0]), # Zenith at center
),
alt.Color("mean(snr):Q", title="Mean SNR", scale=alt.Scale(scheme="viridis")),
)
)
# --- Axis Layers ---
# Radial axis (for elevation)
elevation_rings_df = pl.DataFrame({"elevation": [75, 60, 45, 30, 15, 0]})
radius_scale = alt.Scale(domain=[90, 0])
# Concentric circles as gridlines
elevation_rings = (
alt.Chart(elevation_rings_df)
.mark_arc(fill=None, stroke="lightgrey", strokeDash=[2, 2])
.encode(
theta=alt.value(2 * math.pi),
radius=alt.Radius("elevation:Q", scale=radius_scale).stack(False),
detail="elevation:Q", # Draw a separate arc for each elevation value
)
)
# Text labels for elevation
elevation_labels = (
alt.Chart(elevation_rings_df)
.mark_text(color="grey", align="right", size=14)
.encode(
text=alt.Text("elevation:Q", format=".0f"),
theta=alt.value(math.pi / 12), # Place labels at 15 degrees
radius=alt.Radius("elevation:Q", scale=radius_scale),
detail="elevation:Q", # Ensure a label for each elevation value
)
)
# Azimuth axis labels
azimuth_labels_df = pl.DataFrame(
{
"azimuth": [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330],
"label": [
"N",
"30°",
"60°",
"E",
"120°",
"150°",
"S",
"210°",
"240°",
"W",
"300°",
"330°",
],
}
)
azimuth_labels = (
alt.Chart(azimuth_labels_df)
.mark_text(
radius=265, # Place labels just outside the plot
color="grey",
size=12,
)
.encode(text="label:N", theta=alt.Theta("azimuth:Q"))
)
# Layer all the charts
chart = (
alt.layer(heatmap, elevation_rings, elevation_labels, azimuth_labels)
.properties(
title=f"{station_name}: Mean SNR by Elevation and Azimuth",
width=500,
height=500,
)
.configure_title(fontSize=16)
)
chart