@@ -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+
13131395def _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
13321433def 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