Skip to content

Commit df66be0

Browse files
committed
Fix target_elev contaminating horizon in dask viewshed distance sweep (#1098)
_sweep_ring used a single gradient array that included target_elev for both the visibility test and the horizon update. The horizon profile should only reflect raw terrain gradients — target_elev is the height offset of the thing you're looking for, not extra height on every cell that might block your view. Split the gradient into two arrays: gradients (with target_elev, for visibility) and terrain_gradients (without, for horizon update).
1 parent 443ed78 commit df66be0

File tree

2 files changed

+63
-3
lines changed

2 files changed

+63
-3
lines changed

xrspatial/tests/test_viewshed.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,3 +362,58 @@ def test_viewshed_max_distance_matches_full(backend):
362362
dist_vals[r, c], full_vals[r, c],
363363
atol=0.03,
364364
err_msg=f"Mismatch at ({r},{c})")
365+
366+
367+
def test_viewshed_dask_distance_sweep_target_elev():
368+
"""Tier C distance sweep must not let target_elev contaminate the
369+
horizon profile. Regression test: previously the horizon was updated
370+
with gradients that included target_elev, so intervening terrain
371+
appeared artificially tall and caused false occlusion."""
372+
from unittest.mock import patch
373+
374+
ny, nx = 15, 20
375+
terrain = np.zeros((ny, nx))
376+
# Place a ridge at column 12 that is tall enough to block line-of-sight
377+
# to column 15 when target_elev=0, but not when target_elev=8.
378+
terrain[:, 12] = 6.0
379+
380+
xs = np.arange(nx, dtype=float)
381+
ys = np.arange(ny, dtype=float)
382+
383+
obs_x, obs_y = 5.0, 7.0
384+
obs_elev = 5
385+
386+
# Numpy reference: with target_elev=8 the cells behind the ridge at
387+
# col 15 should be visible (target sticks up above the ridge).
388+
raster_np = xa.DataArray(terrain.copy(), coords=dict(x=xs, y=ys),
389+
dims=["y", "x"])
390+
v_np = viewshed(raster_np, x=obs_x, y=obs_y,
391+
observer_elev=obs_elev, target_elev=8)
392+
393+
# Sanity: numpy says the cell behind the ridge IS visible.
394+
assert v_np.values[7, 15] > INVISIBLE, (
395+
"numpy reference should see cell (7,15) with target_elev=8")
396+
397+
# Dask Tier C with same parameters
398+
raster_da = xa.DataArray(
399+
da.from_array(terrain.copy(), chunks=(5, 5)),
400+
coords=dict(x=xs, y=ys), dims=["y", "x"])
401+
402+
with patch('xrspatial.viewshed._available_memory_bytes',
403+
return_value=10_000):
404+
v_dask = viewshed(raster_da, x=obs_x, y=obs_y,
405+
observer_elev=obs_elev, target_elev=8)
406+
407+
result = v_dask.values
408+
409+
# The cell behind the ridge must be visible in Tier C too.
410+
assert result[7, 15] > INVISIBLE, (
411+
"Tier C distance sweep should see cell (7,15) with target_elev=8 "
412+
"(horizon must not include target_elev)")
413+
414+
# Visible cells should have angles that roughly match numpy.
415+
vis_mask = (v_np.values > INVISIBLE) & (result > INVISIBLE)
416+
if vis_mask.any():
417+
np.testing.assert_allclose(
418+
result[vis_mask], v_np.values[vis_mask], atol=1.0,
419+
err_msg="Tier C angles diverge too far from numpy reference")

xrspatial/viewshed.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1858,8 +1858,12 @@ def _sweep_ring(rows, cols, elevs, n_cells,
18581858
bin_width = 2.0 * PI / n_angles
18591859
INF = 1e30
18601860

1861-
# Pre-compute per-cell values
1861+
# Pre-compute per-cell values.
1862+
# Two gradient arrays: one WITH target_elev for visibility testing, one
1863+
# WITHOUT for the horizon (occlusion) profile. The terrain itself blocks
1864+
# the view, not imaginary targets on every cell.
18621865
gradients = np.empty(n_cells, dtype=np.float64)
1866+
terrain_gradients = np.empty(n_cells, dtype=np.float64)
18631867
center_bins = np.empty(n_cells, dtype=np.int64)
18641868
half_bins_arr = np.empty(n_cells, dtype=np.int64)
18651869
dist_sqs = np.empty(n_cells, dtype=np.float64)
@@ -1887,6 +1891,7 @@ def _sweep_ring(rows, cols, elevs, n_cells,
18871891
eff_elevs[i] = eff_elev
18881892
dist_sqs[i] = dist_sq
18891893
gradients[i] = atan((eff_elev - obs_elev) / dist)
1894+
terrain_gradients[i] = atan((elev - obs_elev) / dist)
18901895

18911896
angle = atan2(dy, dx)
18921897
ang_width = cell_size / dist
@@ -1913,13 +1918,13 @@ def _sweep_ring(rows, cols, elevs, n_cells,
19131918
visibility[r, c] = _get_vertical_ang(
19141919
obs_elev, dist_sqs[i], eff_elevs[i])
19151920

1916-
# Pass 2: update horizon with all ring cells
1921+
# Pass 2: update horizon with raw terrain gradients (no target_elev)
19171922
for i in range(n_cells):
19181923
if valid[i] == 0:
19191924
continue
19201925
cb = center_bins[i]
19211926
hb = half_bins_arr[i]
1922-
grad = gradients[i]
1927+
grad = terrain_gradients[i]
19231928
for b in range(-hb, hb + 1):
19241929
idx = (cb + b) % n_angles
19251930
if grad > horizon[idx]:

0 commit comments

Comments
 (0)