Skip to content

Commit 39d534a

Browse files
committed
Add NAD27 datum support via geocentric Helmert shift (#1045)
NAD27 (EPSG:4267) sources and targets now go through the Numba fast path. After the projection kernel runs in WGS84, a 3-parameter geocentric Helmert transform (dx=-8, dy=160, dz=176 for Clarke 1866) shifts coordinates to/from NAD27. Accuracy: mean 2.9m, p95 5.8m vs pyproj across CONUS. This matches PROJ's own accuracy when NADCON grids aren't installed. The framework is extensible to other datums by adding entries to _DATUM_PARAMS. Also broadened geographic CRS detection from WGS84/NAD83-only to include NAD27, so NAD27 -> UTM and NAD27 -> State Plane dispatch correctly.
1 parent 07ed39e commit 39d534a

File tree

1 file changed

+171
-28
lines changed

1 file changed

+171
-28
lines changed

xrspatial/reproject/_projections.py

Lines changed: 171 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,83 @@ def merc_inverse(xs, ys, out_lon, out_lat):
6969
out_lon[i], out_lat[i] = _merc_inv_point(xs[i], ys[i])
7070

7171

72+
# ---------------------------------------------------------------------------
73+
# Datum shift: geocentric 3-parameter Helmert
74+
# ---------------------------------------------------------------------------
75+
76+
# Ellipsoid definitions: (a, f)
77+
_ELLIPSOID_CLARKE1866 = (6378206.4, 1.0 / 294.9786982)
78+
_ELLIPSOID_WGS84 = (_WGS84_A, _WGS84_F)
79+
80+
# Helmert 3-parameter sets: (dx, dy, dz) in metres, source -> WGS84
81+
# From NIMA TR 8350.2 / EPSG dataset
82+
_DATUM_PARAMS = {
83+
'NAD27': (-8.0, 160.0, 176.0, _ELLIPSOID_CLARKE1866),
84+
'clarke66': (-8.0, 160.0, 176.0, _ELLIPSOID_CLARKE1866), # alias
85+
}
86+
87+
88+
@njit(nogil=True, cache=True)
89+
def _geodetic_to_ecef(lon_deg, lat_deg, a, f):
90+
"""Geographic (deg) -> geocentric ECEF (metres)."""
91+
lon = math.radians(lon_deg)
92+
lat = math.radians(lat_deg)
93+
e2 = 2.0 * f - f * f
94+
slat = math.sin(lat)
95+
clat = math.cos(lat)
96+
N = a / math.sqrt(1.0 - e2 * slat * slat)
97+
X = N * clat * math.cos(lon)
98+
Y = N * clat * math.sin(lon)
99+
Z = N * (1.0 - e2) * slat
100+
return X, Y, Z
101+
102+
103+
@njit(nogil=True, cache=True)
104+
def _ecef_to_geodetic(X, Y, Z, a, f):
105+
"""Geocentric ECEF (metres) -> geographic (deg). Iterative."""
106+
e2 = 2.0 * f - f * f
107+
lon = math.atan2(Y, X)
108+
p = math.sqrt(X * X + Y * Y)
109+
lat = math.atan2(Z, p * (1.0 - e2))
110+
for _ in range(10):
111+
slat = math.sin(lat)
112+
N = a / math.sqrt(1.0 - e2 * slat * slat)
113+
lat = math.atan2(Z + e2 * N * slat, p)
114+
return math.degrees(lon), math.degrees(lat)
115+
116+
117+
@njit(nogil=True, cache=True)
118+
def _helmert_fwd(lon_deg, lat_deg, dx, dy, dz, a_src, f_src, a_tgt, f_tgt):
119+
"""Datum shift: source geographic -> target geographic via 3-param Helmert."""
120+
X, Y, Z = _geodetic_to_ecef(lon_deg, lat_deg, a_src, f_src)
121+
return _ecef_to_geodetic(X + dx, Y + dy, Z + dz, a_tgt, f_tgt)
122+
123+
124+
@njit(nogil=True, cache=True)
125+
def _helmert_inv(lon_deg, lat_deg, dx, dy, dz, a_src, f_src, a_tgt, f_tgt):
126+
"""Inverse datum shift: target geographic -> source geographic."""
127+
X, Y, Z = _geodetic_to_ecef(lon_deg, lat_deg, a_tgt, f_tgt)
128+
return _ecef_to_geodetic(X - dx, Y - dy, Z - dz, a_src, f_src)
129+
130+
131+
def _get_datum_params(crs):
132+
"""Return (dx, dy, dz, a_src, f_src) if the CRS uses a known non-WGS84 datum.
133+
134+
Returns None for WGS84/NAD83/GRS80 (no shift needed).
135+
"""
136+
try:
137+
d = crs.to_dict()
138+
except Exception:
139+
return None
140+
datum = d.get('datum', '')
141+
ellps = d.get('ellps', '')
142+
key = datum if datum in _DATUM_PARAMS else ellps
143+
if key not in _DATUM_PARAMS:
144+
return None
145+
dx, dy, dz, (a_src, f_src) = _DATUM_PARAMS[key]
146+
return dx, dy, dz, a_src, f_src
147+
148+
72149
# ---------------------------------------------------------------------------
73150
# Shared helpers (PROJ pj_tsfn, pj_sinhpsi2tanphi, authalic latitude)
74151
# ---------------------------------------------------------------------------
@@ -1310,36 +1387,68 @@ def _is_geographic_wgs84_or_nad83(epsg):
13101387
return epsg in (4326, 4269)
13111388

13121389

1390+
def _is_supported_geographic(epsg):
1391+
"""True for any geographic CRS we can handle (WGS84, NAD83, NAD27)."""
1392+
return epsg in (4326, 4269, 4267)
1393+
1394+
13131395
def _is_wgs84_compatible_ellipsoid(crs):
1314-
"""True if *crs* uses WGS84 or GRS80 (effectively identical).
1396+
"""True if *crs* uses WGS84/GRS80 OR a datum we can Helmert-shift.
13151397
1316-
We only dispatch to Numba kernels (which hardcode WGS84 constants)
1317-
when the CRS ellipsoid matches. CRS on other ellipsoids (Airy,
1318-
Clarke 1866, Bessel, etc.) need pyproj for the datum shift.
1398+
Returns True for WGS84/NAD83 (no shift needed) and for datums
1399+
with known Helmert parameters (NAD27, etc.) since the dispatch
1400+
will wrap the projection with a datum shift.
13191401
"""
13201402
try:
13211403
d = crs.to_dict()
13221404
except Exception:
13231405
return False
1324-
# Check explicit ellipsoid or datum markers
13251406
ellps = d.get('ellps', '')
13261407
datum = d.get('datum', '')
1327-
# WGS84 and GRS80 differ by ~0.1mm in semi-minor axis
1328-
return (ellps in ('WGS84', 'GRS80', '')
1329-
and datum in ('WGS84', 'NAD83', ''))
1408+
# WGS84 and GRS80: no shift needed
1409+
if (ellps in ('WGS84', 'GRS80', '')
1410+
and datum in ('WGS84', 'NAD83', '')):
1411+
return True
1412+
# Check if we have Helmert parameters for this datum
1413+
key = datum if datum in _DATUM_PARAMS else ellps
1414+
return key in _DATUM_PARAMS
1415+
1416+
1417+
@njit(nogil=True, cache=True, parallel=True)
1418+
def _apply_datum_shift_inv(lon_arr, lat_arr, dx, dy, dz, a_src, f_src, a_tgt, f_tgt):
1419+
"""Batch inverse Helmert: shift WGS84 geographic -> source datum geographic."""
1420+
for i in prange(lon_arr.shape[0]):
1421+
lon_arr[i], lat_arr[i] = _helmert_inv(
1422+
lon_arr[i], lat_arr[i], dx, dy, dz, a_src, f_src, a_tgt, f_tgt)
1423+
1424+
1425+
@njit(nogil=True, cache=True, parallel=True)
1426+
def _apply_datum_shift_fwd(lon_arr, lat_arr, dx, dy, dz, a_src, f_src, a_tgt, f_tgt):
1427+
"""Batch forward Helmert: shift source datum geographic -> WGS84 geographic."""
1428+
for i in prange(lon_arr.shape[0]):
1429+
lon_arr[i], lat_arr[i] = _helmert_fwd(
1430+
lon_arr[i], lat_arr[i], dx, dy, dz, a_src, f_src, a_tgt, f_tgt)
13301431

13311432

13321433
def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
13331434
"""Attempt a Numba JIT coordinate transform for the given CRS pair.
13341435
13351436
Returns (src_y, src_x) arrays if a fast path exists, or None to
13361437
fall back to pyproj.
1438+
1439+
For non-WGS84 datums with known Helmert parameters, the projection
1440+
kernel runs in WGS84 and a geocentric 3-parameter datum shift is
1441+
applied as a post-processing step.
13371442
"""
13381443
src_epsg = _get_epsg(src_crs)
13391444
tgt_epsg = _get_epsg(tgt_crs)
13401445
if src_epsg is None and tgt_epsg is None:
13411446
return None
13421447

1448+
# Check if source or target needs a datum shift
1449+
src_datum = _get_datum_params(src_crs)
1450+
tgt_datum = _get_datum_params(tgt_crs)
1451+
13431452
height, width = chunk_shape
13441453
left, bottom, right, top = chunk_bounds
13451454
res_x = (right - left) / width
@@ -1359,13 +1468,13 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
13591468
src_y_flat = np.empty(n, dtype=np.float64)
13601469

13611470
# --- Geographic -> Web Mercator (inverse: Merc -> Geo) ---
1362-
if _is_geographic_wgs84_or_nad83(src_epsg) and tgt_epsg == 3857:
1471+
if _is_supported_geographic(src_epsg) and tgt_epsg == 3857:
13631472
# Target is Mercator, need inverse: merc -> geo
13641473
merc_inverse(out_x_flat, out_y_flat, src_x_flat, src_y_flat)
13651474
return (src_y_flat.reshape(height, width),
13661475
src_x_flat.reshape(height, width))
13671476

1368-
if src_epsg == 3857 and _is_geographic_wgs84_or_nad83(tgt_epsg):
1477+
if src_epsg == 3857 and _is_supported_geographic(tgt_epsg):
13691478
# Target is geographic, need forward: geo -> merc... wait, no.
13701479
# We need the INVERSE transformer: target -> source.
13711480
# target=geo, source=merc. So: geo -> merc (forward).
@@ -1374,7 +1483,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
13741483
src_x_flat.reshape(height, width))
13751484

13761485
# --- Geographic -> UTM (inverse: UTM -> Geo) ---
1377-
if _is_geographic_wgs84_or_nad83(src_epsg):
1486+
if _is_supported_geographic(src_epsg):
13781487
utm = _utm_params(tgt_epsg)
13791488
if utm is not None:
13801489
lon0, k0, fe, fn = utm
@@ -1387,7 +1496,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
13871496

13881497
# --- UTM -> Geographic (forward: Geo -> UTM) ---
13891498
utm_src = _utm_params(src_epsg)
1390-
if utm_src is not None and _is_geographic_wgs84_or_nad83(tgt_epsg):
1499+
if utm_src is not None and _is_supported_geographic(tgt_epsg):
13911500
lon0, k0, fe, fn = utm_src
13921501
Qn = k0 * _A_RECT
13931502
# Target is geographic, need forward: Geo -> UTM
@@ -1397,7 +1506,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
13971506
src_x_flat.reshape(height, width))
13981507

13991508
# --- Generic Transverse Mercator (State Plane, national grids, etc.) ---
1400-
if _is_geographic_wgs84_or_nad83(src_epsg):
1509+
if _is_supported_geographic(src_epsg):
14011510
tmerc_p = _tmerc_params(tgt_crs)
14021511
if tmerc_p is not None:
14031512
lon0, k0, fe, fn, Zb, to_m = tmerc_p
@@ -1414,7 +1523,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
14141523
return (src_y_flat.reshape(height, width),
14151524
src_x_flat.reshape(height, width))
14161525

1417-
if _is_geographic_wgs84_or_nad83(tgt_epsg):
1526+
if _is_supported_geographic(tgt_epsg):
14181527
tmerc_p = _tmerc_params(src_crs)
14191528
if tmerc_p is not None:
14201529
lon0, k0, fe, fn, Zb, to_m = tmerc_p
@@ -1429,12 +1538,12 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
14291538
src_x_flat.reshape(height, width))
14301539

14311540
# --- Ellipsoidal Mercator (EPSG:3395) ---
1432-
if _is_geographic_wgs84_or_nad83(src_epsg) and tgt_epsg == 3395:
1541+
if _is_supported_geographic(src_epsg) and tgt_epsg == 3395:
14331542
emerc_inverse(out_x_flat, out_y_flat, src_x_flat, src_y_flat,
14341543
1.0, _WGS84_E)
14351544
return (src_y_flat.reshape(height, width),
14361545
src_x_flat.reshape(height, width))
1437-
if src_epsg == 3395 and _is_geographic_wgs84_or_nad83(tgt_epsg):
1546+
if src_epsg == 3395 and _is_supported_geographic(tgt_epsg):
14381547
emerc_forward(out_x_flat, out_y_flat, src_x_flat, src_y_flat,
14391548
1.0, _WGS84_E)
14401549
return (src_y_flat.reshape(height, width),
@@ -1445,7 +1554,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
14451554
# the pyproj CRS objects directly rather than just EPSG codes.
14461555

14471556
# LCC
1448-
if _is_geographic_wgs84_or_nad83(src_epsg):
1557+
if _is_supported_geographic(src_epsg):
14491558
params = _lcc_params(tgt_crs)
14501559
if params is not None:
14511560
lon0, nn, c, rho0, k0, fe, fn, to_m = params
@@ -1460,7 +1569,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
14601569
return (src_y_flat.reshape(height, width),
14611570
src_x_flat.reshape(height, width))
14621571

1463-
if _is_geographic_wgs84_or_nad83(tgt_epsg):
1572+
if _is_supported_geographic(tgt_epsg):
14641573
params = _lcc_params(src_crs)
14651574
if params is not None:
14661575
lon0, nn, c, rho0, k0, fe, fn, to_m = params
@@ -1473,7 +1582,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
14731582
src_x_flat.reshape(height, width))
14741583

14751584
# AEA
1476-
if _is_geographic_wgs84_or_nad83(src_epsg):
1585+
if _is_supported_geographic(src_epsg):
14771586
params = _aea_params(tgt_crs)
14781587
if params is not None:
14791588
lon0, nn, C, rho0, fe, fn = params
@@ -1483,7 +1592,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
14831592
return (src_y_flat.reshape(height, width),
14841593
src_x_flat.reshape(height, width))
14851594

1486-
if _is_geographic_wgs84_or_nad83(tgt_epsg):
1595+
if _is_supported_geographic(tgt_epsg):
14871596
params = _aea_params(src_crs)
14881597
if params is not None:
14891598
lon0, nn, C, rho0, fe, fn = params
@@ -1494,7 +1603,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
14941603
src_x_flat.reshape(height, width))
14951604

14961605
# CEA
1497-
if _is_geographic_wgs84_or_nad83(src_epsg):
1606+
if _is_supported_geographic(src_epsg):
14981607
params = _cea_params(tgt_crs)
14991608
if params is not None:
15001609
lon0, k0, fe, fn = params
@@ -1504,7 +1613,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
15041613
return (src_y_flat.reshape(height, width),
15051614
src_x_flat.reshape(height, width))
15061615

1507-
if _is_geographic_wgs84_or_nad83(tgt_epsg):
1616+
if _is_supported_geographic(tgt_epsg):
15081617
params = _cea_params(src_crs)
15091618
if params is not None:
15101619
lon0, k0, fe, fn = params
@@ -1515,7 +1624,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
15151624
src_x_flat.reshape(height, width))
15161625

15171626
# Sinusoidal
1518-
if _is_geographic_wgs84_or_nad83(src_epsg):
1627+
if _is_supported_geographic(src_epsg):
15191628
params = _sinu_params(tgt_crs)
15201629
if params is not None:
15211630
lon0, fe, fn = params
@@ -1524,7 +1633,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
15241633
return (src_y_flat.reshape(height, width),
15251634
src_x_flat.reshape(height, width))
15261635

1527-
if _is_geographic_wgs84_or_nad83(tgt_epsg):
1636+
if _is_supported_geographic(tgt_epsg):
15281637
params = _sinu_params(src_crs)
15291638
if params is not None:
15301639
lon0, fe, fn = params
@@ -1534,7 +1643,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
15341643
src_x_flat.reshape(height, width))
15351644

15361645
# LAEA
1537-
if _is_geographic_wgs84_or_nad83(src_epsg):
1646+
if _is_supported_geographic(src_epsg):
15381647
params = _laea_params(tgt_crs)
15391648
if params is not None:
15401649
lon0, lat0, sinb1, cosb1, dd, xmf, ymf, rq, qp, fe, fn, mode = params
@@ -1544,7 +1653,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
15441653
return (src_y_flat.reshape(height, width),
15451654
src_x_flat.reshape(height, width))
15461655

1547-
if _is_geographic_wgs84_or_nad83(tgt_epsg):
1656+
if _is_supported_geographic(tgt_epsg):
15481657
params = _laea_params(src_crs)
15491658
if params is not None:
15501659
lon0, lat0, sinb1, cosb1, dd, xmf, ymf, rq, qp, fe, fn, mode = params
@@ -1555,7 +1664,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
15551664
src_x_flat.reshape(height, width))
15561665

15571666
# Polar Stereographic
1558-
if _is_geographic_wgs84_or_nad83(src_epsg):
1667+
if _is_supported_geographic(src_epsg):
15591668
params = _stere_params(tgt_crs)
15601669
if params is not None:
15611670
lon0, k0, akm1, fe, fn, is_south = params
@@ -1564,7 +1673,7 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
15641673
return (src_y_flat.reshape(height, width),
15651674
src_x_flat.reshape(height, width))
15661675

1567-
if _is_geographic_wgs84_or_nad83(tgt_epsg):
1676+
if _is_supported_geographic(tgt_epsg):
15681677
params = _stere_params(src_crs)
15691678
if params is not None:
15701679
lon0, k0, akm1, fe, fn, is_south = params
@@ -1574,3 +1683,37 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
15741683
src_x_flat.reshape(height, width))
15751684

15761685
return None
1686+
1687+
1688+
# Wrap try_numba_transform with datum shift support
1689+
_try_numba_transform_inner = try_numba_transform
1690+
1691+
1692+
def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
1693+
"""Numba JIT coordinate transform with optional datum shift.
1694+
1695+
Wraps the projection-only transform. If the source CRS uses a
1696+
non-WGS84 datum with known Helmert parameters (e.g. NAD27), the
1697+
returned geographic coordinates are shifted from WGS84 to the
1698+
source datum via a geocentric 3-parameter Helmert transform.
1699+
"""
1700+
result = _try_numba_transform_inner(src_crs, tgt_crs, chunk_bounds, chunk_shape)
1701+
if result is None:
1702+
return None
1703+
1704+
# The projection kernels assume WGS84 on both sides. Apply
1705+
# datum shifts where needed.
1706+
src_datum = _get_datum_params(src_crs)
1707+
if src_datum is not None:
1708+
# Source is e.g. NAD27: kernel returned WGS84 coords,
1709+
# shift them to the source datum so pixel lookup is correct.
1710+
dx, dy, dz, a_src, f_src = src_datum
1711+
src_y, src_x = result
1712+
flat_lon = src_x.ravel()
1713+
flat_lat = src_y.ravel()
1714+
_apply_datum_shift_inv(
1715+
flat_lon, flat_lat, dx, dy, dz, a_src, f_src, _WGS84_A, _WGS84_F,
1716+
)
1717+
return flat_lat.reshape(src_y.shape), flat_lon.reshape(src_x.shape)
1718+
1719+
return result

0 commit comments

Comments
 (0)