Skip to content

Commit 2ccbf9e

Browse files
authored
Numba-ize visibility module loops (#1177) (#1179)
* Add design spec for polygonize simplification (#1151) * Add implementation plan for polygonize simplification (#1151) * Numba-ize visibility module loops (#1177) JIT-compile _bresenham_line, _fresnel_radius_1, and the three inner loops of line_of_sight (distance, visibility, Fresnel) via @ngjit. _bresenham_line now returns an (N, 2) int64 array instead of a list of tuples; _extract_transect and tests updated accordingly.
1 parent 06e1025 commit 2ccbf9e

File tree

2 files changed

+105
-61
lines changed

2 files changed

+105
-61
lines changed

xrspatial/tests/test_visibility.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,34 @@
88
class TestBresenhamLine:
99
def test_horizontal(self):
1010
cells = _bresenham_line(0, 0, 0, 4)
11-
assert cells == [(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)]
11+
expected = np.array([[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]])
12+
np.testing.assert_array_equal(cells, expected)
1213

1314
def test_vertical(self):
1415
cells = _bresenham_line(0, 0, 4, 0)
15-
assert cells == [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]
16+
expected = np.array([[0, 0], [1, 0], [2, 0], [3, 0], [4, 0]])
17+
np.testing.assert_array_equal(cells, expected)
1618

1719
def test_diagonal(self):
1820
cells = _bresenham_line(0, 0, 3, 3)
19-
assert cells == [(0, 0), (1, 1), (2, 2), (3, 3)]
21+
expected = np.array([[0, 0], [1, 1], [2, 2], [3, 3]])
22+
np.testing.assert_array_equal(cells, expected)
2023

2124
def test_single_cell(self):
2225
cells = _bresenham_line(2, 3, 2, 3)
23-
assert cells == [(2, 3)]
26+
expected = np.array([[2, 3]])
27+
np.testing.assert_array_equal(cells, expected)
2428

2529
def test_steep_negative(self):
2630
cells = _bresenham_line(4, 2, 0, 0)
27-
# Must start at (4, 2) and end at (0, 0)
28-
assert cells[0] == (4, 2)
29-
assert cells[-1] == (0, 0)
31+
assert tuple(cells[0]) == (4, 2)
32+
assert tuple(cells[-1]) == (0, 0)
3033
assert len(cells) == 5
3134

3235
def test_includes_endpoints(self):
3336
cells = _bresenham_line(1, 1, 5, 8)
34-
assert cells[0] == (1, 1)
35-
assert cells[-1] == (5, 8)
37+
assert tuple(cells[0]) == (1, 1)
38+
assert tuple(cells[-1]) == (5, 8)
3639

3740

3841
def _make_raster(data):
@@ -50,7 +53,7 @@ class TestExtractTransect:
5053
def test_numpy_diagonal(self):
5154
data = np.arange(25, dtype=float).reshape(5, 5)
5255
raster = _make_raster(data)
53-
cells = [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)]
56+
cells = np.array([[0, 0], [1, 1], [2, 2], [3, 3], [4, 4]])
5457
elev, xs, ys = _extract_transect(raster, cells)
5558
np.testing.assert_array_equal(elev, [0, 6, 12, 18, 24])
5659
np.testing.assert_array_equal(xs, [0, 1, 2, 3, 4])
@@ -62,7 +65,7 @@ def test_dask_matches_numpy(self):
6265
raster_np = _make_raster(data)
6366
raster_dask = raster_np.copy()
6467
raster_dask.data = da.from_array(data, chunks=(3, 3))
65-
cells = [(0, 0), (2, 3), (4, 4)]
68+
cells = np.array([[0, 0], [2, 3], [4, 4]])
6669
elev_np, _, _ = _extract_transect(raster_np, cells)
6770
elev_da, _, _ = _extract_transect(raster_dask, cells)
6871
np.testing.assert_array_equal(elev_np, elev_da)

xrspatial/visibility.py

Lines changed: 91 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,37 @@
1111
Elevation profile and visibility along a straight line between two points.
1212
"""
1313

14+
import math
15+
1416
import numpy as np
1517
import xarray
1618

17-
from .utils import _validate_raster, has_cuda_and_cupy, has_dask_array, is_cupy_array
19+
from .utils import (
20+
_validate_raster, has_cuda_and_cupy, has_dask_array, is_cupy_array, ngjit,
21+
)
1822

1923
SPEED_OF_LIGHT = 299_792_458.0 # m/s
2024

2125

26+
@ngjit
2227
def _bresenham_line(r0, c0, r1, c1):
23-
"""Return list of (row, col) cells along the line from (r0,c0) to (r1,c1).
28+
"""Return (N, 2) int64 array of (row, col) cells along a Bresenham line.
2429
25-
Uses Bresenham's line algorithm. Both endpoints are included.
30+
Both endpoints are included.
2631
"""
27-
cells = []
2832
dr = abs(r1 - r0)
2933
dc = abs(c1 - c0)
34+
max_len = dr + dc + 1
35+
out = np.empty((max_len, 2), dtype=np.int64)
3036
sr = 1 if r1 > r0 else -1
3137
sc = 1 if c1 > c0 else -1
3238
err = dr - dc
3339
r, c = r0, c0
40+
idx = 0
3441
while True:
35-
cells.append((r, c))
42+
out[idx, 0] = r
43+
out[idx, 1] = c
44+
idx += 1
3645
if r == r1 and c == c1:
3746
break
3847
e2 = 2 * err
@@ -42,17 +51,18 @@ def _bresenham_line(r0, c0, r1, c1):
4251
if e2 < dr:
4352
err += dr
4453
c += sc
45-
return cells
54+
return out[:idx]
4655

4756

4857
def _extract_transect(raster, cells):
49-
"""Extract elevation, x-coords, and y-coords for a list of (row, col) cells.
58+
"""Extract elevation, x-coords, and y-coords for an (N, 2) array of cells.
5059
60+
*cells* is an (N, 2) int array with columns (row, col).
5161
For dask or cupy-backed rasters the values are pulled to numpy.
5262
Returns (elevations, x_coords, y_coords) as 1-D numpy arrays.
5363
"""
54-
rows = np.array([r for r, c in cells])
55-
cols = np.array([c for r, c in cells])
64+
rows = cells[:, 0]
65+
cols = cells[:, 1]
5666

5767
x_coords = raster.coords['x'].values[cols]
5868
y_coords = raster.coords['y'].values[rows]
@@ -61,7 +71,6 @@ def _extract_transect(raster, cells):
6171
if has_dask_array():
6272
import dask.array as da
6373
if isinstance(data, da.Array):
64-
# Only compute the needed cells, not the entire array
6574
elevations = data.vindex[rows, cols].compute().astype(np.float64)
6675
return elevations, x_coords, y_coords
6776
if has_cuda_and_cupy() and is_cupy_array(data):
@@ -71,13 +80,77 @@ def _extract_transect(raster, cells):
7180
return elevations, x_coords, y_coords
7281

7382

83+
@ngjit
7484
def _fresnel_radius_1(d1, d2, freq_hz):
7585
"""First Fresnel zone radius at a point d1 from transmitter, d2 from receiver."""
7686
D = d1 + d2
77-
if D == 0 or freq_hz == 0:
87+
if D == 0.0 or freq_hz == 0.0:
7888
return 0.0
7989
wavelength = SPEED_OF_LIGHT / freq_hz
80-
return np.sqrt(wavelength * d1 * d2 / D)
90+
return math.sqrt(wavelength * d1 * d2 / D)
91+
92+
93+
@ngjit
94+
def _los_kernel(xs, ys, elevations, obs_h, tgt_h, freq_hz):
95+
"""Compute distance, LOS height, visibility, and optional Fresnel arrays.
96+
97+
All heavy loops live here so they run under numba.
98+
99+
Parameters
100+
----------
101+
xs, ys : 1-D float64 arrays of transect coordinates.
102+
elevations : 1-D float64 array of terrain heights.
103+
obs_h : float -- observer height (terrain + offset).
104+
tgt_h : float -- target height (terrain + offset).
105+
freq_hz : float -- radio frequency in Hz; <= 0 means skip Fresnel.
106+
107+
Returns
108+
-------
109+
distance, los_height, visible, fresnel, fresnel_clear
110+
"""
111+
n = xs.shape[0]
112+
distance = np.empty(n, dtype=np.float64)
113+
distance[0] = 0.0
114+
for i in range(1, n):
115+
dx = xs[i] - xs[i - 1]
116+
dy = ys[i] - ys[i - 1]
117+
distance[i] = distance[i - 1] + math.sqrt(dx * dx + dy * dy)
118+
119+
total_dist = distance[n - 1] if n > 1 else 0.0
120+
121+
# LOS height: linear interpolation from observer to target
122+
los_height = np.empty(n, dtype=np.float64)
123+
if total_dist > 0.0:
124+
for i in range(n):
125+
los_height[i] = obs_h + (tgt_h - obs_h) * (distance[i] / total_dist)
126+
else:
127+
los_height[0] = obs_h
128+
129+
# Visibility: track max elevation angle from observer
130+
visible = np.ones(n, dtype=np.bool_)
131+
max_angle = -1e300
132+
for i in range(1, n):
133+
if distance[i] == 0.0:
134+
continue
135+
angle = (elevations[i] - obs_h) / distance[i]
136+
if angle >= max_angle:
137+
max_angle = angle
138+
else:
139+
visible[i] = False
140+
141+
# Fresnel zone (only when freq_hz > 0)
142+
fresnel = np.zeros(n, dtype=np.float64)
143+
fresnel_clear = np.ones(n, dtype=np.bool_)
144+
if freq_hz > 0.0:
145+
for i in range(n):
146+
d1 = distance[i]
147+
d2 = total_dist - d1
148+
fresnel[i] = _fresnel_radius_1(d1, d2, freq_hz)
149+
clearance = los_height[i] - elevations[i]
150+
if clearance < fresnel[i]:
151+
fresnel_clear[i] = False
152+
153+
return distance, los_height, visible, fresnel, fresnel_clear
81154

82155

83156
def line_of_sight(
@@ -128,36 +201,14 @@ def line_of_sight(
128201
cells = _bresenham_line(r0, c0, r1, c1)
129202
elevations, xs, ys = _extract_transect(raster, cells)
130203

131-
n = len(cells)
132-
133-
# cumulative distance along the transect
134-
distance = np.zeros(n, dtype=np.float64)
135-
for i in range(1, n):
136-
dx = xs[i] - xs[i - 1]
137-
dy = ys[i] - ys[i - 1]
138-
distance[i] = distance[i - 1] + np.sqrt(dx * dx + dy * dy)
139-
140-
total_dist = distance[-1] if n > 1 else 0.0
141-
142-
# LOS height: linear interpolation from observer to target
204+
n = len(elevations)
143205
obs_h = elevations[0] + observer_elev
144-
tgt_h = elevations[-1] + target_elev if n > 1 else obs_h
145-
if total_dist > 0:
146-
los_height = obs_h + (tgt_h - obs_h) * (distance / total_dist)
147-
else:
148-
los_height = np.array([obs_h])
206+
tgt_h = (elevations[-1] + target_elev) if n > 1 else obs_h
207+
freq_hz = frequency_mhz * 1e6 if frequency_mhz is not None else -1.0
149208

150-
# visibility: track max elevation angle from observer
151-
visible = np.ones(n, dtype=bool)
152-
max_angle = -np.inf
153-
for i in range(1, n):
154-
if distance[i] == 0:
155-
continue
156-
angle = (elevations[i] - obs_h) / distance[i]
157-
if angle >= max_angle:
158-
max_angle = angle
159-
else:
160-
visible[i] = False
209+
distance, los_height, visible, fresnel, fresnel_clear = _los_kernel(
210+
xs, ys, elevations, obs_h, tgt_h, freq_hz,
211+
)
161212

162213
data_vars = {
163214
'distance': ('sample', distance),
@@ -169,16 +220,6 @@ def line_of_sight(
169220
}
170221

171222
if frequency_mhz is not None:
172-
freq_hz = frequency_mhz * 1e6
173-
fresnel = np.zeros(n, dtype=np.float64)
174-
fresnel_clear = np.ones(n, dtype=bool)
175-
for i in range(n):
176-
d1 = distance[i]
177-
d2 = total_dist - d1
178-
fresnel[i] = _fresnel_radius_1(d1, d2, freq_hz)
179-
clearance = los_height[i] - elevations[i]
180-
if clearance < fresnel[i]:
181-
fresnel_clear[i] = False
182223
data_vars['fresnel_radius'] = ('sample', fresnel)
183224
data_vars['fresnel_clear'] = ('sample', fresnel_clear)
184225

0 commit comments

Comments
 (0)