How-To Guides
Task-oriented recipes. Each assumes geepers is installed
(Getting started) and, where GPS data is
downloaded, network access to geodesy.unr.edu or
sideshow.jpl.nasa.gov. Downloads are cached under
~/.cache/geepers/ (override with cache_dir= on any source).
How to validate an OPERA DISP product against GPS
One command runs the full comparison — time series, velocities, and the structure-function requirement verification:
geepers \
--los los_enu.tif \
--timeseries-stack disp_product.zarr \
--stack-data-var displacement \
--wavelength 0.2384 \
--insar-buffer-meters 100 \
--requirement-mm 3 0.5 \
-o ./GPS_validation
Key choices:
--insar-buffer-meters Rsamples a circular footprint of radius R meters around every GPS station (reference and secondary alike) and takes the NaN-ignoring median — e.g. 100 m to suppress single-pixel noise without mixing in different ground motion. (--insar-buffer Nis the older pixel-count rectangular variant.)--requirement-mm A Bdraws and checks the requirement curveA + B·√(distance_km)mm on the structure function (e.g.3 0.5).--gps-time-window Dapplies a D-day rolling median to the GPS LOS series (default 10; 0 disables).- If the product stores displacement in meters,
--wavelengthis ignored; it only matters for stacks in radians (pass0.2384for NISAR L-band). --ref STATIONfixes the reference station; otherwise the station with the best temporal coherence / data coverage is auto-selected.
Outputs in GPS_validation/:
| file | contents |
|---|---|
combined_data.csv |
tidy per-station GPS + InSAR LOS series |
relative_comparison.csv |
both series relative to the reference station |
station_summary.csv |
per-station GPS (MIDAS) and InSAR rates + quality metrics |
pairwise_rmse.csv |
structure function: relative RMSE + bias for every station pair vs separation |
rmse_profile.csv |
binned median/mean/p90 RMSE per distance bin, fraction_passing if a requirement was given |
epoch_rmse.csv |
network misfit per acquisition date — spikes flag bad epochs (ionosphere, unwrapping, snow) |
structure_function.png |
pairs + binned curve + requirement line |
The pairwise structure function differences the two stations' series before comparing, so any common reference or datum wobble cancels — it measures the product's relative accuracy the way the OPERA requirements are written. For programmatic access to the same metrics see Analysis modules.
How to screen a GPS network before analysis
import numpy as np
from geepers.gps_sources import UnrSource
from geepers.quality import gap_percentage
from geepers.variability import temporal_velocity_variability
src = UnrSource()
stations = src.stations(bbox=(112, -39, 158, -10)) # W, S, E, N
good = []
for sid in stations["id"]:
df = src.timeseries(sid, start_date="2010-01-01")
# 1. Enough data, few gaps
span_yr = (df["date"].iloc[-1] - df["date"].iloc[0]).days / 365.25
if span_yr < 3 or gap_percentage(df["date"]) > 35:
continue
# 2. Velocity stable in time
tv = temporal_velocity_variability(df["date"], df[["east", "north", "up"]])
if tv.variability["up"] * 1000 > 2.0: # mm/yr
continue
good.append(sid)
Add per-station spatial checks with
geepers.variability.spatial_variability (neighbor RMS/MAD) and
ssf_per_station once you have velocities for the whole network.
Two more cleaning steps that pay off before velocity fitting:
from geepers.steps import detect_steps_enu
from geepers.cme import estimate_cme, remove_cme
# 1. Find uncatalogued jumps; feed them to the trend/MIDAS estimators
found = detect_steps_enu(df) # complements UnrSource.steps()
# 2. Estimate and remove the network common mode from detrended residuals
cme = estimate_cme(residuals_by_station) # epochs x stations DataFrame
cleaned = cme.cleaned # or remove_cme(residuals_by_station)
How to estimate a velocity with a realistic uncertainty
from geepers.gps_sources import UnrSource
from geepers.trend import estimate_trend
src = UnrSource()
df = src.timeseries("P123", start_date="2015-01-01")
steps = src.steps(station_ids=["P123"])
res = estimate_trend(
df["date"],
df["up"] * 1000, # meters -> mm
step_dates=steps["date"].tolist(), # estimate offsets, don't span them
noise_model="PLWN", # power-law + white
)
print(f"v = {res.velocity:.2f} ± {res.velocity_uncertainty:.2f} mm/yr "
f"(kappa = {res.kappa:.2f})")
Use noise_model="WN" for a fast first pass (OLS-equivalent sigma,
typically 5-10× too small), or geepers.midas.midas when robustness to
unknown steps matters more than the noise model.
How to interpolate a velocity field onto a grid
Robust, edge-preserving (GPS Imaging — cite Hammond et al., 2016):
import numpy as np
from geepers.gps_imaging import make_ssf, msf_interpolate
ssf = make_ssf(lon, lat, v_up, sigma_up)
gx, gy = np.meshgrid(np.arange(-120, -114, 0.1), np.arange(36, 41, 0.1))
grid = msf_interpolate(lon, lat, v_up, sigma_up, gx.ravel(), gy.ravel(), ssf)
v_grid = grid["value"].to_numpy().reshape(gx.shape)
Geostatistical, with covariance-propagated uncertainties (collocation):
from geepers.collocation import empirical_covariance, interpolate_velocities
emp = empirical_covariance(lon, lat, ve, vn, se, sn)
at_stations, at_grid = interpolate_velocities(
lon, lat, ve, vn, se, sn, gx.ravel(), gy.ravel(),
covariance_parameters=emp.parameters,
)
Rules of thumb: GPS Imaging for vertical land motion, outlier-prone networks, and sharp boundaries; collocation for smooth horizontal fields where you need rigorous sigmas. See Analysis modules.
How to interpolate across plate boundaries (plate-separation constraint)
A covariance model only knows distance: stations 50 km apart on
opposite sides of a plate boundary look "close", and interpolation
smears the velocity discontinuity across the boundary.
separate_plates fixes this by building a working coordinate space in
which each plate's points are pushed far apart (≥ 1500 km by default),
so any distance-decaying covariance treats cross-plate pairs as
uncorrelated — while within-plate geometry is preserved.
The recipe: assign & separate → interpolate in moved coordinates → attach results back to the true coordinates.
Self-contained synthetic example (two plates with discontinuous motion):
import geopandas as gpd
import numpy as np
from shapely.geometry import box
from geepers.collocation import (
ordinary_kriging, separate_plates,
)
rng = np.random.default_rng(1)
# Two plates meeting at lon = 5°, moving differently
plates = gpd.GeoDataFrame(
{"code": ["A", "B"]},
geometry=[box(0, 0, 5, 10), box(5, 0, 10, 10)],
crs="EPSG:4326",
)
n = 80
lon = np.r_[rng.uniform(0.5, 4.5, n // 2), rng.uniform(5.5, 9.5, n // 2)]
lat = rng.uniform(1, 9, n)
v = np.where(lon < 5, -2.0, 3.0) + rng.normal(0, 0.3, n) # 5 mm/yr jump
sv = np.full(n, 0.3)
# Evaluation grid spanning the boundary
gx, gy = np.meshgrid(np.linspace(0.5, 9.5, 60), np.linspace(1, 9, 40))
# --- 1. Separate stations AND grid points together (one call, so both
# get consistent working coordinates)
all_lon = np.r_[lon, gx.ravel()]
all_lat = np.r_[lat, gy.ravel()]
lon_m, lat_m, plate_idx = separate_plates(
all_lon, all_lat, plates, min_separation_km=1500
)
slon_m, slat_m = lon_m[:n], lat_m[:n] # stations, moved
glon_m, glat_m = lon_m[n:], lat_m[n:] # grid, moved
# --- 2. Interpolate in the moved space
params = np.array([1.0, 200.0]) # (C0, d0) — or fit with
# empirical_covariance on
# the *moved* coordinates
unconstrained = ordinary_kriging(lon, lat, v, sv, gx.ravel(), gy.ravel(), params)
constrained = ordinary_kriging(slon_m, slat_m, v, sv, glon_m, glat_m, params)
# --- 3. Results map back trivially: constrained.signal[i] belongs to
# the true coordinate (gx.ravel()[i], gy.ravel()[i])
v_unc = unconstrained.signal.reshape(gx.shape) # boundary smeared
v_con = constrained.signal.reshape(gx.shape) # sharp step at lon=5°
Plotting v_unc vs v_con shows the difference: without the
constraint the −2 → +3 mm/yr step is blurred over ~2 x d0; with it,
each grid node is informed only by stations on its own plate.
The same moved coordinates work for interpolate_velocities (LSC) and
geepers.gps_imaging.msf_interpolate — pass slon_m/slat_m and
glon_m/glat_m wherever station/evaluation coordinates go, and always
fit empirical_covariance / make_ssf on the moved coordinates so
cross-plate pairs don't contaminate the covariogram.
Real-data notes:
- Plate polygons: use e.g. the Bird (2003) plate-boundary model, or
your project's plate shapefile —
plates = gpd.read_file("plates.shp"). Points falling outside every polygon are assigned to the nearest plate (the original workflow silently dropped them). - Horizontal velocities: remove each plate's rigid rotation
before interpolating and restore it afterward — otherwise the plate
motion itself dominates the field. Use
geepers.euler:
```python from geepers.euler import estimate_euler_pole, predict_plate_motion
for k in np.unique(plate_idx[:n]): # per plate sel = plate_idx[:n] == k pole = estimate_euler_pole(lon[sel], lat[sel], ve[sel], vn[sel], se[sel], sn[sel]) vp_e, vp_n = predict_plate_motion(pole, lon[sel], lat[sel]) ve[sel] -= vp_e; vn[sel] -= vp_n # remove (restore after) ```
(or build the EulerPole from published ITRF2014-PMM values).
Vertical velocities need no such correction, which makes them the
simplest case.
- Choose min_separation_km several times larger than the fitted
correlation length d0 (the 1500 km default suits d0 ≲ 300 km).
How to browse UNR grid time series interactively
cd scripts/
python create-geoparquet.py --bbox -110 28 -101 36 --start-date 2016-01-01
python -m http.server 8123
# open http://localhost:8123/browse_unr_grid.html
The viewer loads the Parquet directly in the browser (no server-side
processing) with a date slider, per-point time-series charts, and WebM
animation export. See scripts/README.md for details.
How to point the download cache somewhere else
Every data source accepts cache_dir:
src = UnrSource(cache_dir="/big/disk/gps_cache")
or set XDG_CACHE_HOME to relocate all caches.