Transforming Between Epoch-Based Datums

This page handles the one CRS problem that a static EPSG code cannot express: continental plates move, so a coordinate on a dynamic datum means nothing without a date attached to it. It covers coordinate epochs in pyproj, the difference between a plate-fixed and an Earth-fixed realisation, time-dependent Helmert transforms, and how to decide whether your twin needs any of this at all.

Why you hit this

Most city twins never notice plate motion, and then one does — when a survey flown in 2019 is merged with one flown in 2025 and the two disagree by six centimetres in a consistent direction across the whole extent. That is not a registration error and no amount of ICP will remove it. The Australian plate moves about 7 cm/year, the Pacific about 7, North America about 2.5, and Europe about 2.5. Over a six-year gap those are 42, 42, 15 and 15 cm respectively, and every one of them exceeds the tolerance of a survey-grade twin.

The static half of this is covered in coordinate reference systems for 3D assets; what follows is what changes when the datum has a time axis.

Prerequisites

  • Python 3.10+ with pyproj>=3.6 (PROJ 9.2+ for reliable epoch handling) and numpy>=1.24.
  • PROJ transformation grids installed, including the plate-motion models — projsync --source-id all fetches them.
  • The acquisition epoch of every dataset, as a decimal year. This is metadata surveys record and pipelines routinely discard, and without it none of the below is possible.
  • A decision about the twin’s reference epoch: the single date all data is expressed at.

Step-by-Step

1. Establish whether your datum is dynamic

Static datums are fixed to a plate and their coordinates do not change with time. Dynamic ones are fixed to the Earth’s centre of mass, so every point on a moving plate drifts.

python
from pyproj import CRS

for code in ("EPSG:7912", "EPSG:6318", "EPSG:7844", "EPSG:4326"):
    crs = CRS.from_user_input(code)
    dyn = crs.datum.type_name if crs.datum else "unknown"
    print(f"{code:<12} {crs.name[:44]:<46} {dyn}")

EPSG:7912 (ITRF2014) is a dynamic geodetic reference frame: its coordinates are Earth-centred and every point moves. EPSG:6318 (NAD83(2011)) and EPSG:7844 (GDA2020) are plate-fixed: within North America and Australia respectively, coordinates are nearly constant, and the plate’s motion has been absorbed into the definition. EPSG:4326 is the trap — nominally WGS84, in practice whatever realisation the receiver used, and treated by almost every tool as static when it is not.

Plate-fixed and Earth-fixed frames over six years A point on a moving plate keeps almost the same coordinates in a plate-fixed frame such as GDA2020, because the plate's motion is built into the definition. In an Earth-fixed frame such as ITRF2014 the same point drifts by the plate velocity, about forty-two centimetres over six years in Australia. plate-fixed (GDA2020) 2019 and 2025 coordinates: 2 mm apart Earth-fixed (ITRF2014) the same point drifts 42 cm in six years Neither frame is wrong. Mixing them without epochs produces a shift that looks like a registration failure. GNSS delivers Earth-fixed; national grids are usually plate-fixed; the conversion between them needs a date
The same ground point, two legitimate coordinate values. Which one you have depends on the frame, and converting between them is a function of time.

2. Attach the epoch to the coordinates

pyproj carries the epoch on the transformer, not on the CRS, and passing it is what switches on the time-dependent path.

python
import numpy as np
from pyproj import Transformer

# ITRF2014 (dynamic, EPSG:7912) at the 2019 acquisition epoch
# → GDA2020 (plate-fixed, EPSG:7844)
tf = Transformer.from_crs("EPSG:7912", "EPSG:7844", always_xy=True)

lon = np.array([151.2093, 151.2110])
lat = np.array([-33.8688, -33.8701])
h   = np.array([58.2, 61.7])
epoch = np.full(lon.shape, 2019.45)          # decimal year of acquisition

x, y, z, t = tf.transform(lon, lat, h, epoch)
print("GDA2020:", np.round(x, 8), np.round(y, 8))
print("operation:", tf.description)

