This notebook downloads daily GNSS position time series from the Nevada Geodetic Laboratory (NGL) at the University of Nevada, Reno. NGL processes data from thousands of permanent GNSS stations worldwide and publishes daily position solutions as plain text files, one file per station.
What the data is. A permanent GNSS station measures its own position, every day, to a few millimeters. The time series records ground motion: steady plate tectonic drift, earthquakes (sudden offsets), slow slip events, and seasonal loading from water and snow.
Reference frame. We use solutions in IGS20, the current realization of the International Terrestrial Reference Frame adopted by the International GNSS Service. Positions are expressed relative to this global frame, so the steady trends you will see are plate motions.
Citation. When using NGL products, cite: Blewitt, G., Hammond, W. C., & Kreemer, C. (2018). Harnessing the GPS data explosion for interdisciplinary science. Eos, 99, Blewitt et al. (2018).
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
import pooch
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)The .tenv3 format¶
Each station file at https://geodesy.unr.edu/gps_timeseries/IGS20/tenv3/IGS20/{STATION}.tenv3 is whitespace-delimited with one header line. The columns include the station name (site), the date (YYMMMDD), the decimal year (yyyy.yyyy), and the position components split into an integer base and a fractional part: __east(m), _north(m), ____up(m) hold the varying part of the east, north, and up positions in meters. The odd-looking underscores are part of the actual column names, so we inspect the header after parsing rather than assuming it.
We use two stations from the Network of the Americas in the Pacific Northwest and northern California: P395 and P563.
Downloading with pooch — and why no pinned hash here¶
Section 1.6 taught the pattern: download with pooch, then pin the printed checksum as known_hash so every future run verifies it got the same bytes. That pattern applies to frozen files — a released dataset, an archived snapshot, the Natural Earth raster in Chapter 2.2.
These NGL files are living data: a new daily solution is appended every day, so the file’s checksum changes every day, and a hash pinned today would fail tomorrow — by design, since the file really did change. So here we pass known_hash=None and instead print the SHA256 of what we actually received. That printed hash is your provenance record: copy it into your notes or run log, and today’s analysis is tied to an exact input even though tomorrow’s download will differ. If you need bit-identical reruns (a paper, a graded submission), freeze a snapshot — archive the downloaded file (Zenodo, your repo’s data release) and pin that copy’s hash.
One caching caveat: with known_hash=None, pooch reuses a cached copy without asking the server. Delete the .tenv3 file from data/ when you want today’s data instead of the day you first ran this notebook.
BASE_URL = "https://geodesy.unr.edu/gps_timeseries/IGS20/tenv3/IGS20/{station}.tenv3"
def fetch_tenv3(station):
"""Fetch an NGL .tenv3 daily position file (cached by pooch) as a DataFrame."""
path = pooch.retrieve(
url=BASE_URL.format(station=station),
known_hash=None, # living data: NGL appends a new daily solution every day
fname=f"{station}.tenv3",
path=DATA_DIR,
downloader=pooch.HTTPDownloader(timeout=120),
)
print(f"{station}.tenv3 sha256:{pooch.file_hash(path)}")
# whitespace-delimited text with one header line
return pd.read_csv(path, sep=r"\s+")
df = fetch_tenv3("P395")
print(df.columns.tolist())Downloading data from 'https://geodesy.unr.edu/gps_timeseries/IGS20/tenv3/IGS20/P395.tenv3' to file '/home/runner/work/mlgeo-book/mlgeo-book/book/Chapter1-GettingStarted/data/P395.tenv3'.
SHA256 hash of downloaded file: 75363b3d5513deebaa54f95894a5b810e8c3a6a63573e6e478b194e5468b7034
Use this value as the 'known_hash' argument of 'pooch.retrieve' to ensure that the file hasn't changed if it is downloaded again in the future.
P395.tenv3 sha256:75363b3d5513deebaa54f95894a5b810e8c3a6a63573e6e478b194e5468b7034
['site', 'YYMMMDD', 'yyyy.yyyy', '__MJD', 'week', 'd', 'reflon', '_e0(m)', '__east(m)', '____n0(m)', '_north(m)', 'u0(m)', '____up(m)', '_ant(m)', 'sig_e(m)', 'sig_n(m)', 'sig_u(m)', '__corr_en', '__corr_eu', '__corr_nu', '_latitude(deg)', '_longitude(deg)', '__height(m)']
df.head()Relative displacement¶
The absolute coordinates are large numbers; what we care about is motion. We subtract the first daily solution from each component, so every series starts at zero and shows displacement in meters since the first observation. We keep the decimal year as the time axis.
def to_relative(df):
"""Return decimal year and east/north/up displacement (m) relative to the first epoch."""
return pd.DataFrame(
{
"decimal_year": df["yyyy.yyyy"],
"east_m": df["__east(m)"] - df["__east(m)"].iloc[0],
"north_m": df["_north(m)"] - df["_north(m)"].iloc[0],
"up_m": df["____up(m)"] - df["____up(m)"].iloc[0],
}
)
rel_p395 = to_relative(df)
rel_p395.describe()# download both stations and save the relative positions to ./data/
stations = ["P395", "P563"]
series = {}
for station in stations:
rel = to_relative(fetch_tenv3(station))
outfile = DATA_DIR / f"gps_{station}_relative_position.csv"
rel.to_csv(outfile, index=False)
series[station] = rel
print(f"{station}: {len(rel)} daily solutions saved to {outfile}")Downloading data from 'https://geodesy.unr.edu/gps_timeseries/IGS20/tenv3/IGS20/P563.tenv3' to file '/home/runner/work/mlgeo-book/mlgeo-book/book/Chapter1-GettingStarted/data/P563.tenv3'.
P395.tenv3 sha256:75363b3d5513deebaa54f95894a5b810e8c3a6a63573e6e478b194e5468b7034
P395: 7418 daily solutions saved to data/gps_P395_relative_position.csv
SHA256 hash of downloaded file: 8cf6fdaeb2453da98763780a28d157cc685aa838897f4fc83bdd411a366584aa
Use this value as the 'known_hash' argument of 'pooch.retrieve' to ensure that the file hasn't changed if it is downloaded again in the future.
P563.tenv3 sha256:8cf6fdaeb2453da98763780a28d157cc685aa838897f4fc83bdd411a366584aa
P563: 7471 daily solutions saved to data/gps_P563_relative_position.csv
fig, ax = plt.subplots(figsize=(10, 4))
for station, rel in series.items():
ax.plot(rel["decimal_year"], rel["east_m"], lw=0.8, label=station)
ax.set_xlabel("Time (decimal year)")
ax.set_ylabel("East displacement (m)")
ax.set_title("Daily east positions, NGL IGS20 solutions")
ax.grid(alpha=0.4)
ax.legend()
plt.tight_layout()
plt.show()
The east components show steady, nearly linear motion of a few millimeters per year: plate tectonics measured directly. Look closely and you can also see seasonal wiggles and, depending on the station and period, small offsets from earthquakes or equipment changes. In later chapters this kind of series becomes input for trend estimation, seasonal decomposition, and forecasting exercises.
The CSV files saved in ./data/ contain only the decimal year and the three displacement components, ready for reuse.
Data credit: Nevada Geodetic Laboratory (Blewitt et al., 2018, doi:10.1029/2018EO104623). The data are openly distributed; cite NGL in anything you publish from them.
- Blewitt, G., Hammond, W., & Kreemer, C. (2018). Harnessing the GPS Data Explosion for Interdisciplinary Science. Eos, 99. 10.1029/2018eo104623