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