diff --git a/.claude/sweep-accuracy-state.csv b/.claude/sweep-accuracy-state.csv index a2061c04a..3c69e5739 100644 --- a/.claude/sweep-accuracy-state.csv +++ b/.claude/sweep-accuracy-state.csv @@ -1,4 +1,5 @@ module,last_inspected,issue,severity_max,categories_found,notes +aspect,2026-06-02,2827,MEDIUM,5,"Cat5 backend divergence: planar cupy _gpu snapped aspect>359.999 to 0 (no such clamp in numpy _cpu, whose range is [0,360) and never reaches 360), so cupy/dask+cupy disagreed with numpy by ~360 on near-degenerate gradients (gx~0+, gy>0). Removing the clamp exposed a 2nd divergence: GPU used coarse 57.29578 vs numpy 180/pi, flipping the >90 compass branch and yielding exact 360 vs 0 on uint32/uint64 random data. Fix #2827/PR #2833: GPU reuses RADIAN and wraps >=360 back to [0,360). Cats 1-4 clean; geodesic path canonicalizes consistently on CPU+GPU and was left untouched. CUDA available; cupy+dask+cupy verified (235 tests pass, numpy-vs-cupy max abs diff 0 over 360 rasters). Dedup: prior aspect fixes #2780 (cellsize)/#2774 (dask mem guard)/#2781 (oracle) all merged and unrelated. Note: PR review COMMENT could not be posted to GitHub (auto-mode permission denial); findings recorded in PR run instead." balanced_allocation,2026-04-14T12:00:00Z,1203,,,float32 allocation array caused source ID mismatch for non-integer IDs. Fix in PR #1205. bilateral,2026-05-01,,,,"No CRIT/HIGH/MEDIUM. Sigma underflow validated via sqrt(tiny) bound; oversize sigma clamped. float64 throughout numpy/cupy. NaN center returns NaN; NaN neighbors skipped (denom not incremented). w_sum>0 guard avoids div-by-zero. map_overlap depth==kernel radius. CUDA bounds correct. Inf input could yield 0*inf=NaN in v_sum but unvalidated input is general xrspatial pattern, not bilateral-specific." contour,2026-05-01,,,,"Marching squares correct: NaN check uses self-inequality, loop bounds (ny-1,nx-1) cover all quads, dask overlap depth=1 matches 2x2 stencil, float64 cast consistent across backends, saddle disambiguation via center value. No CRIT/HIGH issues; minor LOW (Inf inputs not specifically rejected) not flagged." diff --git a/xrspatial/aspect.py b/xrspatial/aspect.py index d8d54f34c..8fa636fde 100644 --- a/xrspatial/aspect.py +++ b/xrspatial/aspect.py @@ -114,7 +114,11 @@ def _gpu(arr, cellsize_x, cellsize_y): # flat surface, slope = 0, thus invalid aspect _aspect = -1 else: - _aspect = atan2(dz_dy, -dz_dx) * 57.29578 + # Reuse the numpy kernel's RADIAN constant (180 / pi) so the branch + # below selects the same way on both backends; a coarser constant can + # push _aspect across the 90 boundary and yield 360 where numpy yields + # 0 (issue #2827). + _aspect = atan2(dz_dy, -dz_dx) * RADIAN # convert to compass direction values (0-360 degrees) if _aspect < 0: _aspect = 90 - _aspect @@ -122,11 +126,14 @@ def _gpu(arr, cellsize_x, cellsize_y): _aspect = 360 - _aspect + 90 else: _aspect = 90 - _aspect - - if _aspect > 359.999: # lame float equality check... - return 0 - else: - return _aspect + # Keep the output in [0, 360) to match the numpy kernel. The numpy + # kernel needs no equivalent wrap: its `elif _aspect > 90` excludes an + # exact-90 tie, which then yields 0 via the else branch. The GPU's + # 450 - 90 = 360 case is folded back to 0 here so the two agree. + if _aspect >= 360.0: + _aspect -= 360.0 + + return _aspect @cuda.jit diff --git a/xrspatial/tests/test_aspect.py b/xrspatial/tests/test_aspect.py index 054c75625..c4f1857aa 100644 --- a/xrspatial/tests/test_aspect.py +++ b/xrspatial/tests/test_aspect.py @@ -437,3 +437,41 @@ def test_planar_aspect_oracle_rectangular_cupy( interior = _aspect_interior(agg) expected = _aspect_oracle(gx, gy) np.testing.assert_allclose(interior, expected, rtol=1e-4, atol=1e-3) + + +# ---- Near-360 aspect band: cupy must match numpy (issue #2827) ---- +# +# A plane with a tiny east gradient and a strong north gradient produces an +# aspect just under 360 (e.g. 359.99994). The numpy/numba kernel emits that +# value; the cupy kernel used to snap anything above 359.999 to 0, so the two +# backends disagreed by ~360 on the same input. The random-data cross-backend +# tests never land in this band, so it went unnoticed. See issue #2827. + +def _near_360_plane(backend='numpy'): + # gx tiny positive, gy positive -> downslope faces just west of north. + return _to_backend(_gradient_plane(1e-6, 1.0, 1.0, 1.0, n=5), backend) + + +def test_near_360_aspect_numpy_lands_in_band(): + """numpy emits a value in (359.999, 360); it never snaps to 0.""" + interior = _aspect_interior(_near_360_plane('numpy')) + assert np.all(interior > 359.999) + assert np.all(interior < 360.0) + + +@pytest.mark.parametrize("backend", _ORACLE_BACKENDS) +def test_near_360_aspect_oracle(backend): + interior = _aspect_interior(_near_360_plane(backend)) + expected = _aspect_oracle(1e-6, 1.0) + np.testing.assert_allclose(interior, expected, rtol=1e-6, atol=1e-3) + + +@cuda_and_cupy_available +@pytest.mark.parametrize("backend", ['cupy', 'dask+cupy']) +def test_near_360_aspect_cupy_matches_numpy(backend): + if backend == 'dask+cupy' and not has_dask_array(): + pytest.skip("Requires dask.Array") + np_interior = _aspect_interior(_near_360_plane('numpy')) + gpu_interior = _aspect_interior(_near_360_plane(backend)) + np.testing.assert_allclose( + gpu_interior, np_interior, rtol=1e-6, atol=1e-3)