Skip to content

Commit 8a705c3

Browse files
committed
Add time-dependent ITRF frame transforms (#1045)
14-parameter Helmert (7 static + 7 rates) for converting between ITRF frames at a given observation epoch. Parameters parsed from the PROJ data files shipped with pyproj. Available frames: ITRF2000, ITRF2008, ITRF2014, ITRF2020 (and all intermediate frames back to ITRF89). Usage: lon2, lat2, h2 = itrf_transform( -74.0, 40.7, 10.0, src='ITRF2014', tgt='ITRF2020', epoch=2024.0, ) Typical shifts: 2-4mm horizontal, 1-3mm vertical between ITRF2014 and ITRF2020 at epoch 2024. The rates capture tectonic plate motion (~mm/year) which accumulates over years. Numba JIT parallelized for batch transforms.
1 parent 5324c06 commit 8a705c3

File tree

2 files changed

+311
-0
lines changed

2 files changed

+311
-0
lines changed

xrspatial/reproject/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@
3636
depth_to_ellipsoidal,
3737
ellipsoidal_to_depth,
3838
)
39+
from ._itrf import itrf_transform, list_frames as itrf_frames
3940

4041
__all__ = [
4142
'reproject', 'merge',
4243
'geoid_height', 'geoid_height_raster',
4344
'ellipsoidal_to_orthometric', 'orthometric_to_ellipsoidal',
4445
'depth_to_ellipsoidal', 'ellipsoidal_to_depth',
46+
'itrf_transform', 'itrf_frames',
4547
]
4648

4749

