Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/sweep-accuracy-state.csv
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
19 changes: 13 additions & 6 deletions xrspatial/aspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,26 @@ 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
elif _aspect > 90:
_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
Expand Down
38 changes: 38 additions & 0 deletions xrspatial/tests/test_aspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading