@@ -66,3 +66,92 @@ def test_dask_matches_numpy(self):
6666 elev_np , _ , _ = _extract_transect (raster_np , cells )
6767 elev_da , _ , _ = _extract_transect (raster_dask , cells )
6868 np .testing .assert_array_equal (elev_np , elev_da )
69+
70+
71+ from xrspatial .visibility import line_of_sight
72+
73+
74+ class TestLineOfSight :
75+ def test_flat_terrain_all_visible (self ):
76+ data = np .zeros ((5 , 10 ), dtype = float )
77+ raster = _make_raster (data )
78+ result = line_of_sight (raster , x0 = 0 , y0 = 2 , x1 = 9 , y1 = 2 ,
79+ observer_elev = 10 , target_elev = 10 )
80+ assert isinstance (result , xr .Dataset )
81+ assert 'visible' in result
82+ assert 'elevation' in result
83+ assert 'los_height' in result
84+ assert 'distance' in result
85+ assert result ['visible' ].all ()
86+
87+ def test_obstruction_blocks_view (self ):
88+ data = np .zeros ((1 , 10 ), dtype = float )
89+ data [0 , 5 ] = 100 # tall wall in the middle
90+ raster = _make_raster (data )
91+ result = line_of_sight (raster , x0 = 0 , y0 = 0 , x1 = 9 , y1 = 0 ,
92+ observer_elev = 1 , target_elev = 0 )
93+ vis = result ['visible' ].values
94+ # observer cell is visible
95+ assert vis [0 ]
96+ # cells before the wall are visible
97+ assert all (vis [:6 ])
98+ # at least some cells after the wall are blocked
99+ assert not all (vis [6 :])
100+
101+ def test_observer_equals_target (self ):
102+ data = np .ones ((5 , 5 ), dtype = float )
103+ raster = _make_raster (data )
104+ result = line_of_sight (raster , x0 = 2 , y0 = 2 , x1 = 2 , y1 = 2 )
105+ assert len (result ['sample' ]) == 1
106+ assert result ['visible' ].values [0 ]
107+
108+ def test_elevation_offsets (self ):
109+ data = np .zeros ((1 , 5 ), dtype = float )
110+ raster = _make_raster (data )
111+ result = line_of_sight (raster , x0 = 0 , y0 = 0 , x1 = 4 , y1 = 0 ,
112+ observer_elev = 10 , target_elev = 20 )
113+ los = result ['los_height' ].values
114+ # LOS starts at 10, ends at 20
115+ assert abs (los [0 ] - 10.0 ) < 1e-10
116+ assert abs (los [- 1 ] - 20.0 ) < 1e-10
117+
118+ def test_distance_monotonic (self ):
119+ data = np .zeros ((5 , 10 ), dtype = float )
120+ raster = _make_raster (data )
121+ result = line_of_sight (raster , x0 = 0 , y0 = 0 , x1 = 9 , y1 = 4 )
122+ d = result ['distance' ].values
123+ assert all (d [i ] <= d [i + 1 ] for i in range (len (d ) - 1 ))
124+
125+ def test_fresnel_zone (self ):
126+ data = np .zeros ((1 , 11 ), dtype = float )
127+ raster = _make_raster (data )
128+ result = line_of_sight (raster , x0 = 0 , y0 = 0 , x1 = 10 , y1 = 0 ,
129+ observer_elev = 50 , target_elev = 50 ,
130+ frequency_mhz = 900 )
131+ assert 'fresnel_radius' in result
132+ assert 'fresnel_clear' in result
133+ # midpoint has largest Fresnel radius
134+ fr = result ['fresnel_radius' ].values
135+ mid = len (fr ) // 2
136+ assert fr [mid ] >= fr [1 ]
137+ assert fr [mid ] >= fr [- 2 ]
138+ # with 50m clearance and flat terrain, Fresnel should be clear
139+ assert result ['fresnel_clear' ].all ()
140+
141+ def test_no_fresnel_by_default (self ):
142+ data = np .zeros ((5 , 5 ), dtype = float )
143+ raster = _make_raster (data )
144+ result = line_of_sight (raster , x0 = 0 , y0 = 0 , x1 = 4 , y1 = 4 )
145+ assert 'fresnel_radius' not in result
146+ assert 'fresnel_clear' not in result
147+
148+ def test_xy_coords_in_output (self ):
149+ data = np .zeros ((5 , 10 ), dtype = float )
150+ raster = _make_raster (data )
151+ result = line_of_sight (raster , x0 = 0 , y0 = 2 , x1 = 9 , y1 = 2 )
152+ # first point should match observer
153+ assert abs (result ['x' ].values [0 ] - 0.0 ) < 1e-10
154+ assert abs (result ['y' ].values [0 ] - 2.0 ) < 1e-10
155+ # last point should match target
156+ assert abs (result ['x' ].values [- 1 ] - 9.0 ) < 1e-10
157+ assert abs (result ['y' ].values [- 1 ] - 2.0 ) < 1e-10
0 commit comments