Handling Epoch and Plate Motion in PyProj
A survey positioned by a global correction service and delivered on a national grid comes back eight centimetres out, consistently, in the direction the continent is moving. Nothing is misconfigured in the usual sense: the horizontal datums were transformed, the geoid was applied, the control fits. The missing piece is that both coordinate systems are correct at different moments, and the transformation between them was asked to ignore time.
This page makes the epoch explicit, applies the transformation with it, and checks that it was actually used.
Why a coordinate needs a date
The Earth’s crust moves. Australia travels roughly 7 cm a year, North America 1–2 cm, western Europe about 2.5 cm. Two families of reference frame handle that differently.
A global frame — the ITRF realisations, and the frames that global correction services broadcast in — is fixed to the Earth as a whole. A point on a moving plate therefore has coordinates that change every year, and a position in such a frame is meaningless without the date it refers to.
A plate-fixed frame — most national grids — is attached to its own plate and defined at a fixed reference epoch. Coordinates in it are stable over decades, which is exactly what a cadastre needs.
Transforming between them requires knowing both dates: where the point was when observed, and where the frame says it should be at its reference epoch. Supply only one and the transformation applies a static approximation, which is wrong by the accumulated motion.
Figure 1 — The same monument in two frames. Neither coordinate is wrong; they describe the position at different moments, and the difference is a measurable quantity rather than an error.
Minimal reproducible solution
pyproj handles this through 4D coordinates: transform with a time component, and use a TransformerGroup when you need to see which operations are available and how accurate each is.
import numpy as np
from pyproj import CRS, Transformer
from pyproj.transformer import TransformerGroup
def epoch_aware_transform(lon: np.ndarray, lat: np.ndarray, h: np.ndarray,
observation_epoch: float,
source_crs: str, target_crs: str) -> tuple:
"""Transform positions with their observation epoch supplied.
observation_epoch is a decimal year — 2026.62 for mid-August 2026. The
fourth coordinate is what lets PROJ apply a time-dependent operation; omit
it and PROJ silently uses a static approximation.
"""
src, tgt = CRS.from_user_input(source_crs), CRS.from_user_input(target_crs)
group = TransformerGroup(src, tgt, always_xy=True)
if not group.transformers:
raise ValueError(f"no operation available from {source_crs} to {target_crs}")
if group.unavailable_operations:
names = [op.name for op in group.unavailable_operations[:2]]
raise RuntimeError(
f"a more accurate operation exists but its grid is missing: {names} — "
"set PROJ_NETWORK=ON or install the grid")
tr = group.transformers[0]
t = np.full_like(np.asarray(lon, dtype=float), observation_epoch)
e, n, z, _ = tr.transform(lon, lat, h, t)
return e, n, z, tr.description, tr.accuracy
Two guards carry the weight. TransformerGroup exposes the unavailable operations as well as the available ones, which is the only way to learn that PROJ chose a coarser path because a grid was missing — the ordinary Transformer.from_crs simply returns the best it can build and says nothing. And passing the epoch as a fourth coordinate is what selects a time-dependent operation at all; a three-argument call cannot, regardless of how the CRS are declared.
The epoch itself should come from the data rather than from datetime.now():
from datetime import datetime, timezone
def decimal_year(when: datetime) -> float:
"""Convert an acquisition timestamp to the decimal year PROJ expects."""
if when.tzinfo is None:
raise ValueError("declare the timezone; a naive timestamp is ambiguous")
utc = when.astimezone(timezone.utc)
start = datetime(utc.year, 1, 1, tzinfo=timezone.utc)
end = datetime(utc.year + 1, 1, 1, tzinfo=timezone.utc)
return utc.year + (utc - start).total_seconds() / (end - start).total_seconds()
Using the flight date rather than the processing date matters when data is reprocessed: a survey flown in 2024 and reprocessed in 2027 must still be transformed at its 2024 epoch, or the reprocessed result will differ from the original delivery by three years of motion.
Edge-case matrix
| Situation | Without an epoch | With one |
|---|---|---|
| Global frame → plate-fixed grid | Off by the accumulated motion | Correct |
| Plate-fixed → the same grid | No difference | No difference |
| Two global frames, same epoch | Small, from frame realisation | Same |
| Reprocessing an old survey | Uses today’s motion | Uses the flight’s epoch |
| Slow plate (a few mm/yr) | Under a centimetre | Same, negligible |
| Fast plate (7 cm/yr) | Decimetres over a decade | Correct |
| Post-seismic deformation | Not modelled by a velocity | Needs a deformation model |
| Missing grid for the operation | Coarser path chosen silently | Detected by the group check |
The post-seismic row is the honest limit. A plate velocity model describes steady motion; it does not describe the metre-scale displacements a large earthquake produces, nor the years of decaying afterslip that follow. In affected regions the national agency publishes a deformation model, and the transformation must use it rather than a linear velocity.
Figure 2 — Why the group matters. The ordinary constructor is a convenience that quietly hides the one fact a survey pipeline needs: whether the operation it got was the operation it wanted.
Verify the fix worked
The check is that the applied displacement matches the plate velocity times the elapsed time, in the direction the plate moves.
import numpy as np
def assert_epoch_correction_plausible(before_en: np.ndarray, after_en: np.ndarray,
years: float, velocity_mm_yr: float,
tol_ratio: float = 0.3) -> None:
"""The shift must match the expected plate motion in size and uniformity."""
shift = after_en - before_en
magnitude = np.linalg.norm(shift, axis=1)
expected_m = abs(velocity_mm_yr) * abs(years) / 1000.0
median = float(np.median(magnitude))
assert abs(median - expected_m) <= expected_m * tol_ratio + 0.005, (
f"applied shift {median * 100:.1f} cm against an expected "
f"{expected_m * 100:.1f} cm over {years:.1f} years")
# Plate motion is uniform across a survey-sized area.
directions = shift / np.maximum(magnitude[:, None], 1e-9)
spread = float(np.std(np.degrees(np.arctan2(directions[:, 0], directions[:, 1]))))
assert spread < 5.0, (
f"shift directions vary by {spread:.1f}° across the survey — plate "
"motion is uniform at this scale, so this is not plate motion")
The directional-uniformity test is the one that catches a misdiagnosis. Over a few kilometres the plate velocity field is constant to well under a degree, so a correction whose direction varies across the block is doing something else — usually a projection difference being absorbed into what was assumed to be an epoch problem.
When to escalate
- The correction is applied and a residual offset remains. The frames differ by more than their epochs; check the datum realisations themselves, which is the vertical and horizontal datum question rather than a temporal one.
- The region has had a significant earthquake since the frame epoch. A linear velocity model is not adequate. The national agency’s published deformation model is the only correct input, and applying plate motion instead will be wrong by the coseismic displacement.
- Two surveys of the same site years apart disagree by the plate motion. They were transformed at their own epochs and are both correct — in different frames. For change detection, transform both to a common epoch before differencing, and record which epoch that was.
Whatever epoch is used, record it. The epoch belongs in the run manifest beside the CRS strings and the PROJ version, and it belongs in the deliverable’s metadata, because a coordinate without its epoch cannot be transformed correctly by whoever receives it. A client who is told only “EPSG:7844” has been given a frame and not a position in it, and the first thing they will do is transform it at whatever epoch their software defaults to.
Related
- Coordinate transformation workflows in PyProj
- Converting WGS84 to a local grid with Python
- RTK and PPK geotagging workflows in Python
← Coordinate Transformation Workflows in PyProj
Figure 3 — The consequence for repeat surveys. Two correct datasets differenced without a common epoch report the continent’s motion as the client’s.