The fourth positional argument is the epoch. Omit it and PROJ picks a default — usually the frame’s own reference epoch — which silently applies the wrong amount of plate motion. The error is smooth, consistent across the extent, and indistinguishable from a small datum offset.

3. Propagate coordinates between epochs within one frame

Bringing two surveys onto a common date is a separate operation from changing frames.

python
from pyproj import Transformer

# Same frame, different epochs: [email protected][email protected]
prop = Transformer.from_pipeline(
    "+proj=pipeline "
    "+step +proj=unitconvert +xy_in=deg +xy_out=rad "
    "+step +proj=cart +ellps=GRS80 "
    "+step +proj=deformation +dt=5.55 +grids=au_ga_AUS_GDA2020_conformal_and_distortion.tif "
    "+step +inv +proj=cart +ellps=GRS80 "
    "+step +proj=unitconvert +xy_in=rad +xy_out=deg"
)

Where a deformation grid is unavailable, a plate-motion model gives a good approximation: the velocity is nearly constant over decades, so the displacement is simply velocity times elapsed years applied in the Earth-centred frame.

python
import numpy as np

# Australian plate velocity in ITRF2014, ECEF metres per year.
VX, VY, VZ = -0.0398, 0.0000, 0.0509

def propagate_ecef(xyz, from_epoch, to_epoch):
    dt = to_epoch - from_epoch
    return xyz + np.array([VX, VY, VZ]) * dt

p2019 = np.array([-4646050.12, 2553461.44, -3534952.87])
p2025 = propagate_ecef(p2019, 2019.45, 2025.00)
print("displacement (m):", np.round(p2025 - p2019, 4),
      "| magnitude:", round(float(np.linalg.norm(p2025 - p2019)), 4))

4. Pick one reference epoch for the twin and convert everything to it

The decision that makes the rest tractable is choosing a single date all stored geometry is expressed at.

python
REFERENCE_EPOCH = 2020.00        # the twin's internal epoch, recorded in the manifest

def ingest(dataset_path, source_crs, acquisition_epoch):
    tf = Transformer.from_crs(source_crs, "EPSG:7844", always_xy=True)
    lon, lat, h = read_coordinates(dataset_path)
    x, y, z, _ = tf.transform(lon, lat, h, np.full(lon.shape, acquisition_epoch))
    return x, y, z, {
        "source_crs": source_crs,
        "acquisition_epoch": acquisition_epoch,
        "reference_epoch": REFERENCE_EPOCH,
        "plate_model": "ITRF2014 / GDA2020",
    }

Recording the metadata alongside the coordinates is what makes the transform reversible. A dataset whose acquisition epoch has been forgotten cannot be returned to its original frame, and cannot be correctly compared against a later survey.

Every dataset converted to one reference epoch at ingest Three surveys acquired in 2018, 2021 and 2025 are each propagated to the twin's reference epoch of 2020 at ingest, so all stored geometry shares one date. Comparisons between them then measure real change rather than plate motion, and the acquisition epoch is retained so the conversion stays reversible. survey 2018.30 survey 2021.60 survey 2025.10 propagate toepoch 2020.00 one internal frame,one date, comparisonsmeasure real change Keep the acquisition epoch in the metadata — without it the propagation cannot be undone or re-derived
One reference epoch turns a fleet of surveys into a comparable set. Without it, every multi-year difference contains an unknown amount of plate motion.
Plate velocity against the gap between two surveys Displacement is velocity times elapsed years, so the error from ignoring plate motion grows linearly with the gap between surveys. In Australia at seven centimetres a year, a two-year gap is inside most tolerances and a six-year gap is not. In Europe at two and a half, the same crossing happens around fifteen years. 5 cm survey tolerance Australia, 7 cm/yr Europe, 2.5 cm/yr 048 121620 years between the two surveys
The threshold is not a property of the datum but of your tolerance and your region. Crossing it is what turns epoch handling from optional into necessary.

