Skip to content

Commit e195258

Browse files
authored
Fixes #804: add min_observable_height() to experimental (#875) (#886)
New function that computes the minimum observer height needed to see each grid cell from a given location. Uses binary search over viewshed evaluations (log₂(max_height/precision) calls) instead of a linear sweep.
1 parent 4b8462f commit e195258

5 files changed

Lines changed: 359 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ In the GIS world, rasters are used for representing continuous phenomena (e.g. e
226226
| [Slope](xrspatial/slope.py) | Computes terrain gradient steepness at each cell in degrees | ✅️ | ✅️ | ✅️ | ✅️ |
227227
| [Terrain Generation](xrspatial/terrain.py) | Generates synthetic terrain elevation using fractal noise | ✅️ | ✅️ | ✅️ | |
228228
| [Viewshed](xrspatial/viewshed.py) | Determines visible cells from a given observer point on terrain | ✅️ | | | |
229+
| [Min Observable Height](xrspatial/experimental/min_observable_height.py) | Finds the minimum observer height needed to see each cell *(experimental)* | ✅️ | | | |
229230
| [Perlin Noise](xrspatial/perlin.py) | Generates smooth continuous random noise for procedural textures | ✅️ | ✅️ | ✅️ | |
230231
| [Bump Mapping](xrspatial/bump.py) | Adds randomized bump features to simulate natural terrain variation | ✅️ | | | |
231232

xrspatial/accessor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ def viewshed(self, x, y, **kwargs):
4343
from .viewshed import viewshed
4444
return viewshed(self._obj, x, y, **kwargs)
4545

46+
def min_observable_height(self, x, y, **kwargs):
47+
from .experimental.min_observable_height import min_observable_height
48+
return min_observable_height(self._obj, x, y, **kwargs)
49+
4650
# ---- Classification ----
4751

4852
def natural_breaks(self, **kwargs):

xrspatial/experimental/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
from .min_observable_height import min_observable_height # noqa
12
from .polygonize import polygonize # noqa
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Compute the minimum observer height needed to see each grid cell.
2+
3+
For a given observer location on a terrain raster, this module determines
4+
the minimum height above the terrain that an observer must be raised to
5+
in order to see each cell. The result is a DataArray of the same shape
6+
as the input, where each cell contains that minimum height (or ``NaN``
7+
for cells that remain invisible even at *max_height*).
8+
9+
The algorithm exploits the fact that visibility is **monotonic** in
10+
observer height: if a cell is visible at height *h*, it is also visible
11+
at every height greater than *h*. This allows a per-cell binary search
12+
over observer height, requiring only ``ceil(log2(max_height / precision))``
13+
viewshed evaluations instead of a linear sweep.
14+
"""
15+
16+
from math import ceil, log2
17+
from typing import Union
18+
19+
import numpy as np
20+
import xarray
21+
22+
from ..viewshed import viewshed, INVISIBLE
23+
24+
25+
def min_observable_height(
26+
raster: xarray.DataArray,
27+
x: Union[int, float],
28+
y: Union[int, float],
29+
max_height: float = 100.0,
30+
target_elev: float = 0.0,
31+
precision: float = 1.0,
32+
) -> xarray.DataArray:
33+
"""Return the minimum observer height to see each cell.
34+
35+
For every cell in *raster*, compute the smallest observer elevation
36+
(above the terrain at the observer location) at which that cell
37+
becomes visible. Cells that are never visible up to *max_height*
38+
are set to ``NaN``.
39+
40+
Parameters
41+
----------
42+
raster : xarray.DataArray
43+
2-D elevation raster with ``y`` and ``x`` coordinates.
44+
x : int or float
45+
x-coordinate (in data space) of the observer location.
46+
y : int or float
47+
y-coordinate (in data space) of the observer location.
48+
max_height : float, default 100.0
49+
Maximum observer height to test. Cells invisible at this
50+
height receive ``NaN`` in the output.
51+
target_elev : float, default 0.0
52+
Height offset added to target cells when testing visibility,
53+
passed through to :func:`~xrspatial.viewshed.viewshed`.
54+
precision : float, default 1.0
55+
Height resolution of the binary search. The result for each
56+
cell is accurate to within *precision* units.
57+
58+
Returns
59+
-------
60+
xarray.DataArray
61+
Same shape and coordinates as *raster*. Each cell contains the
62+
minimum observer height (in surface units) at which it becomes
63+
visible, or ``NaN`` if the cell is not visible at *max_height*.
64+
The cell containing the observer is always ``0.0``.
65+
66+
Raises
67+
------
68+
ValueError
69+
If *max_height* <= 0, *precision* <= 0, or *precision* >
70+
*max_height*.
71+
72+
Notes
73+
-----
74+
The function calls :func:`~xrspatial.viewshed.viewshed` internally,
75+
so the same backend (numpy / cupy) is used. Dask-backed rasters
76+
are not supported because viewshed itself does not support them.
77+
78+
The number of viewshed evaluations is
79+
``ceil(log2(max_height / precision)) + 1``, which is typically far
80+
fewer than a linear sweep at the same resolution.
81+
82+
Examples
83+
--------
84+
.. sourcecode:: python
85+
86+
>>> import numpy as np
87+
>>> import xarray as xr
88+
>>> from xrspatial.experimental import min_observable_height
89+
>>> elev = np.zeros((5, 5), dtype=float)
90+
>>> elev[2, 4] = 10.0 # a tall obstacle
91+
>>> raster = xr.DataArray(elev, dims=['y', 'x'])
92+
>>> h, w = elev.shape
93+
>>> raster['y'] = np.arange(h, dtype=float)
94+
>>> raster['x'] = np.arange(w, dtype=float)
95+
>>> result = min_observable_height(raster, x=0.0, y=2.0,
96+
... max_height=20, precision=0.5)
97+
"""
98+
# ---- input validation ------------------------------------------------
99+
if max_height <= 0:
100+
raise ValueError(f"max_height must be positive, got {max_height}")
101+
if precision <= 0:
102+
raise ValueError(f"precision must be positive, got {precision}")
103+
if precision > max_height:
104+
raise ValueError(
105+
f"precision ({precision}) must not exceed max_height ({max_height})"
106+
)
107+
108+
# ---- determine candidate heights via binary search -------------------
109+
#
110+
# We evaluate viewshed at O(log2(max_height / precision)) heights.
111+
# For each cell we then find the smallest height where it is visible.
112+
#
113+
n_steps = int(ceil(log2(max_height / precision))) + 1
114+
# Build sorted list of heights from 0 to max_height (inclusive).
115+
# Using linspace guarantees both endpoints are included.
116+
if n_steps < 2:
117+
n_steps = 2
118+
heights = np.linspace(0.0, max_height, n_steps)
119+
120+
# ---- collect visibility at each height level -------------------------
121+
# visibility_stack[k] is True where the cell is visible at heights[k].
122+
visibility_stack = np.empty(
123+
(n_steps, *raster.shape), dtype=np.bool_
124+
)
125+
126+
for k, h in enumerate(heights):
127+
vs = viewshed(raster, x=x, y=y, observer_elev=float(h),
128+
target_elev=target_elev)
129+
# viewshed returns INVISIBLE (-1) for invisible, positive angle
130+
# for visible. The observer cell itself gets 180.
131+
vs_data = vs.values if isinstance(vs.data, np.ndarray) else vs.data.get()
132+
visibility_stack[k] = vs_data != INVISIBLE
133+
134+
# ---- per-cell binary search on the precomputed stack -----------------
135+
# Because visibility is monotonic in height, we can find the first
136+
# height index where each cell becomes visible.
137+
#
138+
# Strategy: scan from lowest to highest height. The minimum height
139+
# for a cell is the first index k where visibility_stack[k] is True.
140+
#
141+
# np.argmax on a boolean axis returns the *first* True index.
142+
# If a cell is never visible, all entries are False and argmax
143+
# returns 0 — we need to distinguish that case.
144+
145+
ever_visible = visibility_stack.any(axis=0)
146+
first_visible_idx = visibility_stack.argmax(axis=0)
147+
148+
result = np.where(ever_visible, heights[first_visible_idx], np.nan)
149+
150+
return xarray.DataArray(
151+
result,
152+
coords=raster.coords,
153+
dims=raster.dims,
154+
attrs=raster.attrs,
155+
)
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import numpy as np
2+
import pytest
3+
import xarray as xa
4+
5+
from xrspatial.experimental import min_observable_height
6+
7+
8+
def _make_raster(data, xs=None, ys=None):
9+
"""Build a DataArray with y/x coords from a 2-D numpy array."""
10+
ny, nx = data.shape
11+
if xs is None:
12+
xs = np.arange(nx, dtype=float)
13+
if ys is None:
14+
ys = np.arange(ny, dtype=float)
15+
return xa.DataArray(
16+
data.astype(np.float64),
17+
coords=dict(x=xs, y=ys),
18+
dims=["y", "x"],
19+
)
20+
21+
22+
# ---- input validation ---------------------------------------------------
23+
24+
def test_max_height_positive():
25+
r = _make_raster(np.zeros((3, 3)))
26+
with pytest.raises(ValueError, match="max_height must be positive"):
27+
min_observable_height(r, x=1.0, y=1.0, max_height=0)
28+
29+
30+
def test_precision_positive():
31+
r = _make_raster(np.zeros((3, 3)))
32+
with pytest.raises(ValueError, match="precision must be positive"):
33+
min_observable_height(r, x=1.0, y=1.0, precision=0)
34+
35+
36+
def test_precision_exceeds_max_height():
37+
r = _make_raster(np.zeros((3, 3)))
38+
with pytest.raises(ValueError, match="precision.*must not exceed"):
39+
min_observable_height(r, x=1.0, y=1.0, max_height=5, precision=10)
40+
41+
42+
# ---- flat terrain --------------------------------------------------------
43+
44+
def test_flat_terrain_all_visible_at_zero():
45+
"""On flat terrain with any positive observer_elev, all cells are
46+
visible. min_observable_height should be 0 everywhere."""
47+
data = np.full((5, 5), 10.0)
48+
r = _make_raster(data)
49+
result = min_observable_height(r, x=2.0, y=2.0,
50+
max_height=20.0, precision=1.0)
51+
assert result.shape == r.shape
52+
# Every cell should be visible at height 0 (observer at terrain level
53+
# can see all cells at same elevation).
54+
assert np.all(result.values == 0.0)
55+
56+
57+
# ---- obstacle blocking ---------------------------------------------------
58+
59+
def test_wall_requires_height():
60+
"""A wall between the observer and far cells forces higher observer."""
61+
# y=0: 0 0 0 0 0
62+
# y=1: 0 0 10 0 0 <- wall at (y=1, x=2)
63+
# y=2: 0 0 0 0 0
64+
# Observer at (x=0, y=1).
65+
#
66+
# To see cell (x=3, y=1) over the wall, the line of sight from
67+
# (0, h) to (3, 0) must clear (2, 10). LOS height at x=2 is h/3,
68+
# so h >= 30 is required.
69+
data = np.zeros((3, 5))
70+
data[1, 2] = 10.0 # wall
71+
r = _make_raster(data)
72+
73+
result = min_observable_height(r, x=0.0, y=1.0,
74+
max_height=50.0, precision=1.0)
75+
76+
# Observer cell itself should be 0.
77+
assert result.sel(x=0.0, y=1.0).item() == 0.0
78+
79+
# Cells on same side as observer (x=1) should be visible at 0.
80+
assert result.sel(x=1.0, y=1.0).item() == 0.0
81+
82+
# Cell behind the wall (x=3, y=1) needs a significant height.
83+
behind_wall = result.sel(x=3.0, y=1.0).item()
84+
assert behind_wall > 0.0, "cells behind wall should need height > 0"
85+
86+
# The wall cell itself is visible from height 0 (it's tall, angles
87+
# up to observer) or at least from some modest height.
88+
wall_h = result.sel(x=2.0, y=1.0).item()
89+
assert wall_h < behind_wall, "wall cell should be easier to see than cells behind it"
90+
91+
92+
def test_tall_peak_blocks_cells_behind():
93+
"""A peak should block cells directly behind it, requiring more height."""
94+
# Observer at (x=0, y=1), peak at (x=2, y=1), target at (x=4, y=1).
95+
# Need at least 2 rows so viewshed can compute ns_res.
96+
data = np.zeros((3, 5))
97+
data[1, 2] = 8.0 # peak
98+
r = _make_raster(data)
99+
100+
result = min_observable_height(r, x=0.0, y=1.0,
101+
max_height=40.0, precision=0.5)
102+
103+
h_behind = result.values[1, 4]
104+
h_front = result.values[1, 1]
105+
# cell in front of peak should be visible from lower height
106+
assert h_front < h_behind
107+
108+
109+
# ---- NaN for unreachable cells -------------------------------------------
110+
111+
def test_unreachable_cells_are_nan():
112+
"""If a cell cannot be seen even at max_height, result should be NaN."""
113+
# Very tall wall close to observer, low max_height.
114+
data = np.zeros((3, 3))
115+
data[1, 1] = 1000.0 # enormous wall next to observer
116+
r = _make_raster(data)
117+
118+
result = min_observable_height(r, x=0.0, y=1.0,
119+
max_height=5.0, precision=1.0)
120+
121+
# Some cells behind the 1000-unit wall should be NaN at max_height=5.
122+
# The far corner (x=2, y=0 or y=2) is behind the wall.
123+
far_val = result.sel(x=2.0, y=0.0).item()
124+
assert np.isnan(far_val), "cell behind huge wall should be NaN at low max_height"
125+
126+
127+
# ---- output shape and coords ---------------------------------------------
128+
129+
def test_output_shape_coords_match_input():
130+
data = np.random.default_rng(42).uniform(0, 10, (6, 8))
131+
xs = np.linspace(0, 7, 8)
132+
ys = np.linspace(0, 5, 6)
133+
r = _make_raster(data, xs=xs, ys=ys)
134+
135+
result = min_observable_height(r, x=3.0, y=2.0,
136+
max_height=20, precision=2.0)
137+
assert result.shape == r.shape
138+
np.testing.assert_array_equal(result.coords['x'].values, xs)
139+
np.testing.assert_array_equal(result.coords['y'].values, ys)
140+
assert result.dims == r.dims
141+
142+
143+
# ---- monotonicity property -----------------------------------------------
144+
145+
def test_monotonicity():
146+
"""Visibility is monotonic: if visible at h, visible at all h' > h.
147+
148+
This indirectly tests that the binary search is correct: for each
149+
cell, all heights >= min_observable_height should show it as visible.
150+
"""
151+
data = np.zeros((5, 5))
152+
data[2, 3] = 8.0
153+
r = _make_raster(data)
154+
155+
result = min_observable_height(r, x=0.0, y=2.0,
156+
max_height=20.0, precision=1.0)
157+
158+
# Pick a cell that requires non-zero height.
159+
h_needed = result.sel(x=4.0, y=2.0).item()
160+
if not np.isnan(h_needed) and h_needed > 0:
161+
from xrspatial import viewshed
162+
# Should be invisible slightly below threshold.
163+
vs_below = viewshed(r, x=0.0, y=2.0, observer_elev=max(h_needed - 1.0, 0))
164+
vs_above = viewshed(r, x=0.0, y=2.0, observer_elev=h_needed + 1.0)
165+
# At or above threshold the cell should be visible.
166+
assert vs_above.sel(x=4.0, y=2.0).item() != -1
167+
168+
169+
# ---- precision affects granularity ---------------------------------------
170+
171+
def test_finer_precision():
172+
"""With finer precision, results should be at least as good (<=) as
173+
coarser precision."""
174+
data = np.zeros((3, 5))
175+
data[1, 2] = 10.0
176+
r = _make_raster(data)
177+
178+
coarse = min_observable_height(r, x=0.0, y=1.0,
179+
max_height=20, precision=5.0)
180+
fine = min_observable_height(r, x=0.0, y=1.0,
181+
max_height=20, precision=0.5)
182+
183+
# Fine result should be <= coarse result for all finite cells
184+
# (finer search can find an equal or lower threshold).
185+
mask = np.isfinite(fine.values) & np.isfinite(coarse.values)
186+
assert np.all(fine.values[mask] <= coarse.values[mask] + 1e-10)
187+
188+
189+
# ---- accessor integration ------------------------------------------------
190+
191+
def test_accessor():
192+
"""min_observable_height is available via .xrs accessor."""
193+
import xrspatial # noqa: F401 – registers accessor
194+
data = np.zeros((3, 3))
195+
r = _make_raster(data)
196+
result = r.xrs.min_observable_height(x=1.0, y=1.0,
197+
max_height=10, precision=2)
198+
assert result.shape == r.shape

0 commit comments

Comments
 (0)