diff --git a/xrspatial/reproject/_crs_utils.py b/xrspatial/reproject/_crs_utils.py index c4ebb511..3020d072 100644 --- a/xrspatial/reproject/_crs_utils.py +++ b/xrspatial/reproject/_crs_utils.py @@ -5,6 +5,8 @@ """ from __future__ import annotations +import numpy as np + from xrspatial.reproject._lite_crs import CRS as LiteCRS @@ -111,9 +113,18 @@ def _detect_source_crs(raster): def _detect_nodata(raster, nodata=None): - """Determine nodata value from explicit arg, rioxarray, or attrs.""" + """Determine nodata value from explicit arg, rioxarray, or attrs. + + NaN is the canonical sentinel for missing data and is allowed. + +/-Inf would break downstream `np.isnan` masks, so it is rejected. + """ if nodata is not None: - return float(nodata) + nd = float(nodata) + if np.isinf(nd): + raise ValueError( + f"nodata must be finite or NaN, got {nodata!r}" + ) + return nd # rioxarray try: diff --git a/xrspatial/reproject/_itrf.py b/xrspatial/reproject/_itrf.py index a799bca6..4542d505 100644 --- a/xrspatial/reproject/_itrf.py +++ b/xrspatial/reproject/_itrf.py @@ -257,6 +257,26 @@ def itrf_transform(lon, lat, h=0.0, *, src, tgt, epoch): -------- >>> itrf_transform(-74.0, 40.7, 10.0, src='ITRF2014', tgt='ITRF2020', epoch=2024.0) """ + if not isinstance(src, str) or not src: + raise ValueError( + f"itrf_transform(): src must be a non-empty string, got {src!r}" + ) + if not isinstance(tgt, str) or not tgt: + raise ValueError( + f"itrf_transform(): tgt must be a non-empty string, got {tgt!r}" + ) + + try: + epoch_float = float(epoch) + except (TypeError, ValueError): + raise ValueError( + f"itrf_transform(): epoch must be a finite number, got {epoch!r}" + ) + if not np.isfinite(epoch_float): + raise ValueError( + f"itrf_transform(): epoch must be a finite number, got {epoch!r}" + ) + raw_params, is_reverse = _find_transform(src, tgt) if raw_params is None: raise ValueError( diff --git a/xrspatial/reproject/_vertical.py b/xrspatial/reproject/_vertical.py index 2762db46..e0013e54 100644 --- a/xrspatial/reproject/_vertical.py +++ b/xrspatial/reproject/_vertical.py @@ -202,6 +202,19 @@ def geoid_height(lon, lat, model='EGM96'): lon_arr = np.atleast_1d(np.asarray(lon, dtype=np.float64)).ravel() lat_arr = np.atleast_1d(np.asarray(lat, dtype=np.float64)).ravel() + if not np.isfinite(lon_arr).all(): + raise ValueError( + "geoid_height(): lon contains non-finite values (NaN or Inf)." + ) + if not np.isfinite(lat_arr).all(): + raise ValueError( + "geoid_height(): lat contains non-finite values (NaN or Inf)." + ) + if not ((lat_arr >= -90.0) & (lat_arr <= 90.0)).all(): + raise ValueError( + "geoid_height(): lat must be in [-90, 90]." + ) + out = np.empty(lon_arr.shape[0], dtype=np.float64) _interp_geoid_batch(lon_arr, lat_arr, out, data, left, top, res_x, res_y, h, w) diff --git a/xrspatial/tests/test_reproject.py b/xrspatial/tests/test_reproject.py index a3ad6b8e..1a5cc6d6 100644 --- a/xrspatial/tests/test_reproject.py +++ b/xrspatial/tests/test_reproject.py @@ -1517,3 +1517,80 @@ def test_numpy_chunk_source_window_guard(self): ) result = reproject(raster, target_crs='EPSG:3857') assert result.shape[0] > 0 and result.shape[1] > 0 + + +# ===================================================================== +# Issue #1435: NaN/Inf rejection in scalar inputs +# ===================================================================== + +class TestItrfFiniteness: + @pytest.mark.parametrize("epoch", [float('nan'), float('inf'), float('-inf')]) + def test_itrf_rejects_non_finite_epoch(self, epoch): + from xrspatial.reproject import itrf_transform + with pytest.raises(ValueError, match="epoch"): + itrf_transform(0.0, 0.0, 0.0, + src='ITRF2014', tgt='ITRF2020', epoch=epoch) + + def test_itrf_rejects_empty_src(self): + from xrspatial.reproject import itrf_transform + with pytest.raises(ValueError, match="src"): + itrf_transform(0.0, 0.0, 0.0, + src='', tgt='ITRF2020', epoch=2024.0) + + def test_itrf_rejects_empty_tgt(self): + from xrspatial.reproject import itrf_transform + with pytest.raises(ValueError, match="tgt"): + itrf_transform(0.0, 0.0, 0.0, + src='ITRF2014', tgt='', epoch=2024.0) + + +class TestGeoidFiniteness: + @pytest.mark.parametrize("lon", [float('nan'), float('inf')]) + def test_geoid_rejects_non_finite_lon(self, lon): + from xrspatial.reproject import geoid_height + with pytest.raises(ValueError, match="lon"): + geoid_height(lon, 0.0) + + @pytest.mark.parametrize("lat", [float('nan'), float('inf')]) + def test_geoid_rejects_non_finite_lat(self, lat): + from xrspatial.reproject import geoid_height + with pytest.raises(ValueError, match="lat"): + geoid_height(0.0, lat) + + @pytest.mark.parametrize("lat", [-91.0, 91.0]) + def test_geoid_rejects_out_of_range_lat(self, lat): + from xrspatial.reproject import geoid_height + with pytest.raises(ValueError, match=r"\[-90, 90\]"): + geoid_height(0.0, lat) + + def test_geoid_rejects_array_with_nan(self): + from xrspatial.reproject import geoid_height + lon = np.array([0.0, float('nan'), 10.0]) + lat = np.array([0.0, 0.0, 0.0]) + with pytest.raises(ValueError, match="lon"): + geoid_height(lon, lat) + + +class TestNodataFiniteness: + def test_detect_nodata_rejects_inf(self): + from xrspatial.reproject._crs_utils import _detect_nodata + r = xr.DataArray(np.zeros((4, 4)), dims=('y', 'x')) + with pytest.raises(ValueError, match="nodata"): + _detect_nodata(r, nodata=float('inf')) + + def test_detect_nodata_rejects_neg_inf(self): + from xrspatial.reproject._crs_utils import _detect_nodata + r = xr.DataArray(np.zeros((4, 4)), dims=('y', 'x')) + with pytest.raises(ValueError, match="nodata"): + _detect_nodata(r, nodata=float('-inf')) + + def test_detect_nodata_accepts_nan(self): + from xrspatial.reproject._crs_utils import _detect_nodata + r = xr.DataArray(np.zeros((4, 4)), dims=('y', 'x')) + nd = _detect_nodata(r, nodata=float('nan')) + assert np.isnan(nd) + + def test_detect_nodata_accepts_finite(self): + from xrspatial.reproject._crs_utils import _detect_nodata + r = xr.DataArray(np.zeros((4, 4)), dims=('y', 'x')) + assert _detect_nodata(r, nodata=-9999) == -9999.0