Skip to content

Commit 747cf32

Browse files
authored
Merge pull request #27 from GeoscienceAustralia/upgrade/improved_antimeridian_logic
Upgrade/improved antimeridian logic
2 parents 14f5e5f + 38b3415 commit 747cf32

File tree

7 files changed

+382
-147
lines changed

7 files changed

+382
-147
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,40 @@ get_cop30_dem_for_bounds(
9797

9898
```
9999

100+
### Antimeridian
101+
102+
Requesting data across the antimeridian requires correctly formatted bounds. The antimeridian is a special case where the `left` value of the bounds is greater than the `right`, e.g left = 178 (eastern hemisphere) and right = -179 (western hemisphere). If bounds such as these are passed in, the dem-handler logic will return a mosaiced DEM that crossess the antimeridian projected in the most appropriate crs. For example, 3031 for high latitudes.
103+
104+
```python
105+
# specify bounds over the antimeridian
106+
# bounds tuple must be (left, bottom, right, top)
107+
bounds = (178, -66, -179, -60)
108+
109+
get_cop30_dem_for_bounds(
110+
bounds = bounds,
111+
...,
112+
)
113+
```
114+
115+
If a set of bounds look like they *may* cross the antimeridian, but are incorrectly formatted, a warning will be raised, but the process will run as normal.
116+
117+
```python
118+
# A valid set of bounds that almost entirely wraps the earth, and may therefore
119+
# be a misformatted set of bounds over the antimeridian
120+
bounds = (-179, -66, 178, -60)
121+
122+
# the functions will run as intended but a warning will be raised
123+
get_cop30_dem_for_bounds(
124+
bounds = bounds,
125+
...,
126+
)
127+
128+
>>>WARNING:dem_handler.dem.cop_glo30:Provided bounds have very large longitude extent. If the shape crosses the antimeridian, reformat the bounds as : (178, -66, -179, -60)
129+
```
130+
131+
132+
133+
100134
## Install
101135

102136
Currently, we only support installing `dem-handler` from source.

dem_handler/dem/cop_glo30.py

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414

1515
from 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
)
@@ -32,7 +32,7 @@
3232

3333
logger = 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)
3636
BBox = BoundingBox | tuple[float | int, float | int, float | int, float | int]
3737

3838
from 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

434533
def 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

dem_handler/dem/geoid.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def read_geoid(
2828
geoid_path : str | Path
2929
Path to the GEOID file
3030
bounds : tuple
31-
the set of bounds (min_lon, min_lat, max_lon, max_lat)
31+
the set of bounds (left, bottom, right, top)
3232
buffer_pixels : int, optional
3333
additional pixels to buffern around bounds, by default 0
3434

dem_handler/dem/rema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from dem_handler.download.aws import download_egm_08_geoid
2222

2323

24-
# Create a custom type that allows use of BoundingBox or tuple(xmin, ymin, xmax, ymax)
24+
# Create a custom type that allows use of BoundingBox or tuple(left, bottom, right, top)
2525
BBox = BoundingBox | tuple[float | int, float | int, float | int, float | int]
2626

2727
from dem_handler import REMA_GPKG_PATH, REMA_VALID_RESOLUTIONS, REMAResolutions

0 commit comments

Comments
 (0)