Propagating Coordinates Between Epochs with Velocity Grids

Moving a survey-grade position from the epoch it was observed at to the epoch a deliverable requires is a single linear step — position plus velocity times interval — and the whole difficulty is getting a trustworthy velocity at the point. This guide, part of time-dependent transformations and plate motion within core transformation fundamentals and standards, implements the gridded route: reading a horizontal and vertical velocity model, interpolating it at the site, applying it over the interval, and recording enough that the result can be reproduced. The tolerance it has to satisfy is the same as any other cadastral operation — the propagated uncertainty must stay inside the horizontal and vertical budgets set out in tuning transformation thresholds for survey-grade work.

Why a Grid Rather Than a Plate Rotation

A rigid plate rotation describes the bulk motion of a plate interior and nothing else. Near a plate boundary the crust deforms: velocities vary smoothly but non-uniformly across tens of kilometres, so two monuments in the same county can move differently by several millimetres a year. A velocity grid stores that field on a regular lattice — typically a national model published on a grid of a few arc-minutes, carrying north, east and up rates in millimetres per year — and interpolating it recovers the local velocity rather than the plate average.

Path of one epoch propagation from a velocity grid A position and its source epoch enter. The grid extent is tested first and an outside query is refused rather than extrapolated. The four surrounding nodes are read and checked for no-data, which is the second refusal. The north, east and up rates are bilinearly interpolated, converted to degrees using the meridian and prime-vertical radii at that latitude, and applied over the interval. The velocity actually used is returned with the position so it can be written to the audit record. Position + source epoch Inside the grid extent? Read 4 nodes check for no-data Refuse no defensible velocity Bilinear vn, ve, vu metres per year Apply over the interval via M and N at this latitude Position at target epoch + the velocity used yes no no-data

Figure — the propagation path, with the two places it is allowed to refuse.

The arithmetic is unchanged from the rigid case. For a site at (φ,λ)(\varphi, \lambda) with interpolated velocity components vn,ve,vuv_n, v_e, v_u in metres per year, the propagated position at epoch t1t_1 from epoch t0t_0 is

φ(t1)=φ(t0)+vn(t1t0)M(φ)λ(t1)=λ(t0)+ve(t1t0)N(φ)cosφh(t1)=h(t0)+vu(t1t0)\begin{aligned} \varphi(t_1) &= \varphi(t_0) + \frac{v_n\,(t_1 - t_0)}{M(\varphi)} \\ \lambda(t_1) &= \lambda(t_0) + \frac{v_e\,(t_1 - t_0)}{N(\varphi)\cos\varphi} \\ h(t_1) &= h(t_0) + v_u\,(t_1 - t_0) \end{aligned}

where MM and NN are the meridian and prime-vertical radii of curvature in metres, and the latitude and longitude increments come out in radians. Working in geodetic coordinates rather than Cartesian ones keeps the north and east components separate, which is what the grid publishes and what a surveyor wants to see in the audit record.

Complete Runnable Implementation

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

# GRS80 — the ellipsoid behind NAD83, ETRS89, GDA2020 and ITRF alike.
A = 6378137.0
F = 1.0 / 298.257222101
E2 = F * (2.0 - F)


@dataclass(frozen=True)
class VelocityGrid:
    """A regular lattice of site velocities, in metres per year.

    `vn`, `ve`, `vu` are (nlat, nlon) arrays indexed south-to-north and
    west-to-east, matching the row order of the published model. `lat0`/`lon0`
    are the south-west node in degrees and `dlat`/`dlon` the node spacing.
    """

    lat0: float
    lon0: float
    dlat: float
    dlon: float
    vn: np.ndarray
    ve: np.ndarray
    vu: np.ndarray

    def __post_init__(self) -> None:
        if not (self.vn.shape == self.ve.shape == self.vu.shape):
            raise ValueError("velocity components must share a shape")
        if self.vn.dtype != np.float64:
            raise ValueError("velocity grids must be float64: float32 costs ~0.1 mm/yr")

    def interpolate(self, lat: float, lon: float) -> np.ndarray:
        """Bilinear velocity at (lat, lon) in degrees -> (vn, ve, vu) in m/yr.

        Raises outside the grid extent. Extrapolating a deformation model past its
        published extent produces a number with no defensible uncertainty, which is
        the same rule the grid-shift readers apply.
        """
        nlat, nlon = self.vn.shape
        fi = (lat - self.lat0) / self.dlat
        fj = (lon - self.lon0) / self.dlon
        if not (0.0 <= fi <= nlat - 1 and 0.0 <= fj <= nlon - 1):
            raise ValueError(
                f"({lat:.6f}, {lon:.6f}) is outside the velocity grid extent"
            )
        i, j = int(np.floor(fi)), int(np.floor(fj))
        i = min(i, nlat - 2)
        j = min(j, nlon - 2)
        u, v = fi - i, fj - j
        w = np.array([(1 - u) * (1 - v), (1 - u) * v, u * (1 - v), u * v])
        out = np.empty(3, dtype=np.float64)
        for k, comp in enumerate((self.vn, self.ve, self.vu)):
            corners = np.array([comp[i, j], comp[i, j + 1],
                                comp[i + 1, j], comp[i + 1, j + 1]], dtype=np.float64)
            if not np.all(np.isfinite(corners)):
                raise ValueError("velocity grid carries a no-data node at this position")
            out[k] = float(w @ corners)
        return out


