Recording Coordinate Epochs in Cadastral Deliverables

A coordinate epoch that is computed correctly and then lost at the file boundary is worth nothing, and most exchange formats have nowhere obvious to put it. This guide, part of time-dependent transformations and plate motion, covers where the epoch goes in a deliverable, how to validate that it survived the round trip, and what a reviewer should be able to reconstruct from it — the ISO 19111 requirement being that a coordinate set carries enough metadata to be interpreted unambiguously, which for a time-dependent frame includes the instant.

What the Epoch Has to Travel With

An epoch on its own is not enough, because it only means something in relation to a frame and an operation. The minimum group that must stay together is four items: the coordinate reference system including its realisation, the coordinate epoch, the operation that produced the coordinates, and the source of any velocity applied. Split them and each piece becomes uninterpretable — a set of coordinates labelled “epoch 2020.00” with no frame is exactly as ambiguous as one labelled NAD83 with no realisation.

Where the coordinate epoch can live, by container Four containers rated for whether they carry a coordinate epoch natively and what the remedy is. WKT2:2019 carries one through its coordinate-metadata construct and needs no remedy. GeoPackage carries WKT2 and so inherits it, with reader support varying, so a sidecar is recommended as well. A shapefile .prj is WKT1 and carries nothing, so a sidecar is required. A plain text or CSV export carries nothing at all and requires both a sidecar and a manifest binding the two together. Carries an epoch? Remedy WKT2:2019 yes none needed GeoPackage via WKT2 sidecar as well Shapefile .prj no sidecar required CSV / text no sidecar + manifest

Figure — which containers can hold an epoch, and what to do when they cannot.

Formats differ in how much of this they can carry natively. WKT2:2019 has a COORDINATEMETADATA construct that carries an epoch alongside a CRS, and GeoPackage and modern GeoTIFF can hold WKT2 in their CRS fields. A shapefile’s .prj is WKT1 and has nowhere to put an epoch at all. LandXML and most CAD exchange formats have project-level metadata blocks that will hold arbitrary key-value pairs. The rule that follows is simple: where the format supports it, put the epoch in the CRS definition; where it does not, put it in an accompanying metadata sidecar that ships with the file and is named in the delivery manifest.

Complete Runnable Implementation

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, asdict
from datetime import date


@dataclass(frozen=True)
class CoordinateMetadata:
    """The group that must travel with a time-dependent coordinate set."""

    crs_id: str                  # e.g. "EPSG:6318" — a realisation, not an ensemble
    crs_wkt: str                 # the full definition as it stood at production time
    coordinate_epoch: float      # decimal year, e.g. 2020.00
    operation_id: str            # e.g. "EPSG:8264" or a pipeline string
    operation_accuracy_m: float  # as declared by the operation
    velocity_source: str | None  # model + version, or grid file name
    velocity_grid_sha256: str | None
    epoch_propagation: str | None  # "2015.20 -> 2020.00" when one was applied
    produced_on: str             # ISO date; provenance, NOT part of the identity

    def validate(self) -> None:
        """Refuse a record that cannot be interpreted by a reader."""
        if self.crs_id.upper().endswith("4269"):
            raise ValueError(
                "EPSG:4269 is the NAD83 ensemble, not a realisation — use the "
                "realisation code (e.g. EPSG:6318 for NAD83(2011))"
            )
        if not (1980.0 <= self.coordinate_epoch <= 2100.0):
            raise ValueError(f"implausible coordinate epoch {self.coordinate_epoch}")
        if self.epoch_propagation and not self.velocity_source:
            raise ValueError(
                "a propagation was applied but no velocity source is recorded"
            )

    def to_sidecar(self) -> str:
        """Deterministic JSON for a metadata sidecar shipped with the data file."""
        self.validate()
        return json.dumps(asdict(self), indent=2, sort_keys=True)

    def identity_digest(self) -> str:
        """Digest over the interpretive fields only.

        The production date is deliberately excluded: re-running the same job over
        the same inputs must produce the same digest, or the digest cannot answer
        the only question it exists to answer.
        """
        self.validate()
        payload = {k: v for k, v in asdict(self).items() if k != "produced_on"}
        blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
        return hashlib.sha256(blob.encode("utf-8")).hexdigest()

Parameter Reference

Field Type Required Why a reviewer needs it
crs_id str yes A realisation code; an ensemble code hides up to ~2 m
crs_wkt str yes The definition as it stood, in case the registry entry changes
coordinate_epoch float yes for time-dependent frames Without it a global-frame coordinate is uninterpretable
operation_id str yes Which of several candidate operations actually ran
operation_accuracy_m float yes The declared accuracy the tolerance was judged against
velocity_source str when propagated Model and version, or the grid file
velocity_grid_sha256 str when a grid was used Makes a changed grid visible
epoch_propagation str when propagated Records it as its own operation
produced_on str yes Provenance only — excluded from the digest

Worked Example

