Skip to content

Commit e4c6c19

Browse files
committed
Add design spec for multi-observer viewshed and LOS profiles (#1145)
1 parent cb5bc02 commit e4c6c19

File tree

1 file changed

+192
-0
lines changed

1 file changed

+192
-0
lines changed
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# Multi-Observer Viewshed and Line-of-Sight Profiles
2+
3+
**Issue:** xarray-contrib/xarray-spatial#1145
4+
**Date:** 2026-04-01
5+
**Module:** `xrspatial/visibility.py` (new)
6+
7+
## Overview
8+
9+
Add three public functions for multi-observer visibility analysis and
10+
point-to-point line-of-sight profiling. All functions build on the existing
11+
single-observer `viewshed()` rather than reimplementing the sweep algorithm.
12+
13+
## Public API
14+
15+
### `cumulative_viewshed`
16+
17+
```python
18+
def cumulative_viewshed(
19+
raster: xarray.DataArray,
20+
observers: list[dict],
21+
target_elev: float = 0,
22+
max_distance: float | None = None,
23+
) -> xarray.DataArray:
24+
```
25+
26+
**Parameters:**
27+
28+
- `raster` -- elevation DataArray (any backend).
29+
- `observers` -- list of dicts. Required keys: `x`, `y`. Optional keys:
30+
`observer_elev` (default 0), `target_elev` (overrides the function-level
31+
default), `max_distance` (per-observer override).
32+
- `target_elev` -- default target elevation for observers that don't specify
33+
their own.
34+
- `max_distance` -- default maximum analysis radius for observers that don't
35+
specify their own.
36+
37+
**Returns:** integer DataArray where each cell value is the count of observers
38+
with line-of-sight to that cell. Cells visible to zero observers are 0.
39+
40+
**Algorithm:**
41+
42+
1. For each observer, call `viewshed(raster, ...)` with that observer's
43+
parameters.
44+
2. Convert the result to a binary mask (1 where value != INVISIBLE, else 0).
45+
3. Sum all masks element-wise.
46+
47+
**Backend behaviour:**
48+
49+
| Backend | Strategy |
50+
|---|---|
51+
| NumPy | Loop over observers, accumulate in-place into a numpy int32 array. |
52+
| CuPy | Delegates to `viewshed()` which handles CuPy dispatch. Accumulate on device. |
53+
| Dask+NumPy | Wrap each `viewshed()` call as a `dask.delayed` task, convert each result to a binary dask array, sum lazily. The graph is submitted once at the end. |
54+
| Dask+CuPy | Same as Dask+NumPy -- `viewshed()` handles the backend internally. |
55+
56+
For dask backends, `max_distance` should be set (either globally or
57+
per-observer) to keep each viewshed computation tractable. Without it, each
58+
observer viewshed loads the full raster into memory.
59+
60+
### `visibility_frequency`
61+
62+
```python
63+
def visibility_frequency(
64+
raster: xarray.DataArray,
65+
observers: list[dict],
66+
target_elev: float = 0,
67+
max_distance: float | None = None,
68+
) -> xarray.DataArray:
69+
```
70+
71+
Thin wrapper: returns `cumulative_viewshed(...) / len(observers)` cast to
72+
float64. Same parameters and backend behaviour.
73+
74+
### `line_of_sight`
75+
76+
```python
77+
def line_of_sight(
78+
raster: xarray.DataArray,
79+
x0: float, y0: float,
80+
x1: float, y1: float,
81+
observer_elev: float = 0,
82+
target_elev: float = 0,
83+
frequency_mhz: float | None = None,
84+
) -> xarray.Dataset:
85+
```
86+
87+
**Parameters:**
88+
89+
- `raster` -- elevation DataArray.
90+
- `x0, y0` -- observer coordinates in data space.
91+
- `x1, y1` -- target coordinates in data space.
92+
- `observer_elev` -- height above terrain at the observer point.
93+
- `target_elev` -- height above terrain at the target point.
94+
- `frequency_mhz` -- if set, compute first Fresnel zone clearance.
95+
96+
**Returns:** `xarray.Dataset` with dimension `sample` (one entry per cell
97+
along the transect) containing:
98+
99+
| Variable | Type | Description |
100+
|---|---|---|
101+
| `distance` | float64 | Distance from observer along the transect |
102+
| `elevation` | float64 | Terrain elevation at the sample point |
103+
| `los_height` | float64 | Height of the line-of-sight ray at that point |
104+
| `visible` | bool | Whether the cell is visible from the observer |
105+
| `x` | float64 | x-coordinate of the sample point |
106+
| `y` | float64 | y-coordinate of the sample point |
107+
| `fresnel_radius` | float64 | First Fresnel zone radius (only if `frequency_mhz` set) |
108+
| `fresnel_clear` | bool | Whether Fresnel zone is clear of terrain (only if `frequency_mhz` set) |
109+
110+
**Algorithm:**
111+
112+
1. Convert (x0, y0) and (x1, y1) to grid indices using the raster's
113+
coordinate arrays.
114+
2. Walk the line between the two grid cells using Bresenham's algorithm to
115+
get the sequence of (row, col) pairs.
116+
3. Extract terrain elevation at each cell. For dask/cupy rasters, pull only
117+
the transect cells to numpy.
118+
4. Compute the straight-line LOS height at each sample point by linear
119+
interpolation between observer and target heights (terrain + offsets).
120+
5. Walk forward from the observer tracking the maximum elevation angle seen
121+
so far. A cell is visible if no prior cell has a higher angle.
122+
6. If `frequency_mhz` is set, compute the first Fresnel zone radius at each
123+
point: `F1 = sqrt(d1 * d2 * c / (f * D))` where d1 and d2 are distances
124+
from observer and target, D is total distance, f is frequency, and c is
125+
the speed of light. A point has Fresnel clearance if the terrain is at
126+
least F1 below the LOS height.
127+
128+
**Backend behaviour:** Always runs on CPU. For CuPy-backed rasters, the
129+
transect elevations are copied to host. For dask-backed rasters, the transect
130+
slice is computed. The transect is at most `max(H, W)` cells long so this is
131+
always cheap.
132+
133+
## Module structure
134+
135+
```
136+
xrspatial/visibility.py
137+
cumulative_viewshed() -- public
138+
visibility_frequency() -- public
139+
line_of_sight() -- public
140+
_bresenham_line() -- private, returns list of (row, col) pairs
141+
_extract_transect() -- private, pulls elevation values from any backend
142+
_fresnel_radius() -- private, first Fresnel zone calculation
143+
```
144+
145+
## Integration points
146+
147+
- `xrspatial/__init__.py` -- add imports for all three functions.
148+
- `xrspatial/accessor.py` -- add accessor methods for all three functions.
149+
- `docs/source/reference/surface.rst` -- add a "Visibility Analysis" section
150+
with autosummary entries.
151+
- `README.md` -- add rows for `cumulative_viewshed`, `visibility_frequency`,
152+
and `line_of_sight` in the feature matrix.
153+
- `examples/user_guide/37_Visibility_Analysis.ipynb` -- new notebook.
154+
155+
## Testing strategy
156+
157+
Tests go in `xrspatial/tests/test_visibility.py`.
158+
159+
**cumulative_viewshed / visibility_frequency:**
160+
161+
- Flat terrain: all cells visible from all observers, count == n_observers.
162+
- Single tall wall: observers on opposite sides, verify cells behind the wall
163+
are only visible to the observer on their side.
164+
- Single observer: result matches `(viewshed(...) != INVISIBLE).astype(int)`.
165+
- Per-observer parameters: verify that `observer_elev` and `max_distance`
166+
overrides work.
167+
- Dask backend: verify result matches numpy backend.
168+
169+
**line_of_sight:**
170+
171+
- Flat terrain: all cells visible, LOS height matches linear interpolation.
172+
- Single obstruction: cells behind the peak are not visible.
173+
- Observer/target elevation offsets: verify LOS line shifts up.
174+
- Fresnel zone: known geometry where the zone is partially obstructed.
175+
- Edge case: observer == target (single cell, trivially visible).
176+
- Bresenham correctness: verify the line visits expected cells for known
177+
endpoints.
178+
179+
## Scope boundaries
180+
181+
**In scope:** the three functions described above, tests, docs, notebook,
182+
README update.
183+
184+
**Out of scope:**
185+
186+
- Refactoring existing `viewshed.py` internals.
187+
- GPU-specific kernels for cumulative viewshed (composition via `viewshed()`
188+
is sufficient).
189+
- Weighted observer contributions (each observer counts as 1).
190+
- Earth curvature correction for line-of-sight (the transect is typically
191+
short enough that curvature is negligible; users working at very long
192+
distances should use geodesic viewshed).

0 commit comments

Comments
 (0)