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.
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.
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?
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.