1111 Elevation profile and visibility along a straight line between two points.
1212"""
1313
14+ import math
15+
1416import numpy as np
1517import 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
1923SPEED_OF_LIGHT = 299_792_458.0 # m/s
2024
2125
26+ @ngjit
2227def _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
4857def _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
7484def _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
83156def 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