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
Expand Up @@ -31,6 +31,7 @@ sieve,2026-04-13T12:00:00Z,,,,Union-find CCL correct. NaN excluded from labeling
sky_view_factor,2026-05-01,1407,HIGH,4,Horizon angle ignored cell size; fixed by passing cellsize_x/cellsize_y into CPU+GPU kernels and using ground distance
terrain,2026-04-10T12:00:00Z,,,,Perlin/Worley/ridged noise correct. Dask chunk boundaries produce bit-identical results. No precision issues.
terrain_metrics,2026-04-30,,LOW,2;5,"LOW: Inf input not rejected, propagates as Inf (consistent across backends but undocumented). LOW: dask+cupy non-nan boundary path double-pads (wasted compute, central output values still correct). No CRIT/HIGH; tests cover NaN propagation, all 4 backends, all 4 boundary modes, dtype acceptance."
viewshed,2026-05-29,2691,HIGH,3;5,max_distance window sized from coarser axis clipped cells on anisotropic rasters (PR #2702). LOW unfixed: distance_sweep ring radius same max(res) pattern but max_distance arg always None; _calculate_event_row_col line 880 abs(x>1) precedence bug is a broken guard only. cuda+rtx paths validated.
visibility,2026-04-13T12:00:00Z,,,,"Bresenham line, LOS kernel, Fresnel zone all correct. All backends converge to numpy."
worley,2026-05-01,,MEDIUM,2;5,"MEDIUM: numpy backend uses np.empty_like(data) so integer input dtype produces integer output (distances truncated to 0); cupy/dask paths always produce float32. LOW: freq=inf produces 100000 sentinel (sqrt of initial min_dist=1e10), no validation of freq/seed for non-finite values."
zonal,2026-05-27,2528,MEDIUM,5,"Pass 2 (2026-05-27): MEDIUM fixed -- issue #2528. zonal_stats() on dask-backed inputs silently dropped 'majority' from the requested stats list. The mutable default stats_funcs included 'majority' (added in commit 7c8d5759), but the dask path filtered it out at xrspatial/zonal.py:459 (computed_stats = [s for s in stats_funcs.keys() if s in stats_dict]) because 'majority' is not in _DASK_BLOCK_STATS. Symptom: stats(zones=dask, values=dask) returned 7 columns instead of the 8 the docstring promises; stats(..., stats_funcs=['mean','majority']) returned only ['zone','mean'] with no error or warning. Both dask+numpy and dask+cupy were affected (dask+cupy delegates to dask+numpy). Fix: replaced the mutable list literal default with stats_funcs=None and resolved the default per backend inside the function -- numpy/cupy get the full 8-stat list, dask gets the 7-stat subset (no majority). Explicit majority on dask now raises ValueError with a clear supported-stats message instead of silently filtering. 4 regression tests in test_zonal.py: explicit majority raises on dask, bare default omits majority on dask, bare default keeps majority on numpy, default list is not mutated across calls (covers the historical mutable-default pitfall). All 129 test_zonal.py tests pass (125 pre-existing + 4 new); test_dasymetric.py 61 tests still pass (dasymetric uses zonal.stats internally). Categories: Cat 5 (backend inconsistency: numpy/cupy honoured majority; dask paths silently dropped it). | Pass 1 (2026-03-30T12:00:00Z): historical entry #1090."
71 changes: 71 additions & 0 deletions xrspatial/tests/test_viewshed.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,74 @@ def test_viewshed_cpu_memory_guard_passes_with_max_distance():
v = viewshed(raster, x=50.0, y=50.0, observer_elev=5,
max_distance=3.0)
assert v.values[50, 50] == 180.0


@pytest.mark.parametrize("backend", ["numpy", "cupy", "dask"])
@pytest.mark.parametrize("fine_axis", ["x", "y"])
def test_viewshed_max_distance_anisotropic(backend, fine_axis):
"""max_distance must not clip cells on anisotropic-resolution rasters.

Regression test: the windowed path sized its analysis window from the
coarser of ew_res / ns_res and used that single radius for both axes,
so cells within max_distance along the finer axis were dropped from
the window and returned INVISIBLE. Both anisotropy orientations are
checked so a mix-up between radius_rows / radius_cols is caught.
"""
if backend == "cupy":
if not has_rtx():
pytest.skip("rtxpy not available")
import cupy as cp

# Strongly anisotropic: one axis spaced by 1, the other by 10.
# Use a uniform nonzero elevation: flat enough that all in-range cells
# stay visible, but the RTX mesh builder needs positive max elevation.
ny, nx = 21, 21
terrain = np.full((ny, nx), 1.0)
if fine_axis == "x":
xs = np.arange(nx, dtype=float) * 1.0 # fine
ys = np.arange(ny, dtype=float) * 10.0 # coarse
else:
xs = np.arange(nx, dtype=float) * 10.0 # coarse
ys = np.arange(ny, dtype=float) * 1.0 # fine

obs_x, obs_y = xs[10], ys[10]
obs_elev = 50

base = xa.DataArray(terrain, coords=dict(x=xs, y=ys), dims=["y", "x"])
full = viewshed(base, x=obs_x, y=obs_y, observer_elev=obs_elev)
full_vals = full.values

# max_distance=8 reaches 8 cells along the fine axis but < 1 along the
# coarse axis, so the buggy single-radius window clipped the fine axis.
arr = terrain.copy()
if backend == "cupy":
arr = cp.asarray(arr)
raster = xa.DataArray(arr, coords=dict(x=xs, y=ys), dims=["y", "x"])
if backend == "dask":
raster = xa.DataArray(
da.from_array(terrain.copy(), chunks=(7, 7)),
coords=dict(x=xs, y=ys), dims=["y", "x"])

v = viewshed(raster, x=obs_x, y=obs_y, observer_elev=obs_elev,
max_distance=8.0)
if backend == "cupy":
result = v.data.get()
else:
# numpy and dask both resolve through .values
result = v.values

# Cells within max_distance along the fine axis must be evaluated and
# match the full viewshed, not be clipped to INVISIBLE. obs is at
# index 10 on both axes; step along the fine axis from the observer.
for offset in (2, 5, 7): # all < 8 cells along the fine axis
if fine_axis == "x":
rc = (10, 10 + offset)
else:
rc = (10 + offset, 10)
assert result[rc] > INVISIBLE, (
f"cell {rc} ({offset} fine-axis cells away) wrongly clipped")
np.testing.assert_allclose(result[rc], full_vals[rc], atol=0.03)

# A cell 9 fine-axis cells away (> max_distance) stays INVISIBLE.
far = (10, 19) if fine_axis == "x" else (19, 10)
assert result[far] == INVISIBLE
18 changes: 11 additions & 7 deletions xrspatial/viewshed.py
Original file line number Diff line number Diff line change
Expand Up @@ -2077,13 +2077,17 @@ def _viewshed_windowed(raster, x, y, observer_elev, target_elev,
x_range = (x_coords[0], x_coords[-1])
ew_res = (x_range[1] - x_range[0]) / (width - 1) if width > 1 else 1.0
ns_res = (y_range[1] - y_range[0]) / (height - 1) if height > 1 else 1.0
cell_size = max(abs(ew_res), abs(ns_res))
radius_cells = int(np.ceil(max_distance / cell_size))

r_lo = max(0, obs_r - radius_cells)
r_hi = min(height, obs_r + radius_cells + 1)
c_lo = max(0, obs_c - radius_cells)
c_hi = min(width, obs_c + radius_cells + 1)
# Size the window per axis: rows are spaced by ns_res, columns by ew_res.
# Using a single radius from the coarser resolution under-sizes the
# window along the finer axis and clips cells that are within
# max_distance there.
radius_rows = int(np.ceil(max_distance / abs(ns_res)))
radius_cols = int(np.ceil(max_distance / abs(ew_res)))

r_lo = max(0, obs_r - radius_rows)
r_hi = min(height, obs_r + radius_rows + 1)
c_lo = max(0, obs_c - radius_cols)
c_hi = min(width, obs_c + radius_cols + 1)

window = raster.isel(y=slice(r_lo, r_hi), x=slice(c_lo, c_hi))

Expand Down
Loading