meta = CoordinateMetadata(
    crs_id="EPSG:6318",
    crs_wkt='GEOGCRS["NAD83(2011)", ... ]',
    coordinate_epoch=2020.00,
    operation_id="EPSG:8264",
    operation_accuracy_m=0.015,
    velocity_source="regional velocity grid v2.1",
    velocity_grid_sha256="9f2c1a...",
    epoch_propagation="2015.20 -> 2020.00",
    produced_on=date(2026, 8, 11).isoformat(),
)
print(meta.identity_digest()[:16])
print(meta.to_sidecar()[:80])
# 4b8f0c2d9a1e7c53
# {
#   "coordinate_epoch": 2020.0,

Re-running the job tomorrow produces a different produced_on and the same digest — which is the property that makes the digest usable as evidence rather than as a timestamp.

The four-part identity of a time-dependent coordinate set Four items form one identity. The coordinate reference system with its realisation says what frame the numbers are in. The coordinate epoch says when they were valid. The operation identifier says how they were produced. The velocity source says what moved them, when a propagation was applied. Any one of the four on its own is insufficient, and a deliverable that separates them makes each piece uninterpretable. CRS + realisation what frame Coordinate epoch when valid Operation id how produced Velocity source what moved it One interpretable coordinate set all four, or none

Figure — the group that has to stay together; splitting it makes each piece uninterpretable.

Validation Check

def assert_epoch_survived(written_sidecar: str, expected_epoch: float) -> None:
    """Read back what was actually written, not what was meant to be written."""
    got = json.loads(written_sidecar)["coordinate_epoch"]
    assert abs(got - expected_epoch) < 1e-9, (
        f"epoch was written as {got}, expected {expected_epoch}"
    )

Reading the file back is the only check that catches a serialiser that dropped a field, a float formatted to zero decimal places, or a template that was never populated. It costs one line and is the difference between believing the epoch shipped and knowing it did.

Common Mistakes

The ensemble code used instead of the realisation. EPSG:4269 (NAD83) and EPSG:4326 (WGS84) are ensembles: they name a family of realisations that differ by up to a couple of metres. A deliverable labelled with an ensemble code has thrown away the precision it was produced with, which is why the validator above rejects it outright. The same argument is made at more length in working with EPSG and WKT2 CRS definitions.

The epoch recorded as a date string. “2020-03-15” and 2020.20 are not interchangeable: decimal years are what transformation parameters and velocity models are stated in, and converting a date to a decimal year requires a convention about day counts that different tools resolve differently. Store the decimal year, and store the calendar date beside it if a human needs to read one.

Metadata written from what was intended rather than what ran. A sidecar populated from the job configuration rather than from the objects the pipeline actually used will be right until the day a fallback operation runs, at which point it documents the operation that was requested and not the one that executed. Populate it from the returned operation, as exporting ISO 19111 metadata for cadastral deliverables sets out.

A Delivery Checklist

The failure this guide addresses is not a hard one to fix once it is visible, so a short checklist run before a package leaves is worth more than any amount of framework. Does every data file have a CRS whose code names a realisation rather than an ensemble? Does the epoch appear somewhere a machine can read it, not only in a covering note? If a propagation was applied, is the velocity source and its version recorded, and is the propagation described as its own operation? Does the manifest list the metadata sidecar so it cannot be separated from the data? And has the sidecar been read back from disk and checked, rather than assumed to contain what was written?

Pre-delivery checks for a time-dependent deliverable Five ordered checks. Confirm the CRS names a realisation rather than an ensemble. Confirm the epoch is present somewhere machine-readable. Confirm the velocity source is recorded if a propagation was applied. Confirm the manifest lists the metadata sidecar. Finally read the sidecar back from disk rather than assuming it holds what was written. 1 Realisation not an ensemble 2 Epoch machine-readable 3 Velocity source + version 4 Manifest lists the sidecar 5 Read back verify from disk

Figure — five checks before a package leaves, in the order that catches most first.

Five questions, each answerable in a second, and between them they catch the whole class of “the epoch was computed correctly and lost at the boundary” failures that make an otherwise careful time-dependent workflow unreproducible.

Frequently Asked Questions

Where does the epoch go in a shapefile deliverable?

Not in the .prj, which is WKT1 and has no field for it. Ship a sidecar — a JSON or XML metadata file with the same basename — and name it in the delivery manifest so it cannot be separated from the data by accident. If the receiving agency has a prescribed metadata format, use theirs; the requirement is that the epoch arrives with the coordinates, not that it arrives in any particular container.

Should every feature carry its own epoch, or the dataset as a whole?

The dataset, in almost all cases: a deliverable is normally produced at a single reference epoch precisely so that its features are mutually consistent. Per-feature epochs are appropriate only when the dataset genuinely mixes observation times and has not been propagated to a common epoch — and that is worth avoiding, because it pushes the propagation onto every downstream user.

Does an epoch matter for a purely local project?

For internal geometry, no; for anything that leaves the site, yes. The moment coordinates are compared against national control, submitted to an agency, or re-surveyed years later, the epoch is what makes the comparison meaningful. Recording it costs one field and removes a question nobody will be able to answer later.

What if the source data has no epoch and cannot be traced?

Record that explicitly rather than inventing one. An unknown epoch is a real property of legacy data, and stating it lets a downstream user widen their uncertainty appropriately. Substituting a plausible value produces a deliverable that looks better documented than it is, which is the worse outcome — the same reasoning applied to unmapped CRS definitions in parsing WKT2:2019 CRS strings in Python.