33import os .path
44import re
55import warnings
6+ from typing import Literal
67
78import geoutils as gu
89import numpy as np
910import pytest
11+ import rasterio as rio
1012from geoutils .raster .distributed_computing import MultiprocConfig
13+ from pyproj import CRS
1114
1215import xdem
1316
14- xdem .examples .download_longyearbyen_examples ()
15-
16- PLOT = True
17+ PLOT = False
1718
1819
1920class TestTerrainAttribute :
@@ -201,19 +202,6 @@ def test_attribute_functions_against_richdem(self, attribute: str, get_test_data
201202 # output = functions_richdem[attribute](dem)
202203 # assert np.all(dem.data.mask == output.data.mask)
203204
204- def test_hillshade_errors (self ) -> None :
205- """Validate that the hillshade function raises appropriate errors."""
206- # Try giving the hillshade invalid arguments.
207-
208- with pytest .raises (ValueError , match = "Azimuth must be a value between 0 and 360" ):
209- xdem .terrain .hillshade (self .dem .data , resolution = self .dem .res , azimuth = 361 )
210-
211- with pytest .raises (ValueError , match = "Altitude must be a value between 0 and 90" ):
212- xdem .terrain .hillshade (self .dem .data , resolution = self .dem .res , altitude = 91 )
213-
214- with pytest .raises (ValueError , match = "z_factor must be a non-negative finite value" ):
215- xdem .terrain .hillshade (self .dem .data , resolution = self .dem .res , z_factor = np .inf )
216-
217205 def test_hillshade (self ) -> None :
218206 """Test hillshade-specific settings."""
219207
@@ -229,6 +217,19 @@ def test_hillshade(self) -> None:
229217 # A low altitude should be darker than a high altitude.
230218 assert np .nanmean (low_altitude ) < np .nanmean (high_altitude )
231219
220+ def test_hillshade__errors (self ) -> None :
221+ """Validate that the hillshade function raises appropriate errors."""
222+ # Try giving the hillshade invalid arguments.
223+
224+ with pytest .raises (ValueError , match = "Azimuth must be a value between 0 and 360" ):
225+ xdem .terrain .hillshade (self .dem .data , resolution = self .dem .res , azimuth = 361 )
226+
227+ with pytest .raises (ValueError , match = "Altitude must be a value between 0 and 90" ):
228+ xdem .terrain .hillshade (self .dem .data , resolution = self .dem .res , altitude = 91 )
229+
230+ with pytest .raises (ValueError , match = "z_factor must be a non-negative finite value" ):
231+ xdem .terrain .hillshade (self .dem .data , resolution = self .dem .res , z_factor = np .inf )
232+
232233 @pytest .mark .parametrize (
233234 "name" , ["curvature" , "planform_curvature" , "profile_curvature" , "maximum_curvature" ]
234235 ) # type: ignore
@@ -239,21 +240,25 @@ def test_curvatures(self, name: str) -> None:
239240 dem = self .dem .copy ()
240241
241242 # Derive curvature without any gaps
242- curvature = xdem .terrain .get_terrain_attribute (
243- dem .data , attribute = name , resolution = dem .res , edge_method = "nearest"
244- )
243+ curvature = xdem .terrain .get_terrain_attribute (dem .data , attribute = name , resolution = dem .res )
245244
246- # Validate that the array has the same shape as the input and that all values are finite.
245+ # Validate that the array has the same shape as the input and that all non-edge values are finite.
247246 assert curvature .shape == dem .data .shape
248247 try :
249- assert np .all (np .isfinite (curvature ))
248+ assert np .all (np .isfinite (curvature [ 1 : - 1 , 1 : - 1 ] ))
250249 except Exception :
251250 import matplotlib .pyplot as plt
252251
253252 plt .imshow (curvature .squeeze ())
254253 plt .show ()
255254
256- with pytest .raises (ValueError , match = "Quadric surface fit requires the same X and Y resolution." ):
255+ with pytest .raises (
256+ ValueError ,
257+ match = re .escape (
258+ f"Surface fit and rugosity require the same X and Y resolution "
259+ f"((1.0, 2.0) was given). This was required by: ['{ name } ']."
260+ ),
261+ ):
257262 xdem .terrain .get_terrain_attribute (dem .data , attribute = name , resolution = (1.0 , 2.0 ))
258263
259264 # Introduce some nans
@@ -263,7 +268,7 @@ def test_curvatures(self, name: str) -> None:
263268 # Validate that this doesn't raise weird warnings after introducing nans.
264269 xdem .terrain .get_terrain_attribute (dem .data , attribute = name , resolution = dem .res )
265270
266- def test_get_terrain_attribute (self ) -> None :
271+ def test_get_terrain_attribute__multiple_inputs (self ) -> None :
267272 """Test the get_terrain_attribute function by itself."""
268273
269274 # Validate that giving only one terrain attribute only returns that, and not a list of len() == 1
@@ -286,7 +291,7 @@ def test_get_terrain_attribute(self) -> None:
286291 slope_lowres = xdem .terrain .get_terrain_attribute (self .dem .data , "slope" , resolution = self .dem .res [0 ] * 2 )
287292 assert np .nanmean (slope ) > np .nanmean (slope_lowres )
288293
289- def test_get_terrain_attribute_multiproc (self ) -> None :
294+ def test_get_terrain_attribute__multiproc (self ) -> None :
290295 """Test the get_terrain attribute function in multiprocessing."""
291296 outfile = "mp_output.tif"
292297 outfile_multi = ["mp_output_slope.tif" , "mp_output_aspect.tif" , "mp_output_hillshade.tif" ]
@@ -336,7 +341,7 @@ def test_get_terrain_attribute_multiproc(self) -> None:
336341 os .remove (outfile )
337342 assert slope .get_stats ("mean" ) > slope_lowres .get_stats ("mean" )
338343
339- def test_get_terrain_attribute_errors (self ) -> None :
344+ def test_get_terrain_attribute__errors (self ) -> None :
340345 """Test the get_terrain_attribute function raises appropriate errors."""
341346
342347 # Below, re.escape() is needed to match expressions that have special characters (e.g., parenthesis, bracket)
@@ -346,15 +351,25 @@ def test_get_terrain_attribute_errors(self) -> None:
346351 "Slope method 'DoesNotExist' is not supported. Must be one of: " "['Horn', 'ZevenbergThorne']"
347352 ),
348353 ):
349- xdem .terrain .slope (self .dem .data , method = "DoesNotExist" )
354+ xdem .terrain .slope (self .dem .data , resolution = self . dem . res , method = "DoesNotExist" ) # type: ignore
350355
351356 with pytest .raises (
352357 ValueError ,
353358 match = re .escape ("TRI method 'DoesNotExist' is not supported. Must be one of: " "['Riley', 'Wilson']" ),
354359 ):
355- xdem .terrain .terrain_ruggedness_index (self .dem .data , method = "DoesNotExist" )
360+ xdem .terrain .terrain_ruggedness_index (self .dem .data , method = "DoesNotExist" ) # type: ignore
356361
357- def test_raster_argument (self ) -> None :
362+ # Check warning for geographic CRS
363+ data = np .ones ((5 , 5 ))
364+ transform = rio .transform .from_bounds (0 , 0 , 1 , 1 , 5 , 5 )
365+ crs = CRS ("EPSG:4326" )
366+ nodata = - 9999
367+ dem = xdem .DEM .from_array (data , transform = transform , crs = crs , nodata = nodata )
368+ with pytest .warns (match = "DEM is not in a projected CRS.*" ):
369+ xdem .terrain .get_terrain_attribute (dem , "slope" )
370+
371+ def test_get_terrain_attribute__raster_input (self ) -> None :
372+ """Test the get_terrain_attribute function supports raster input/output."""
358373
359374 slope , aspect = xdem .terrain .get_terrain_attribute (self .dem , attribute = ["slope" , "aspect" ])
360375
@@ -384,9 +399,9 @@ def test_rugosity_jenness(self) -> None:
384399 assert rugosity [1 , 1 ] == pytest .approx (r , rel = 10 ** (- 4 ))
385400
386401 # Loop for various elevation differences with the center
387- @pytest .mark .parametrize ("dh" , np .linspace (0.01 , 100 , 10 )) # type: ignore
402+ @pytest .mark .parametrize ("dh" , np .linspace (0.01 , 100 , 3 )) # type: ignore
388403 # Loop for different resolutions
389- @pytest .mark .parametrize ("resolution" , np .linspace (0.01 , 100 , 10 )) # type: ignore
404+ @pytest .mark .parametrize ("resolution" , np .linspace (0.01 , 100 , 3 )) # type: ignore
390405 def test_rugosity_simple_cases (self , dh : float , resolution : float ) -> None :
391406 """Test the rugosity calculation for simple cases."""
392407
@@ -415,36 +430,134 @@ def test_rugosity_simple_cases(self, dh: float, resolution: float) -> None:
415430 # Check rugosity value is valid
416431 assert r == pytest .approx (rugosity [1 , 1 ], rel = 10 ** (- 6 ))
417432
418- def test_get_quadric_coefficients (self ) -> None :
419- """Test the outputs and exceptions of the get_quadric_coefficients() function."""
433+ def test_fractal_roughness (self ) -> None :
434+ """Test fractal roughness for synthetic cases for which we know the output."""
435+
436+ # The fractal dimension of a line is 1 (a single pixel with non-zero value)
437+ dem = np .zeros ((13 , 13 ), dtype = "float32" )
438+ dem [1 , 1 ] = 6.5
439+ frac_rough = xdem .terrain .fractal_roughness (dem )
440+ assert np .round (frac_rough [6 , 6 ], 5 ) == np .float32 (1.0 )
441+
442+ # The fractal dimension of plane is 2 (a plan of pixels with non-zero values)
443+ dem = np .zeros ((13 , 13 ), dtype = "float32" )
444+ dem [:, 1 ] = 13
445+ frac_rough = xdem .terrain .fractal_roughness (dem )
446+ assert np .round (frac_rough [6 , 6 ]) == np .float32 (2.0 )
447+
448+ # The fractal dimension of a cube is 3 (a block of pixels with non-zero values
449+ dem = np .zeros ((13 , 13 ), dtype = "float32" )
450+ dem [:, :6 ] = 13
451+ frac_rough = xdem .terrain .fractal_roughness (dem )
452+ assert np .round (frac_rough [6 , 6 ]) == np .float32 (3.0 )
453+
454+ def test_convolution__quadric_coefficients (self ) -> None :
455+ """Test the outputs of quadric coefficients (not accessible by users)."""
420456
421457 dem = np .array ([[1 , 1 , 1 ], [1 , 2 , 1 ], [1 , 1 , 1 ]], dtype = "float32" )
422458
423- coefficients = xdem .terrain .get_quadric_coefficients (
424- dem , resolution = 1.0 , edge_method = "nearest" , make_rugosity = True
425- )
459+ # Get all coefficients and convolve middle mixel
460+ coef_arrs = list (xdem .terrain .all_coefs .values ())
461+ coef_names = list (xdem .terrain .all_coefs .keys ())
462+ kern3d = np .stack (coef_arrs , axis = 0 )
463+ coefs = xdem .spatialstats .convolution (
464+ dem .reshape ((1 , dem .shape [0 ], dem .shape [1 ])), filters = kern3d , method = "scipy"
465+ ).squeeze ()[:, 1 , 1 ]
466+
467+ # The 4th to last coefficient is identity, so the dem itself
468+ assert np .array_equal (coefs [coef_names .index ("zt_i" )], dem [1 , 1 ])
469+
470+ # The third should be concave in the x-direction
471+ assert coefs [coef_names .index ("zt_d" )] < 0
472+
473+ # The fourth should be concave in the y-direction
474+ assert coefs [coef_names .index ("zt_e" )] < 0
475+
476+ def test_convolution_equal__engine (self ) -> None :
477+ """
478+ Check that convolution through SciPy or Numba give equal result for all kernels.
479+ This calls the convolution subfunctions directly (as they need to be chained sequentially with other
480+ steps in the main functions).
481+ """
482+
483+ # Stack to convolve all coefs at once
484+ coef_arrs = list (xdem .terrain .all_coefs .values ())
485+ kern3d = np .stack (coef_arrs , axis = 0 )
486+
487+ rnd = np .random .default_rng (42 )
488+ dem = rnd .normal (size = (5 , 7 ))
489+
490+ # With SciPy
491+ conv_scipy = xdem .spatialstats .convolution (
492+ dem .reshape ((1 , dem .shape [0 ], dem .shape [1 ])), filters = kern3d , method = "scipy"
493+ ).squeeze ()[:, 3 , 3 ]
426494
427- # Check all coefficients are finite with an edge method
428- assert np .all (np .isfinite (coefficients ))
495+ # With Numba
496+ _ , M1 , M2 = kern3d .shape
497+ half_M1 = int ((M1 - 1 ) / 2 )
498+ half_M2 = int ((M2 - 1 ) / 2 )
499+ dem = np .pad (dem , pad_width = ((half_M1 , half_M1 ), (half_M2 , half_M2 )), constant_values = np .nan )
500+ conv_numba = xdem .terrain ._convolution_numba (dem , filters = kern3d , row = 3 , col = 3 )
429501
430- # The 4th to last coefficient is the dem itself (could maybe be removed in the future as it is duplication..)
431- assert np .array_equal (coefficients [- 4 , :, :], dem )
502+ np .allclose (conv_scipy , conv_numba , equal_nan = True )
432503
433- # The middle pixel (index 1, 1) should be concave in the x-direction
434- assert coefficients [3 , 1 , 1 ] < 0
504+ @pytest .mark .parametrize (
505+ "attribute" ,
506+ ["slope" , "aspect" , "hillshade" , "curvature" , "profile_curvature" , "planform_curvature" , "maximum_curvature" ],
507+ ) # type: ignore
508+ @pytest .mark .parametrize ("slope_method" , ["Horn" , "ZevenbergThorne" ]) # type: ignore
509+ def test_get_surface_attributes__engine (
510+ self , attribute : str , slope_method : Literal ["Horn" , "ZevenbergThorne" ]
511+ ) -> None :
512+ """Check that all quadric coefficients from the convolution give the same results as with the numba loop."""
513+
514+ rnd = np .random .default_rng (42 )
515+ dem = rnd .normal (size = (5 , 7 ))
435516
436- # The middle pixel (index 1, 1) should be concave in the y-direction
437- assert coefficients [4 , 1 , 1 ] < 0
517+ attrs_scipy = xdem .terrain ._get_surface_attributes (
518+ dem = dem , resolution = 2 , surface_attributes = [attribute ], slope_method = slope_method , engine = "scipy"
519+ )
520+ attrs_numba = xdem .terrain ._get_surface_attributes (
521+ dem = dem , resolution = 2 , surface_attributes = [attribute ], slope_method = slope_method , engine = "numba"
522+ )
438523
439- with pytest .raises (ValueError , match = "Invalid input array shape" ):
440- xdem .terrain .get_quadric_coefficients (dem .reshape ((1 , 1 , - 1 )), 1.0 )
524+ assert np .allclose (attrs_scipy , attrs_numba , equal_nan = True )
525+ # assert np.allclose(coefs_numba, coefs_numba_cv, equal_nan=True)
526+
527+ @pytest .mark .parametrize (
528+ "attribute" ,
529+ [
530+ "topographic_position_index" ,
531+ "terrain_ruggedness_index_Riley" ,
532+ "terrain_ruggedness_index_Wilson" ,
533+ "roughness" ,
534+ "rugosity" ,
535+ "fractal_roughness" ,
536+ ],
537+ ) # type: ignore
538+ def test_get_windowed_indices__engine (self , attribute : str ) -> None :
539+ """Check that all quadric coefficients from the convolution give the same results as with the numba loop."""
540+
541+ rnd = np .random .default_rng (42 )
542+ dem = rnd .normal (size = (15 , 15 ))
543+
544+ # Get TRI method if specified
545+ if "Wilson" in attribute or "Riley" in attribute :
546+ attribute = "terrain_ruggedness_index"
547+ tri_method : Literal ["Riley" , "Wilson" ]
548+ tri_method = attribute .split ("_" )[- 1 ] # type: ignore
549+ # Otherwise use any one, doesn't matter
550+ else :
551+ tri_method = "Wilson"
552+
553+ attrs_scipy = xdem .terrain ._get_windowed_indexes (
554+ dem = dem , window_size = 3 , resolution = 1 , windowed_indexes = [attribute ], tri_method = tri_method , engine = "scipy"
555+ )
556+ attrs_numba = xdem .terrain ._get_windowed_indexes (
557+ dem = dem , window_size = 3 , resolution = 1 , windowed_indexes = [attribute ], tri_method = tri_method , engine = "numba"
558+ )
441559
442- # Validate that when using the edge_method="none", only the one non-edge value is kept.
443- coefs = xdem .terrain .get_quadric_coefficients (dem , resolution = 1.0 , edge_method = "none" )
444- assert np .count_nonzero (np .isfinite (coefs [0 , :, :])) == 1
445- # When using edge wrapping, all coefficients should be finite.
446- coefs = xdem .terrain .get_quadric_coefficients (dem , resolution = 1.0 , edge_method = "wrap" )
447- assert np .count_nonzero (np .isfinite (coefs [0 , :, :])) == 9
560+ assert np .allclose (attrs_scipy , attrs_numba , equal_nan = True )
448561
449562 def test_get_terrain_attribute__out_dtype (self ) -> None :
450563
0 commit comments