1414
1515from dem_handler .utils .spatial import (
1616 BoundingBox ,
17- check_s1_bounds_cross_antimeridian ,
17+ check_bounds_likely_cross_antimeridian ,
1818 get_target_antimeridian_projection ,
19- split_s1_bounds_at_am_crossing ,
19+ split_bounds_at_antimeridian ,
2020 adjust_bounds_at_high_lat ,
2121 crop_datasets_to_bounds ,
2222)
3232
3333logger = logging .getLogger (__name__ )
3434
35- # Create a custom type that allows use of BoundingBox or tuple(xmin, ymin, xmax, ymax )
35+ # Create a custom type that allows use of BoundingBox or tuple(left, bottom, right, top )
3636BBox = BoundingBox | tuple [float | int , float | int , float | int , float | int ]
3737
3838from dem_handler import COP30_GPKG_PATH
@@ -56,6 +56,93 @@ def get_cop30_dem_for_bounds(
5656 return_paths : bool = False ,
5757 download_dir : Path | None = None ,
5858):
59+ """
60+ Retrieve and mosaic COPDEM GLO-30 (Copernicus 30 m DEM) tiles covering a specified geographic bounding box.
61+
62+ This function locates and optionally downloads the Copernicus GLO-30 Digital Elevation Model (DEM)
63+ tiles that intersect the requested bounding box. It handles high-latitude adjustments, buffering,
64+ ellipsoidal height conversion using a geoid model, and special cases where the bounds cross
65+ the antimeridian (±180° longitude).
66+
67+ The function can return either a merged DEM array with metadata or a list of intersecting DEM tile paths.
68+
69+ Parameters
70+ ----------
71+ bounds : BBox or tuple
72+ The geographic bounding box of interest, either as a `BBox` object or a tuple
73+ `(left, bottom, right, top)` in degrees.
74+ save_path : Path
75+ File path where the output DEM (GeoTIFF) will be saved.
76+ ellipsoid_heights : bool, optional
77+ If True, converts DEM heights from the geoid reference to ellipsoidal heights
78+ using the EGM08 geoid model. Default is True.
79+ adjust_at_high_lat : bool, optional
80+ If True, expands the bounds near the poles to ensure adequate DEM coverage.
81+ Default is False.
82+ buffer_pixels : int or None, optional
83+ Optional pixel buffer applied around the requested bounds to include a margin of DEM data.
84+ Default is None.
85+ buffer_degrees : int or float or None, optional
86+ Optional geographic buffer in degrees around the bounds. Default is None.
87+ cop30_index_path : Path, optional
88+ Path to the COPDEM GLO-30 index GeoPackage (`.gpkg`) used to locate intersecting DEM tiles.
89+ Default is `COP30_GPKG_PATH`.
90+ cop30_folder_path : Path, optional
91+ Directory containing the COPDEM GLO-30 tiles. Default is the current directory.
92+ geoid_tif_path : Path, optional
93+ Path to the local geoid model GeoTIFF (e.g., `egm_08_geoid.tif`) used for height conversion.
94+ Default is `"egm_08_geoid.tif"`.
95+ download_dem_tiles : bool, optional
96+ If True, automatically downloads any missing DEM tiles required to cover the requested bounds.
97+ Default is False.
98+ download_geoid : bool, optional
99+ If True, downloads the EGM08 geoid model if it does not exist locally.
100+ Default is False.
101+ num_cpus : int, optional
102+ Number of CPU cores to use for parallel tasks such as downloading or merging tiles.
103+ Default is 1.
104+ num_tasks : int or None, optional
105+ Number of parallel tasks to execute when searching or downloading tiles.
106+ Default is None (serial execution).
107+ return_paths : bool, optional
108+ If True, returns only a list of file paths to intersecting DEM tiles rather than reading or merging them.
109+ Default is False.
110+ download_dir : Path or None, optional
111+ Directory where downloaded DEM tiles or geoid files should be saved. Default is None (current directory).
112+
113+ Returns
114+ -------
115+ tuple or list
116+ If `return_paths=True`, returns:
117+ list of Path
118+ File paths to the intersecting COPDEM GLO-30 tiles.
119+ Otherwise, returns:
120+ tuple (dem_array, dem_profile, dem_paths)
121+ - dem_array : numpy.ndarray
122+ The merged DEM raster data covering the requested bounds.
123+ - dem_profile : dict
124+ Raster metadata/profile dictionary compatible with `rasterio`.
125+ - dem_paths : list of Path
126+ The DEM tile paths used to produce the merged output.
127+
128+ Raises
129+ ------
130+ FileExistsError
131+ If the geoid model file does not exist locally and `download_geoid=False`.
132+ ValueError
133+ If the input bounds are invalid or cannot be processed.
134+ RuntimeError
135+ If DEM merging or reprojection fails during processing.
136+
137+ Notes
138+ -----
139+ - If the bounds cross the antimeridian the function recursively processes each
140+ side of the antimeridian, reprojects them into a common coordinate reference system,
141+ and merges them into a continuous DEM.
142+ - The DEM heights are geoid-referenced by default (EGM08 model). Set `ellipsoid_heights=True`
143+ to obtain ellipsoidal heights (WGS84).
144+ - At high latitudes or near the poles, `adjust_at_high_lat=True` can help include complete DEM coverage.
145+ """
59146
60147 # Convert bounding box to built-in bounding box type
61148 if isinstance (bounds , tuple ):
@@ -65,9 +152,21 @@ def get_cop30_dem_for_bounds(
65152 logger .info (f"Getting cop30m dem for bounds: { bounds .bounds } " )
66153
67154 # Check if bounds cross the antimeridian
68- antimeridian_crossing = check_s1_bounds_cross_antimeridian (
69- bounds , max_scene_width = 15
70- )
155+ if bounds .left > bounds .right :
156+ logger .warning (
157+ f"left longitude value ({ bounds [0 ]} ) is greater than the right longitude value { ({bounds [2 ]})} "
158+ f"for the bounds provided. Assuming the bounds cross the antimeridian : { bounds } "
159+ )
160+ antimeridian_crossing = True
161+ else :
162+ antimeridian_crossing = False
163+ # run a basic to check if the bounds likely cross the antimeridian but
164+ # are just formatted wrong. If so, warn the user.
165+ if check_bounds_likely_cross_antimeridian (bounds ):
166+ logger .warning (
167+ "Provided bounds have very large longitude extent. If the shape crosses the "
168+ f"antimeridian, reformat the bounds as : ({ bounds [2 ]} , { bounds [1 ]} , { bounds [0 ]} , { bounds [3 ]} )"
169+ )
71170
72171 if antimeridian_crossing :
73172 logger .warning (
@@ -77,7 +176,7 @@ def get_cop30_dem_for_bounds(
77176 target_crs = get_target_antimeridian_projection (bounds )
78177
79178 logger .info (f"Splitting bounds into left and right side of antimeridian" )
80- bounds_eastern , bounds_western = split_s1_bounds_at_am_crossing (bounds )
179+ bounds_eastern , bounds_western = split_bounds_at_antimeridian (bounds )
81180
82181 # Use recursion to process each side of the AM. The function is rerun
83182 # This time, antimeridian_crossing will be False enabling each side to be
@@ -168,7 +267,7 @@ def get_cop30_dem_for_bounds(
168267 output_path = save_path ,
169268 )
170269
171- return dem_array , dem_profile , eastern_output [2 ] + western_output [2 ]
270+ return dem_array [ 0 ] , dem_profile , eastern_output [2 ] + western_output [2 ]
172271
173272 else :
174273 # Adjust bounds at high latitude if requested
@@ -202,7 +301,7 @@ def get_cop30_dem_for_bounds(
202301 "The Cop30 DEM bounds do not fully cover the requested bounds. "
203302 "Try increasing the 'buffer_pixels' value. Note at the antimeridian "
204303 "This is expected, with bounds being slightly smaller on +ve side. "
205- "e.g. max_lon is 179.9999 < 180."
304+ "e.g. right is 179.9999 < 180."
206305 )
207306 logging .warning (warn_msg )
208307
@@ -391,7 +490,7 @@ def buffer_bounds_cop_glo30(
391490 Parameters
392491 ----------
393492 bounds : BoundingBox | tuple[float | int, float | int, float | int, float | int]
394- The set of bounds (min_lon, min_lat, max_lon, max_lat )
493+ The set of bounds (left, bottom, right, top )
395494 pixel_buffer : int | None, optional
396495 Number of pixels to buffer, by default None
397496 degree_buffer : float | int | None, optional
@@ -423,12 +522,12 @@ def buffer_bounds_cop_glo30(
423522 if degree_buffer :
424523 buffer = (degree_buffer , degree_buffer )
425524
426- new_xmin = max (bounds .xmin - buffer [0 ], - 180 )
427- new_ymin = max (bounds .ymin - buffer [1 ], - 90 )
428- new_xmax = min (bounds .xmax + buffer [0 ], 180 )
429- new_ymax = min (bounds .ymax + buffer [1 ], 90 )
525+ new_left = max (bounds .left - buffer [0 ], - 180 )
526+ new_bottom = max (bounds .bottom - buffer [1 ], - 90 )
527+ new_right = min (bounds .right + buffer [0 ], 180 )
528+ new_top = min (bounds .top + buffer [1 ], 90 )
430529
431- return BoundingBox (new_xmin , new_ymin , new_xmax , new_ymax )
530+ return BoundingBox (new_left , new_bottom , new_right , new_top )
432531
433532
434533def get_cop_glo30_spacing (
@@ -439,7 +538,7 @@ def get_cop_glo30_spacing(
439538 Parameters
440539 ----------
441540 bounds : BoundingBox | tuple[float | int, float | int, float | int, float | int]
442- The set of bounds (min_lon, min_lat, max_lon, max_lat )
541+ The set of bounds (left, bottom, right, top )
443542
444543 Returns
445544 -------
@@ -455,7 +554,7 @@ def get_cop_glo30_spacing(
455554 if isinstance (bounds , tuple ):
456555 bounds = BoundingBox (* bounds )
457556
458- mean_latitude = abs ((bounds .ymin + bounds .ymax ) / 2 )
557+ mean_latitude = abs ((bounds .bottom + bounds .top ) / 2 )
459558
460559 minimum_pixel_spacing = 0.0002777777777777778
461560
@@ -531,7 +630,7 @@ def make_empty_cop_glo30_profile_for_bounds(
531630 Parameters
532631 ----------
533632 bounds : BoundingBox | tuple[float | int, float | int, float | int, float | int]
534- The set of bounds (min_lon, min_lat, max_lon, max_lat )
633+ The set of bounds (left, bottom, right, top )
535634 pixel_buffer | int
536635 The number of pixels to add as a buffer to the profile
537636
@@ -552,7 +651,7 @@ def make_empty_cop_glo30_profile_for_bounds(
552651 spacing_lon , spacing_lat = get_cop_glo30_spacing (bounds )
553652
554653 glo30_transform = get_cop_glo30_tile_transform (
555- bounds .xmin , bounds .ymax , spacing_lon , spacing_lat
654+ bounds .left , bounds .top , spacing_lon , spacing_lat
556655 )
557656
558657 # Expand the bounds to the edges of pixels
0 commit comments