xrspatial/reproject/_itrf.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
"""Time-dependent ITRF frame transformations.
2+
3+
Implements 14-parameter Helmert transforms (7 static + 7 rates)
4+
for converting between International Terrestrial Reference Frames.
5+
6+
The parameters are published by IGN France and shipped with PROJ.
7+
Shifts are mm-level for position and mm/year for rates -- relevant
8+
for precision geodesy, negligible for most raster reprojection.
9+
10+
Usage
11+
-----
12+
>>> from xrspatial.reproject import itrf_transform
13+
>>> lon2, lat2, h2 = itrf_transform(
14+
... -74.0, 40.7, 0.0,
15+
... src='ITRF2014', tgt='ITRF2020', epoch=2024.0,
16+
... )
17+
"""
18+
from __future__ import annotations
19+
20+
import math
21+
import os
22+
import re
23+
24+
import numpy as np
25+
from numba import njit, prange
26+
27+
# ---------------------------------------------------------------------------
28+
# Parse PROJ ITRF parameter files
29+
# ---------------------------------------------------------------------------
30+
31+
def _find_proj_data_dir():
32+
"""Locate the PROJ data directory."""
33+
try:
34+
import pyproj
35+
return pyproj.datadir.get_data_dir()
36+
except Exception:
37+
return None
38+
39+
40+
def _parse_itrf_file(path):
41+
"""Parse a PROJ ITRF parameter file.
42+
43+
Returns dict mapping target_frame -> parameter dict.
44+
"""
45+
transforms = {}
46+
with open(path) as f:
47+
for line in f:
48+
line = line.strip()
49+
if not line or line.startswith('#') or line.startswith('<metadata>'):
50+
continue
51+
# Format: <TARGET_FRAME> +proj=helmert +x=... +dx=... +t_epoch=...
52+
m = re.match(r'<(\w+)>\s+(.+)', line)
53+
if not m:
54+
continue
55+
target = m.group(1)
56+
params_str = m.group(2)
57+
params = {}
58+
for token in params_str.split():
59+
if '=' in token:
60+
key, val = token.lstrip('+').split('=', 1)
61+
try:
62+
params[key] = float(val)
63+
except ValueError:
64+
params[key] = val
65+
elif token.startswith('+'):
66+
params[token.lstrip('+')] = True
67+
transforms[target] = params
68+
return transforms
69+
70+
71+
def _load_all_itrf_params():
72+
"""Load all ITRF transformation parameters from PROJ data files.
73+
74+
Returns a nested dict: {source_frame: {target_frame: params}}.
75+
"""
76+
proj_dir = _find_proj_data_dir()
77+
if proj_dir is None:
78+
return {}
79+
80+
all_params = {}
81+
for filename in os.listdir(proj_dir):
82+
if not filename.startswith('ITRF'):
83+
continue
84+
source_frame = filename
85+
path = os.path.join(proj_dir, filename)
86+
if not os.path.isfile(path):
87+
continue
88+
transforms = _parse_itrf_file(path)
89+
all_params[source_frame] = transforms
90+
91+
return all_params
92+
93+
94+
# Lazy-loaded parameter cache
95+
_itrf_params = None
96+
97+
98+
def _get_itrf_params():
99+
global _itrf_params
100+
if _itrf_params is None:
101+
_itrf_params = _load_all_itrf_params()
102+
return _itrf_params
103+
104+
105+
def _find_transform(src, tgt):
106+
"""Find the 14-parameter Helmert from src to tgt frame.
107+
108+
Returns parameter dict or None. Tries direct lookup first,
109+
then reverse (with negated parameters).
110+
"""
111+
params = _get_itrf_params()
112+
113+
# Direct: src file contains entry for tgt
114+
if src in params and tgt in params[src]:
115+
return params[src][tgt], False
116+
117+
# Reverse: tgt file contains entry for src
118+
if tgt in params and src in params[tgt]:
119+
return params[tgt][src], True # need to negate
120+
121+
return None, False
122+
123+
124+
# ---------------------------------------------------------------------------
125+
# 14-parameter time-dependent Helmert (Numba JIT)
126+
# ---------------------------------------------------------------------------
127+
128+
@njit(nogil=True, cache=True)
129+
def _helmert14_point(X, Y, Z,
130+
tx, ty, tz, s, rx, ry, rz,
131+
dtx, dty, dtz, ds, drx, dry, drz,
132+
t_epoch, t_obs, position_vector):
133+
"""Apply 14-parameter Helmert transform to a single ECEF point.
134+
135+
Parameters are in metres (translations), ppb (scale), and
136+
arcseconds (rotations). Rates are per year.
137+
"""
138+
dt = t_obs - t_epoch
139+
140+
# Effective parameters at observation epoch
141+
tx_e = tx + dtx * dt
142+
ty_e = ty + dty * dt
143+
tz_e = tz + dtz * dt
144+
s_e = 1.0 + (s + ds * dt) * 1e-9 # ppb -> scale factor
145+
# Rotations: arcsec -> radians
146+
AS2RAD = math.pi / (180.0 * 3600.0)
147+
rx_e = (rx + drx * dt) * AS2RAD
148+
ry_e = (ry + dry * dt) * AS2RAD
149+
rz_e = (rz + drz * dt) * AS2RAD
150+
151+
if position_vector:
152+
# Position vector convention (IERS/IGN)
153+
X2 = tx_e + s_e * (X - rz_e * Y + ry_e * Z)
154+
Y2 = ty_e + s_e * (rz_e * X + Y - rx_e * Z)
155+
Z2 = tz_e + s_e * (-ry_e * X + rx_e * Y + Z)
156+
else:
157+
# Coordinate frame convention (transpose rotation)
158+
X2 = tx_e + s_e * (X + rz_e * Y - ry_e * Z)
159+
Y2 = ty_e + s_e * (-rz_e * X + Y + rx_e * Z)
160+
Z2 = tz_e + s_e * (ry_e * X - rx_e * Y + Z)
161+
162+
return X2, Y2, Z2
163+
164+
165+
@njit(nogil=True, cache=True)
166+
def _geodetic_to_ecef(lon_deg, lat_deg, h, a, f):
167+
lon = math.radians(lon_deg)
168+
lat = math.radians(lat_deg)
169+
e2 = 2.0 * f - f * f
170+
slat = math.sin(lat)
171+
clat = math.cos(lat)
172+
N = a / math.sqrt(1.0 - e2 * slat * slat)
173+
X = (N + h) * clat * math.cos(lon)
174+
Y = (N + h) * clat * math.sin(lon)
175+
Z = (N * (1.0 - e2) + h) * slat
176+
return X, Y, Z
177+
178+
179+
@njit(nogil=True, cache=True)
180+
def _ecef_to_geodetic(X, Y, Z, a, f):
181+
e2 = 2.0 * f - f * f
182+
lon = math.atan2(Y, X)
183+
p = math.sqrt(X * X + Y * Y)
184+
lat = math.atan2(Z, p * (1.0 - e2))
185+
for _ in range(10):
186+
slat = math.sin(lat)
187+
N = a / math.sqrt(1.0 - e2 * slat * slat)
188+
lat = math.atan2(Z + e2 * N * slat, p)
189+
N = a / math.sqrt(1.0 - e2 * math.sin(lat) * math.sin(lat))
190+
h = p / math.cos(lat) - N if abs(lat) < math.pi / 4 else Z / math.sin(lat) - N * (1 - e2)
191+
return math.degrees(lon), math.degrees(lat), h
192+
193+
194+
@njit(nogil=True, cache=True, parallel=True)
195+
def _itrf_batch(lon_arr, lat_arr, h_arr,
196+
out_lon, out_lat, out_h,
197+
tx, ty, tz, s, rx, ry, rz,
198+
dtx, dty, dtz, ds, drx, dry, drz,
199+
t_epoch, t_obs, position_vector,
200+
a, f):
201+
for i in prange(lon_arr.shape[0]):
202+
X, Y, Z = _geodetic_to_ecef(lon_arr[i], lat_arr[i], h_arr[i], a, f)
203+
X2, Y2, Z2 = _helmert14_point(
204+
X, Y, Z,
205+
tx, ty, tz, s, rx, ry, rz,
206+
dtx, dty, dtz, ds, drx, dry, drz,
207+
t_epoch, t_obs, position_vector,
208+
)
209+
out_lon[i], out_lat[i], out_h[i] = _ecef_to_geodetic(X2, Y2, Z2, a, f)
210+
211+
212+
# ---------------------------------------------------------------------------
213+
# Public API
214+
# ---------------------------------------------------------------------------
215+
216+
# WGS84 ellipsoid
217+
_A = 6378137.0
218+
_F = 1.0 / 298.257223563
219+
220+
221+
def list_frames():
222+
"""List available ITRF frames.
223+
224+
Returns
225+
-------
226+
list of str
227+
Available frame names (e.g. ['ITRF2000', 'ITRF2008', 'ITRF2014', 'ITRF2020']).
228+
"""
229+
return sorted(_get_itrf_params().keys())
230+
231+
232+
def itrf_transform(lon, lat, h=0.0, *, src, tgt, epoch):
233+
"""Transform coordinates between ITRF frames at a given epoch.
234+
235+
Parameters
236+
----------
237+
lon, lat : float or array-like
238+
Geographic coordinates in degrees.
239+
h : float or array-like
240+
Ellipsoidal height in metres (default 0).
241+
src : str
242+
Source ITRF frame (e.g. 'ITRF2014').
243+
tgt : str
244+
Target ITRF frame (e.g. 'ITRF2020').
245+
epoch : float
246+
Observation epoch as decimal year (e.g. 2024.0).
247+
248+
Returns
249+
-------
250+
(lon, lat, h) : tuple of float or ndarray
251+
Transformed coordinates.
252+
253+
Examples
254+
--------
255+
>>> itrf_transform(-74.0, 40.7, 10.0, src='ITRF2014', tgt='ITRF2020', epoch=2024.0)
256+
"""
257+
raw_params, is_reverse = _find_transform(src, tgt)
258+
if raw_params is None:
259+
raise ValueError(
260+
f"No transform found between {src} and {tgt}. "
261+
f"Available frames: {list_frames()}"
262+
)
263+
264+
# Extract parameters (default 0 for missing)
265+
def g(key):
266+
return raw_params.get(key, 0.0)
267+
268+
tx, ty, tz = g('x'), g('y'), g('z')
269+
s = g('s')
270+
rx, ry, rz = g('rx'), g('ry'), g('rz')
271+
dtx, dty, dtz = g('dx'), g('dy'), g('dz')
272+
ds = g('ds')
273+
drx, dry, drz = g('drx'), g('dry'), g('drz')
274+
t_epoch = g('t_epoch')
275+
convention = raw_params.get('convention', 'position_vector')
276+
position_vector = convention == 'position_vector'
277+
278+
if is_reverse:
279+
# Negate all parameters for the reverse direction
280+
tx, ty, tz = -tx, -ty, -tz
281+
s = -s
282+
rx, ry, rz = -rx, -ry, -rz
283+
dtx, dty, dtz = -dtx, -dty, -dtz
284+
ds = -ds
285+
drx, dry, drz = -drx, -dry, -drz
286+
287+
scalar = np.ndim(lon) == 0 and np.ndim(lat) == 0
288+
lon_arr = np.atleast_1d(np.asarray(lon, dtype=np.float64)).ravel()
289+
lat_arr = np.atleast_1d(np.asarray(lat, dtype=np.float64)).ravel()
290+
h_arr = np.broadcast_to(np.atleast_1d(np.asarray(h, dtype=np.float64)),
291+
lon_arr.shape).copy()
292+
293+
n = lon_arr.shape[0]
294+
out_lon = np.empty(n, dtype=np.float64)
295+
out_lat = np.empty(n, dtype=np.float64)
296+
out_h = np.empty(n, dtype=np.float64)
297+
298+
_itrf_batch(
299+
lon_arr, lat_arr, h_arr,
300+
out_lon, out_lat, out_h,
301+
tx, ty, tz, s, rx, ry, rz,
302+
dtx, dty, dtz, ds, drx, dry, drz,
303+
t_epoch, float(epoch), position_vector,
304+
_A, _F,
305+
)
306+
307+
if scalar:
308+
return float(out_lon[0]), float(out_lat[0]), float(out_h[0])
309+
return out_lon, out_lat, out_h

0 commit comments

Comments
 (0)