|
| 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