Skip to content

Commit 17128ac

Browse files
committed
Add cumulative_viewshed for multi-observer analysis (#1145)
1 parent 1c64643 commit 17128ac

File tree

2 files changed

+143
-0
lines changed

2 files changed

+143
-0
lines changed

xrspatial/tests/test_visibility.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,93 @@ def test_xy_coords_in_output(self):
155155
# last point should match target
156156
assert abs(result['x'].values[-1] - 9.0) < 1e-10
157157
assert abs(result['y'].values[-1] - 2.0) < 1e-10
158+
159+
160+
import dask.array as da
161+
from xrspatial.visibility import cumulative_viewshed
162+
163+
164+
class TestCumulativeViewshed:
165+
def test_flat_terrain_all_visible(self):
166+
"""On flat terrain with elevated observers, every cell is visible."""
167+
data = np.zeros((10, 10), dtype=float)
168+
raster = _make_raster(data)
169+
observers = [
170+
{'x': 2.0, 'y': 2.0, 'observer_elev': 10},
171+
{'x': 7.0, 'y': 7.0, 'observer_elev': 10},
172+
]
173+
result = cumulative_viewshed(raster, observers)
174+
assert result.dtype == np.int32
175+
# every cell should be seen by both observers
176+
assert (result.values == 2).all()
177+
178+
def test_single_observer_matches_viewshed(self):
179+
"""Single-observer cumulative should match binary viewshed."""
180+
from xrspatial import viewshed
181+
from xrspatial.viewshed import INVISIBLE
182+
data = np.random.RandomState(42).rand(15, 15).astype(float) * 100
183+
raster = _make_raster(data)
184+
obs = {'x': 7.0, 'y': 7.0, 'observer_elev': 50}
185+
result = cumulative_viewshed(raster, [obs])
186+
vs = viewshed(raster, x=7.0, y=7.0, observer_elev=50)
187+
expected = (vs.values != INVISIBLE).astype(np.int32)
188+
np.testing.assert_array_equal(result.values, expected)
189+
190+
def test_wall_blocks_one_side(self):
191+
"""A tall wall blocks visibility from the other side."""
192+
data = np.zeros((5, 11), dtype=float)
193+
data[:, 5] = 1000 # tall wall across all rows
194+
raster = _make_raster(data)
195+
obs_left = {'x': 0.0, 'y': 2.0, 'observer_elev': 1}
196+
obs_right = {'x': 10.0, 'y': 2.0, 'observer_elev': 1}
197+
result = cumulative_viewshed(raster, [obs_left, obs_right])
198+
# the wall cell itself is visible to both
199+
assert result.values[2, 5] == 2
200+
# cells far from wall visible to at least one observer
201+
assert result.values[2, 0] >= 1
202+
assert result.values[2, 10] >= 1
203+
204+
def test_per_observer_max_distance(self):
205+
"""Per-observer max_distance limits the analysis radius."""
206+
data = np.zeros((20, 20), dtype=float)
207+
raster = _make_raster(data)
208+
obs = {'x': 10.0, 'y': 10.0, 'observer_elev': 10, 'max_distance': 3}
209+
result = cumulative_viewshed(raster, [obs])
210+
# corners should be 0 (beyond max_distance)
211+
assert result.values[0, 0] == 0
212+
assert result.values[19, 19] == 0
213+
# center should be 1
214+
assert result.values[10, 10] == 1
215+
216+
def test_empty_observers_raises(self):
217+
data = np.zeros((5, 5), dtype=float)
218+
raster = _make_raster(data)
219+
with pytest.raises(ValueError):
220+
cumulative_viewshed(raster, [])
221+
222+
def test_dask_matches_numpy(self):
223+
"""Dask backend should produce the same result as numpy."""
224+
data = np.random.RandomState(99).rand(15, 15).astype(float) * 50
225+
raster_np = _make_raster(data)
226+
raster_dask = raster_np.copy()
227+
raster_dask.data = da.from_array(data, chunks=(8, 8))
228+
observers = [
229+
{'x': 3.0, 'y': 3.0, 'observer_elev': 30},
230+
{'x': 12.0, 'y': 12.0, 'observer_elev': 30},
231+
]
232+
result_np = cumulative_viewshed(raster_np, observers)
233+
result_dask = cumulative_viewshed(raster_dask, observers)
234+
np.testing.assert_array_equal(result_np.values, result_dask.values)
235+
236+
def test_preserves_coords_and_dims(self):
237+
data = np.zeros((5, 5), dtype=float)
238+
raster = _make_raster(data)
239+
raster.attrs['crs'] = 'EPSG:4326'
240+
observers = [{'x': 2.0, 'y': 2.0, 'observer_elev': 10}]
241+
result = cumulative_viewshed(raster, observers)
242+
assert result.dims == raster.dims
243+
np.testing.assert_array_equal(result.coords['x'].values,
244+
raster.coords['x'].values)
245+
np.testing.assert_array_equal(result.coords['y'].values,
246+
raster.coords['y'].values)
247+
assert result.attrs.get('crs') == 'EPSG:4326'

xrspatial/visibility.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,56 @@ def line_of_sight(
181181
data_vars['fresnel_clear'] = ('sample', fresnel_clear)
182182

183183
return xarray.Dataset(data_vars)
184+
185+
186+
def cumulative_viewshed(
187+
raster: xarray.DataArray,
188+
observers: list,
189+
target_elev: float = 0,
190+
max_distance: float = None,
191+
) -> xarray.DataArray:
192+
"""Count how many observers can see each cell.
193+
194+
Parameters
195+
----------
196+
raster : xarray.DataArray
197+
Elevation raster (numpy, cupy, or dask-backed).
198+
observers : list of dict
199+
Each dict must have ``x`` and ``y`` keys (data-space coords).
200+
Optional keys: ``observer_elev`` (default 0), ``target_elev``
201+
(overrides function-level default), ``max_distance`` (per-observer
202+
analysis radius).
203+
target_elev : float
204+
Default target elevation for observers that don't specify one.
205+
max_distance : float, optional
206+
Default maximum analysis radius.
207+
208+
Returns
209+
-------
210+
xarray.DataArray
211+
Integer raster (int32) with the count of observers that have
212+
line-of-sight to each cell.
213+
"""
214+
from .viewshed import viewshed, INVISIBLE
215+
216+
_validate_raster(raster, func_name='cumulative_viewshed', name='raster')
217+
if not observers:
218+
raise ValueError("observers list must not be empty")
219+
220+
count = np.zeros(raster.shape, dtype=np.int32)
221+
222+
for obs in observers:
223+
ox = obs['x']
224+
oy = obs['y']
225+
oe = obs.get('observer_elev', 0)
226+
te = obs.get('target_elev', target_elev)
227+
md = obs.get('max_distance', max_distance)
228+
229+
vs = viewshed(raster, x=ox, y=oy, observer_elev=oe,
230+
target_elev=te, max_distance=md)
231+
232+
vs_np = vs.values
233+
count += (vs_np != INVISIBLE).astype(np.int32)
234+
235+
return xarray.DataArray(count, coords=raster.coords,
236+
dims=raster.dims, attrs=raster.attrs)

0 commit comments

Comments
 (0)