def radii_of_curvature(lat_rad: float) -> tuple[float, float]:
    """Meridian (M) and prime-vertical (N) radii in metres, GRS80."""
    s = np.sin(lat_rad)
    w = np.sqrt(1.0 - E2 * s * s)
    n = A / w
    m = A * (1.0 - E2) / (w ** 3)
    return float(m), float(n)


def propagate_epoch(
    lat_deg: float,
    lon_deg: float,
    h_m: float,
    grid: VelocityGrid,
    from_epoch: float,
    to_epoch: float,
) -> tuple[float, float, float, np.ndarray]:
    """Propagate a geodetic position within its frame between two epochs.

    Returns (lat_deg, lon_deg, h_m, velocity_used). The frame is unchanged: this
    is an epoch propagation, recorded as its own coordinate operation.
    """
    v = grid.interpolate(lat_deg, lon_deg)                    # m/yr, north-east-up
    dt = to_epoch - from_epoch                                # decimal years
    lat_rad = np.radians(lat_deg)
    m, n = radii_of_curvature(lat_rad)

    dlat = (v[0] * dt) / m                                    # radians
    dlon = (v[1] * dt) / (n * np.cos(lat_rad))                # radians
    return (
        float(lat_deg + np.degrees(dlat)),
        float(lon_deg + np.degrees(dlon)),
        float(h_m + v[2] * dt),
        v,
    )

Parameter Reference

Name Type Units Valid range Cadastral significance
lat_deg, lon_deg float degrees inside the grid extent Outside the extent the model has no defensible value
h_m float metres ellipsoidal Vertical rate applies to ellipsoidal height, not orthometric
from_epoch, to_epoch float decimal years 1990–2050 typical Interval in years; days or seconds are the classic unit bug
grid.vn/ve/vu np.ndarray m/yr −0.10 to +0.10 Published models often use mm/yr — convert on load
return velocity_used np.ndarray m/yr Must be written to the audit record, not just used

Worked Example

A monument at 45.500000° N, 122.600000° W with an ellipsoidal height of 61.230 m, observed at epoch 2015.20, is required at the deliverable’s reference epoch of 2020.00. The interpolated velocity is 7.4 mm/yr north, −12.1 mm/yr east and −1.6 mm/yr up.

Propagated velocity uncertainty against interval Bar chart of the propagated one-sigma uncertainty in millimetres from a velocity known to plus or minus one millimetre a year, over intervals of 2, 5, 10 and 20 years: 2, 5, 10 and 20 millimetres. Against a 20 millimetre horizontal tolerance, a twenty-year propagation consumes the entire budget on the velocity alone, before any other term is added. 0 5 10 15 20 mm 2 2 yr 5 5 yr 10 10 yr 20 20 yr 20 mm horizontal tolerance

Figure — velocity uncertainty at ±1 mm/yr, propagated over four intervals.

import numpy as np

grid = VelocityGrid(
    lat0=45.0, lon0=-123.0, dlat=0.5, dlon=0.5,
    vn=np.full((3, 3), 0.0074), ve=np.full((3, 3), -0.0121), vu=np.full((3, 3), -0.0016),
)
lat, lon, h, v = propagate_epoch(45.5, -122.6, 61.230, grid, 2015.20, 2020.00)
print(f"{lat:.8f} {lon:.8f} {h:.4f}")
print(f"applied {v * 1000} mm/yr over {2020.00 - 2015.20:.2f} yr")
# 45.50000032 -122.60000074 61.2223
# applied [ 7.4 -12.1  -1.6] mm/yr over 4.80 yr

The horizontal position has moved 35 mm north and 58 mm east, and the height has dropped 8 mm. Against a 20 mm horizontal tolerance, ignoring the propagation would have failed the deliverable on its own — with no arithmetic error anywhere in the pipeline.

Validation Check

def assert_within_budget(v: np.ndarray, dt: float, sigma_v: float, tol_h: float) -> None:
    """A propagation is only admissible while its own uncertainty fits the budget.

    `sigma_v` is the 1-sigma velocity uncertainty in m/yr (from the model's own
    accuracy statement); over a long interval it can exceed the tolerance by itself.
    """
    propagated_sigma = sigma_v * abs(dt)
    assert propagated_sigma <= tol_h, (
        f"velocity uncertainty {propagated_sigma * 1000:.1f} mm over {dt:.2f} yr "
        f"exceeds the {tol_h * 1000:.1f} mm horizontal budget"
    )

assert_within_budget(v, 4.80, sigma_v=0.0010, tol_h=0.020)   # 4.8 mm — passes

Common Mistakes

Millimetres per year read as metres per year. Published velocity models almost always state rates in mm/yr, and a grid loaded without the conversion propagates a thousand times too far — a monument that should move 35 mm moves 35 m. The tell is obvious once you look, which is exactly why the loader should assert that every rate is within ±0.1 m/yr, as the dataclass above does.

Unit and convention errors in an epoch propagation Three errors with their cause and their effect on a propagation that should move a point 35 millimetres. Rates read as metres per year instead of millimetres per year multiply the motion by a thousand, giving 35 metres. An interval computed in days rather than years multiplies it by about 365, giving nearly 13 metres. The vertical rate applied to an orthometric rather than an ellipsoidal height leaves the horizontal correct and the height wrong by the geoid assumption, a few millimetres to a centimetre. Cause Effect on a 35 mm move mm/yr read as m/yr missing conversion 35 m interval in days date subtraction 12.8 m vu on orthometric height wrong height type mm to cm in H

Figure — three unit and convention errors, and what each does to a 35 mm propagation.

The interval computed in days. Subtracting two dates and getting days, then multiplying by an annual rate, overstates the motion by a factor of about 365. It survives a round trip, because the error is symmetric, which is why the time-reversal check in the parent topic tests the displacement against the annual rate directly.

Vertical rate applied to an orthometric height. The velocity model’s up component describes the motion of the ground relative to the ellipsoid. Applying it to an orthometric height silently assumes the geoid moves with the ground, which is not what the model says. Propagate the ellipsoidal height, then re-derive the orthometric height using the approach in applying geoid undulation for orthometric heights.

Frequently Asked Questions

Is linear propagation good enough over twenty years?

For steady tectonic motion, yes: the departure from linearity in a stable field is well under a millimetre a decade, far below the velocity model’s own uncertainty. It is not good enough across an earthquake, which displaces a site instantly by an amount no velocity describes, and it is questionable across a period of changing groundwater extraction. Both cases need a co-seismic or time-series correction rather than a longer extrapolation.

What uncertainty should I assign to the velocity?

The one the model publishes, interpolated the same way the velocity is — most national models supply a per-node uncertainty grid, and using a single national figure discards real spatial variation. Where nothing is published, one to two millimetres a year is a defensible working figure for a stable interior and clearly optimistic near a boundary. Whatever the number, propagate it: over fifteen years, 1 mm/yr becomes 15 mm.

Should I propagate every point, or the dataset as a whole?

Every point, using its own interpolated velocity, whenever the dataset spans more than a few tens of kilometres in a deforming region. Applying one velocity to a whole county reintroduces exactly the error the grid exists to remove. For a small site in a stable interior a single velocity is fine, and stating that simplification in the audit record makes it reviewable.

Can I propagate and transform the datum in one step?

Numerically, yes — and it is often how a published pipeline is written. What must not be merged is the record: the epoch propagation and the datum transformation have independent uncertainties, and a reviewer needs to see both, along with the velocity source and the operation identifier. Combining them into one line in the report hides which of the two produced a given millimetre.