Skip to content

Commit 9a79981

Browse files
committed
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.
1 parent 443ed78 commit 9a79981

File tree

3 files changed

+74
-16
lines changed

3 files changed

+74
-16
lines changed

xrspatial/reproject/_projections.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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
@@ -1315,6 +1315,60 @@ def test_bounds_overlap(self):
13151315
assert not _bounds_overlap(a, (0, 11, 10, 20)) # no overlap y
13161316

13171317

1318+
class TestLongitudeNormalization:
1319+
"""CPU projection round-trips should keep longitude in [-180, 180] (#1088)."""
1320+
1321+
def test_sinusoidal_round_trip_stays_in_range(self):
1322+
"""Sinusoidal inverse must normalize longitude near antimeridian."""
1323+
from xrspatial.reproject._projections import (
1324+
_sinu_fwd_point, _sinu_inv_point, _MLFN_EN,
1325+
)
1326+
# Forward: WGS84 point near antimeridian
1327+
lon_in, lat_in = 179.5, 30.0
1328+
lon0 = 0.0 # central meridian at 0
1329+
x, y = _sinu_fwd_point(lon_in, lat_in, lon0, _WGS84_E2, _WGS84_A, _MLFN_EN)
1330+
# Inverse: should return longitude in [-180, 180]
1331+
lon_out, lat_out = _sinu_inv_point(x, y, lon0, _WGS84_E2, _WGS84_A, _MLFN_EN)
1332+
assert -180 <= lon_out <= 180, f"lon {lon_out} outside [-180, 180]"
1333+
assert abs(lon_out - lon_in) < 1e-6
1334+
assert abs(lat_out - lat_in) < 1e-6
1335+
1336+
def test_lcc_round_trip_stays_in_range(self):
1337+
"""LCC inverse must normalize longitude."""
1338+
from xrspatial.reproject._projections import (
1339+
_lcc_fwd_point, _lcc_inv_point, _WGS84_E, _WGS84_A,
1340+
)
1341+
import math
1342+
# EPSG:2154 (France): lon0=3, lat1=44, lat2=49
1343+
lon0 = math.radians(3.0)
1344+
lat1, lat2, lat0 = math.radians(44.0), math.radians(49.0), math.radians(46.5)
1345+
e = _WGS84_E
1346+
a = _WGS84_A
1347+
k0 = 1.0
1348+
# Compute n, c, rho0 for LCC
1349+
from xrspatial.reproject._projections import _pj_tsfn
1350+
s1, s2 = math.sin(lat1), math.sin(lat2)
1351+
ts1 = _pj_tsfn(lat1, s1, e)
1352+
ts2 = _pj_tsfn(lat2, s2, e)
1353+
m1 = math.cos(lat1) / math.sqrt(1.0 - e * e * s1 * s1)
1354+
m2 = math.cos(lat2) / math.sqrt(1.0 - e * e * s2 * s2)
1355+
n = (math.log(m1) - math.log(m2)) / (math.log(ts1) - math.log(ts2))
1356+
c = m1 / (n * math.pow(ts1, n))
1357+
ts0 = _pj_tsfn(lat0, math.sin(lat0), e)
1358+
rho0 = a * k0 * c * math.pow(ts0, n)
1359+
# Forward + inverse round trip
1360+
lon_in, lat_in = 2.5, 47.0
1361+
x, y = _lcc_fwd_point(lon_in, lat_in, lon0, n, c, rho0, k0, e, a)
1362+
lon_out, lat_out = _lcc_inv_point(x, y, lon0, n, c, rho0, k0, e, a)
1363+
assert -180 <= lon_out <= 180
1364+
assert abs(lon_out - lon_in) < 1e-6
1365+
assert abs(lat_out - lat_in) < 1e-6
1366+
1367+
1368+
_WGS84_E2 = 2.0 * (1.0 / 298.257223563) - (1.0 / 298.257223563) ** 2
1369+
_WGS84_A = 6378137.0
1370+
1371+
13181372
class TestReprojWithLiteCRS:
13191373
def test_reproject_wgs84_to_utm_with_lite_crs(self):
13201374
import xarray as xr

0 commit comments

Comments
 (0)