Expected Output & Verification

The check that settles whether epoch handling is working is a differenced control point across two surveys.

python
import numpy as np

# Same physical benchmark, surveyed twice, both propagated to the reference epoch.
p_2019 = np.array([336742.118, 6250881.442, 58.204])
p_2025 = np.array([336742.121, 6250881.446, 58.199])

d = p_2025 - p_2019
print("residual (mm):", np.round(d * 1000, 1),
      "| magnitude:", round(float(np.linalg.norm(d)) * 1000, 1), "mm")
assert np.linalg.norm(d) < 0.02, "residual exceeds survey tolerance — check the epochs"

Expected output for a correctly handled pair is a residual of a few millimetres — the surveys’ own noise. A residual of 15–45 cm with a consistent horizontal direction across every control point is unhandled plate motion, and its magnitude divided by the years between surveys should come out close to your region’s plate velocity, which is a satisfying confirmation of the diagnosis.

Common Errors

A consistent horizontal shift between two epochs of the same site. Plate motion, unhandled. Divide the shift by the elapsed years and compare against the plate velocity for your region; the match is usually within a centimetre per year.

pyproj ignores the epoch you passed. The transformation PROJ selected is not time-dependent, either because a plate-motion grid is missing or because both CRSs are plate-fixed and no propagation is required. Inspect tf.description and tf.get_grids_used() to see which operation was chosen.

Coordinates change when nothing should have. A static transform was applied between two dynamic frames using the default epoch rather than the acquisition epoch. The magnitude is the plate velocity times the difference between the acquisition date and the frame’s reference date.

Frequently Asked Questions

Does my twin need epoch handling at all?

If all data comes from one survey campaign, or if the twin’s tolerance is decimetres rather than centimetres, no. It becomes necessary when multi-year data is fused at survey-grade tolerance, or when the twin is in a fast-moving region such as Australia, New Zealand or the Pacific coast.

What epoch should I pick as the reference?

Something round and defensible — the year the twin was established is common. What matters far more than the value is that it is recorded in the manifest and that every ingest converts to it.

Is WGS84 static or dynamic?

Dynamic, in every modern realisation, though almost every tool treats EPSG:4326 as static. That mismatch is why a GNSS-derived cloud and a national-grid dataset can differ by a decimetre with no error anywhere.

A closing note on where the epoch belongs in a pipeline. It is metadata about the acquisition, not about the file, so it should travel with the survey from the moment it is delivered — in the LAS header’s VLR, in a sidecar next to the raster, in the manifest row for the tile. Pipelines routinely discard it at the first conversion because no format demands it, and by the time the second survey arrives there is nothing to propagate from. Recording it costs one field and is unrecoverable once lost.

The second point is that the epoch and the datum are separate declarations, and both are needed. “GDA2020” alone does not say when the coordinates were valid, and “epoch 2019.45” alone does not say which frame they are in. A pipeline that asserts one and not the other will happily fuse two surveys that agree on the frame and differ by six years of plate motion.

Can I just apply a constant shift between two survey epochs?

Over a city, usually yes — plate velocity varies by well under a millimetre per year across a metropolitan extent, so a single displacement vector is accurate to a millimetre or two. Over a national extent it is not: velocity varies with position on the plate, and a deformation grid is the correct instrument.

What about vertical motion?

Subsidence, uplift and glacial isostatic adjustment are real and are not captured by a horizontal plate model. Where they matter — reclaimed land, extraction areas, formerly glaciated regions — the vertical rate has to come from a local deformation model or from repeated levelling, and it is often larger than the horizontal plate motion.

Does this affect the tileset I publish?

Only through the coordinates that went into it. The published tileset is a snapshot at the reference epoch; what changes is that a tileset built next year from a new survey will line up with it, because both were propagated to the same date before tiling.

Back to Coordinate Reference Systems for 3D Assets.