Skip to content

Commit e00a52a

Browse files
authored
Add longitude normalization to CUDA inverse projection kernels (#1089)
* Add longitude normalization to CUDA inverse projection kernels (#1088) Six CUDA inverse projection kernels returned raw `lam + lon0` without wrapping to [-pi, pi]. The CPU Numba kernels all pass through `_norm_lon_rad()` before converting to degrees. Without normalization, coordinates far from the central meridian (e.g., near the antimeridian) can produce longitude values outside [-180, 180]. Added `_d_norm_lon_rad` device function and applied it to: - LCC inverse (2 return paths) - AEA inverse - CEA inverse - Sinusoidal inverse - LAEA inverse - Polar Stereographic inverse Also removed dead code in `_tmerc_params()` where a loop computed a value that was immediately overwritten. * Address review: normalize CPU LCC early-return path, move constants (#1088) - Add _norm_lon_rad to CPU _lcc_inv_point early-return (rho < 1e-30) for consistency with the CUDA fix - Move _WGS84_E2/_WGS84_A constants to top of test file
1 parent a7c2399 commit e00a52a

File tree

3 files changed

+75
-17
lines changed

3 files changed

+75
-17
lines changed

xrspatial/reproject/_projections.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ def _lcc_inv_point(x, y, lon0, n, c, rho0, k0, e, a):
417417
rho = math.hypot(x, rho0_y)
418418
lam_n = math.atan2(x, rho0_y)
419419
if abs(rho) < 1e-30:
420-
return math.degrees(lon0 + lam_n / n), 90.0 if n > 0 else -90.0
420+
return math.degrees(_norm_lon_rad(lon0 + lam_n / n)), 90.0 if n > 0 else -90.0
421421
ts = math.pow(rho / (a * k0 * c), 1.0 / n)
422422
# Recover phi from ts via Newton (pj_sinhpsi2tanphi)
423423
phi_approx = math.pi / 2.0 - 2.0 * math.atan(ts)
@@ -1739,17 +1739,12 @@ def _tmerc_params(crs):
17391739
else:
17401740
# Conformal latitude of origin
17411741
Z = lat_0 + _clenshaw_sin_py(_CBG, lat_0)
1742-
# Forward Krueger correction at Ce=0 (central meridian)
1742+
# Forward Krueger correction at Ce=0 (central meridian):
1743+
# sin2=sin(2Z), cos2=cos(2Z), sinh2=0, cosh2=1
17431744
sin2Z = math.sin(2.0 * Z)
17441745
cos2Z = math.cos(2.0 * Z)
1745-
dCn = 0.0
1746-
for k in range(5, -1, -1):
1747-
dCn = cos2Z * dCn + _ALPHA[k] * sin2Z
1748-
# This is a simplified Clenshaw for Ce=0 (sinh=0, cosh=1)
1749-
# Actually, use the proper complex Clenshaw with Ce=0:
1750-
# sin2=sin(2Z), cos2=cos(2Z), sinh2=0, cosh2=1
1751-
dCn_val = _clenshaw_complex_py(_ALPHA, sin2Z, cos2Z, 0.0, 1.0)
1752-
Zb = -Qn * (Z + dCn_val)
1746+
dCn = _clenshaw_complex_py(_ALPHA, sin2Z, cos2Z, 0.0, 1.0)
1747+
Zb = -Qn * (Z + dCn)
17531748

17541749
return lon_0, k0, fe, fn, Zb, to_meter
17551750

xrspatial/reproject/_projections_cuda.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ def _d_clenshaw_complex(a0, a1, a2, a3, a4, a5,
9090
dCe = sin2Cn * cosh2Ce * hi + cos2Cn * sinh2Ce * hr
9191
return dCn, dCe
9292

93+
@cuda.jit(device=True)
94+
def _d_norm_lon_rad(lon):
95+
"""Normalize longitude to [-pi, pi]."""
96+
while lon > math.pi:
97+
lon -= 2.0 * math.pi
98+
while lon < -math.pi:
99+
lon += 2.0 * math.pi
100+
return lon
101+
93102
# -----------------------------------------------------------------
94103
# Web Mercator (EPSG:3857) -- spherical
95104
# -----------------------------------------------------------------
@@ -291,11 +300,11 @@ def _d_lcc_inv(x, y, lon0, n, c, rho0, k0, e, a):
291300
lam_n = math.atan2(x, rho0_y)
292301
if abs(rho) < 1e-30:
293302
lat = 90.0 if n > 0 else -90.0
294-
return math.degrees(lon0 + lam_n / n), lat
303+
return math.degrees(_d_norm_lon_rad(lon0 + lam_n / n)), lat
295304
ts = math.pow(rho / (a * k0 * c), 1.0 / n)
296305
taup = math.sinh(math.log(1.0 / ts))
297306
tau = _d_pj_sinhpsi2tanphi(taup, e)
298-
return math.degrees(lam_n / n + lon0), math.degrees(math.atan(tau))
307+
return math.degrees(_d_norm_lon_rad(lam_n / n + lon0)), math.degrees(math.atan(tau))
299308

300309
@cuda.jit
301310
def _k_lcc_inverse(out_src_x, out_src_y,
@@ -355,7 +364,7 @@ def _d_aea_inv(x, y, lon0, n, C, rho0, e, a, qp,
355364
ratio = -1.0
356365
beta = math.asin(ratio)
357366
phi = _d_authalic_inv(beta, apa0, apa1, apa2, apa3, apa4)
358-
return math.degrees(theta / n + lon0), math.degrees(phi)
367+
return math.degrees(_d_norm_lon_rad(theta / n + lon0)), math.degrees(phi)
359368

360369
@cuda.jit
361370
def _k_aea_inverse(out_src_x, out_src_y,
@@ -404,7 +413,7 @@ def _d_cea_inv(x, y, lon0, k0, e, a, qp, apa0, apa1, apa2, apa3, apa4):
404413
ratio = -1.0
405414
beta = math.asin(ratio)
406415
phi = _d_authalic_inv(beta, apa0, apa1, apa2, apa3, apa4)
407-
return math.degrees(lam + lon0), math.degrees(phi)
416+
return math.degrees(_d_norm_lon_rad(lam + lon0)), math.degrees(phi)
408417

409418
@cuda.jit
410419
def _k_cea_inverse(out_src_x, out_src_y,
@@ -476,7 +485,7 @@ def _d_sinu_inv(x, y, lon0, e2, a, en0, en1, en2, en3, en4):
476485
lam = 0.0
477486
else:
478487
lam = x * math.sqrt(1.0 - e2 * s * s) / (a * c)
479-
return math.degrees(lam + lon0), math.degrees(phi)
488+
return math.degrees(_d_norm_lon_rad(lam + lon0)), math.degrees(phi)
480489

481490
@cuda.jit
482491
def _k_sinu_inverse(out_src_x, out_src_y,
@@ -594,7 +603,7 @@ def _d_laea_inv(x, y, lon0, sinb1, cosb1,
594603
ratio = -1.0
595604
beta = math.asin(ratio)
596605
phi = _d_authalic_inv(beta, apa0, apa1, apa2, apa3, apa4)
597-
return math.degrees(lam + lon0), math.degrees(phi)
606+
return math.degrees(_d_norm_lon_rad(lam + lon0)), math.degrees(phi)
598607

599608
@cuda.jit
600609
def _k_laea_inverse(out_src_x, out_src_y,
@@ -670,7 +679,7 @@ def _d_stere_inv(x, y, lon0, akm1, e, is_south):
670679
phi = phi_new
671680
if is_south:
672681
phi = -phi
673-
return math.degrees(lam + lon0), math.degrees(phi)
682+
return math.degrees(_d_norm_lon_rad(lam + lon0)), math.degrees(phi)
674683

675684
@cuda.jit
676685
def _k_stere_inverse(out_src_x, out_src_y,

xrspatial/tests/test_reproject.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
not HAS_PYPROJ, reason="pyproj required for reproject tests"
2828
)
2929

30+
# WGS84 constants for projection round-trip tests
31+
_WGS84_E2 = 2.0 * (1.0 / 298.257223563) - (1.0 / 298.257223563) ** 2
32+
_WGS84_A = 6378137.0
33+
3034

3135
# ---------------------------------------------------------------------------
3236
# Helpers
@@ -1387,6 +1391,56 @@ def test_bounds_overlap(self):
13871391
assert not _bounds_overlap(a, (0, 11, 10, 20)) # no overlap y
13881392

13891393

1394+
class TestLongitudeNormalization:
1395+
"""CPU projection round-trips should keep longitude in [-180, 180] (#1088)."""
1396+
1397+
def test_sinusoidal_round_trip_stays_in_range(self):
1398+
"""Sinusoidal inverse must normalize longitude near antimeridian."""
1399+
from xrspatial.reproject._projections import (
1400+
_sinu_fwd_point, _sinu_inv_point, _MLFN_EN,
1401+
)
1402+
# Forward: WGS84 point near antimeridian
1403+
lon_in, lat_in = 179.5, 30.0
1404+
lon0 = 0.0 # central meridian at 0
1405+
x, y = _sinu_fwd_point(lon_in, lat_in, lon0, _WGS84_E2, _WGS84_A, _MLFN_EN)
1406+
# Inverse: should return longitude in [-180, 180]
1407+
lon_out, lat_out = _sinu_inv_point(x, y, lon0, _WGS84_E2, _WGS84_A, _MLFN_EN)
1408+
assert -180 <= lon_out <= 180, f"lon {lon_out} outside [-180, 180]"
1409+
assert abs(lon_out - lon_in) < 1e-6
1410+
assert abs(lat_out - lat_in) < 1e-6
1411+
1412+
def test_lcc_round_trip_stays_in_range(self):
1413+
"""LCC inverse must normalize longitude."""
1414+
from xrspatial.reproject._projections import (
1415+
_lcc_fwd_point, _lcc_inv_point, _WGS84_E, _WGS84_A,
1416+
)
1417+
import math
1418+
# EPSG:2154 (France): lon0=3, lat1=44, lat2=49
1419+
lon0 = math.radians(3.0)
1420+
lat1, lat2, lat0 = math.radians(44.0), math.radians(49.0), math.radians(46.5)
1421+
e = _WGS84_E
1422+
a = _WGS84_A
1423+
k0 = 1.0
1424+
# Compute n, c, rho0 for LCC
1425+
from xrspatial.reproject._projections import _pj_tsfn
1426+
s1, s2 = math.sin(lat1), math.sin(lat2)
1427+
ts1 = _pj_tsfn(lat1, s1, e)
1428+
ts2 = _pj_tsfn(lat2, s2, e)
1429+
m1 = math.cos(lat1) / math.sqrt(1.0 - e * e * s1 * s1)
1430+
m2 = math.cos(lat2) / math.sqrt(1.0 - e * e * s2 * s2)
1431+
n = (math.log(m1) - math.log(m2)) / (math.log(ts1) - math.log(ts2))
1432+
c = m1 / (n * math.pow(ts1, n))
1433+
ts0 = _pj_tsfn(lat0, math.sin(lat0), e)
1434+
rho0 = a * k0 * c * math.pow(ts0, n)
1435+
# Forward + inverse round trip
1436+
lon_in, lat_in = 2.5, 47.0
1437+
x, y = _lcc_fwd_point(lon_in, lat_in, lon0, n, c, rho0, k0, e, a)
1438+
lon_out, lat_out = _lcc_inv_point(x, y, lon0, n, c, rho0, k0, e, a)
1439+
assert -180 <= lon_out <= 180
1440+
assert abs(lon_out - lon_in) < 1e-6
1441+
assert abs(lat_out - lat_in) < 1e-6
1442+
1443+
13901444
class TestReprojWithLiteCRS:
13911445
def test_reproject_wgs84_to_utm_with_lite_crs(self):
13921446
import xarray as xr

0 commit comments

Comments
 (0)