Position Vector vs Coordinate Frame Rotation Conventions
Two conventions describe the same seven-parameter transformation, they differ only in the sign of the three rotations, and using a parameter set under the wrong one produces an error of roughly twice the rotation effect — metres, for a typical global parameter set. This guide, part of Helmert 7-parameter transformations in Python, sets out the two conventions precisely, shows how to detect which one a parameter set belongs to, and gives the conversion that lets a single implementation consume both.
The Two Conventions
Both write the transformation as a translation, a scale and a small rotation:
Figure — one transformation, two conventions: the rotation matrices are transposes.
and both use the same small-angle rotation matrix built from three angles. The difference is what the angles rotate. In the position vector convention (EPSG method 9606) the rotations are applied to the position vector itself, so a positive
The two matrices are transposes, which for a small-angle rotation is the same as negating all three angles. Everything else — translations, scale, units — is identical, which is exactly why the mistake is so easy to make and so hard to see.
Complete Runnable Implementation
from __future__ import annotations
from enum import Enum
import numpy as np
MAS_TO_RAD = np.pi / (180.0 * 3600.0 * 1000.0)
class RotationConvention(Enum):
"""EPSG method 9606 (position vector) or 9607 (coordinate frame)."""
POSITION_VECTOR = "position_vector"
COORDINATE_FRAME = "coordinate_frame"
def rotation_matrix(rot_rad: np.ndarray,
convention: RotationConvention) -> np.ndarray:
"""Small-angle rotation matrix in the stated convention."""
rx, ry, rz = (float(v) for v in rot_rad)
pv = np.array([
[1.0, -rz, ry],
[rz, 1.0, -rx],
[-ry, rx, 1.0],
], dtype=np.float64)
return pv if convention is RotationConvention.POSITION_VECTOR else pv.T
def helmert(xyz: np.ndarray, t_m: np.ndarray, rot_mas: np.ndarray, scale_ppb: float,
convention: RotationConvention) -> np.ndarray:
"""Apply a 7-parameter transformation under an EXPLICIT convention.
The convention is a required argument on purpose: a default here is a decision
made silently, and it is the decision that goes wrong.
"""
if xyz.shape != (3,) or xyz.dtype != np.float64:
raise ValueError("expected a (3,) float64 Cartesian position in metres")
r = rotation_matrix(np.asarray(rot_mas, dtype=np.float64) * MAS_TO_RAD, convention)
return t_m + (1.0 + scale_ppb * 1e-9) * (r @ xyz)
def convert_convention(rot_mas: np.ndarray) -> np.ndarray:
"""Convert a rotation triple between the two conventions: negate all three."""
return -np.asarray(rot_mas, dtype=np.float64)
def detect_convention(xyz_source: np.ndarray, xyz_target: np.ndarray,
t_m: np.ndarray, rot_mas: np.ndarray,
scale_ppb: float) -> RotationConvention:
"""Decide which convention a parameter set belongs to, from one control pair.
Applies both and returns whichever lands closer to the published target. The
two differ by twice the rotation effect, so for any real parameter set the
answer is unambiguous — usually metres apart.
"""
errors = {}
for conv in RotationConvention:
got = helmert(xyz_source, t_m, rot_mas, scale_ppb, conv)
errors[conv] = float(np.linalg.norm(got - xyz_target))
best, worst = sorted(errors, key=errors.get)
if errors[worst] < 4.0 * max(errors[best], 1e-9):
raise ValueError(
f"cannot distinguish the conventions from this pair: residuals "
f"{errors[best]:.4f} m and {errors[worst]:.4f} m — the rotations may be "
f"too small, or the control pair may be wrong"
)
return best
Parameter Reference
| Name | Type | Units | Note |
|---|---|---|---|
t_m |
np.ndarray |
m | Identical in both conventions |
rot_mas |
np.ndarray |
mas | Sign differs between conventions |
scale_ppb |
float |
ppb | Identical in both conventions |
convention |
RotationConvention |
— | Required; no default is offered |
detect_convention |
— | — | Needs one independently known control pair |
Worked Example
import numpy as np
xyz = np.array([3_771_793.968, 140_253.342, 5_124_304.349]) # a European site
t = np.array([0.0, 0.0, 0.0])
rot = np.array([0.891, 5.390, -8.712]) # mas — a realistic magnitude
scale = 0.0
pv = helmert(xyz, t, rot, scale, RotationConvention.POSITION_VECTOR)
cf = helmert(xyz, t, rot, scale, RotationConvention.COORDINATE_FRAME)
print(f"{np.linalg.norm(pv - cf):.4f} m apart")
# 0.4113 m apart
Forty-one centimetres from a sign, with translations of zero and no scale — and both results look like perfectly ordinary coordinates. With a global parameter set carrying rotations of tens of milliarcseconds the separation runs to several metres.
Figure — the separation between the two conventions, by rotation magnitude.
Validation Check
def assert_convention_recorded(metadata: dict) -> None:
"""A parameter set without its convention is not usable."""
conv = metadata.get("rotation_convention")
assert conv in {c.value for c in RotationConvention}, (
"the parameter set records no rotation convention; it cannot be applied "
"without guessing, and the guess is worth up to several metres"
)
Common Mistakes
Copying rotations from a table without reading its convention. Published tables state the convention, usually in a header or a footnote that does not survive being copied into a configuration file. Carry the convention with the numbers in the same record, always.
Assuming a library’s default matches your source. Different tools default differently, and applying a coordinate-frame set through a position-vector implementation reverses the rotation. The fix is to make the convention an explicit argument, as above, so there is no default to be wrong about.
Testing only against a control point near the rotation axis. The two conventions agree exactly where the rotation has no effect, so a test point badly chosen can validate a wrong implementation. Test where the rotations bite — far from the axis — and use the detection function’s own guard, which refuses when the two candidates are too close to separate.
Recording the Convention With the Parameters
Because the two conventions are indistinguishable from the numbers alone, the convention has to be carried as data rather than as knowledge. Three places it belongs, in descending order of reliability.
Figure — three places the convention can live, in descending order of reliability.
In the operation identifier. A registered EPSG operation names its method — 9606 or 9607 — so recording the operation code records the convention exactly, with no room for transcription error. This is the best answer whenever the operation is registered.
In the parameter record itself. Where the parameters are local or unregistered, the structure that holds them should have a convention field with a constrained set of values, so that a set without one cannot be constructed. The enumeration in the implementation above exists for that reason.
In the deliverable’s metadata block. Even when the pipeline is internally consistent, a recipient who wants to reproduce a coordinate needs the convention, and it is not implied by anything else they receive.
What does not work is a comment in a configuration file, or a note in a project document. Both are separated from the numbers on the first copy-paste, and the resulting error is a metre or more with no diagnostic signature beyond a failed comparison against control.
Frequently Asked Questions
Which convention does EPSG use?
Both, as two distinct methods: 9606 is position vector and 9607 is coordinate frame, and a registered operation names which one it uses. That is the authoritative answer for any operation with an EPSG code, and it is another reason to record the operation identifier rather than only the seven numbers.
Is one convention more correct than the other?
No — they are two descriptions of the same physical relationship, and each is standard in a different community. Geodesy and the IERS conventions favour position vector; some national and photogrammetric practice favours coordinate frame. What matters is that the convention travels with the parameters.
Can I detect the convention from the parameters alone?
Not reliably. Nothing in the magnitudes distinguishes them, and the sign pattern is not a tell because either convention can produce any sign pattern. You need one independently known control pair, which is what the detection function above uses — and if you have no control pair, you have no way to check anything else about the parameter set either.
Does the convention affect the derived uncertainty?
Not its magnitude: the rotation covariance transforms the same way under either convention, so the propagated uncertainty is identical. It affects the correlations between the rotation and translation parameters by a sign, which matters if you are converting a published covariance rather than a published parameter set — a detail covered in propagating covariance through a Helmert transformation.
Does the convention matter for a three-parameter transformation?
No — with no rotations there is nothing to disagree about, and the two conventions coincide exactly. That is worth knowing because it explains why a pipeline can work for years on translation-only shifts and break the first time somebody supplies a seven-parameter set: the code never had a convention, and the default it acquired was whichever the implementer assumed.
The summary, if only one line survives: the two conventions differ by the sign of all three rotations, the difference is metres at the Earth’s surface, and nothing in the numbers reveals which one a parameter set belongs to.