Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added get_bounds functions for EPSG 4326 #980

Merged
merged 8 commits into from
Dec 19, 2024
88 changes: 88 additions & 0 deletions climada/util/coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,7 @@
return str(resolution) + "m"


def get_country_geometries(

Check warning on line 729 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Pylint

too-many-locals

LOW: Too many local variables (19/15)
Raw output
Used when a function or method has too many local variables.

Check warning on line 729 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Pylint

too-many-branches

LOW: Too many branches (13/12)
Raw output
Used when a function or method has too many branches, making it hard tofollow.
country_names=None, extent=None, resolution=10, center_crs=True
):
"""Natural Earth country boundaries within given extent
Expand Down Expand Up @@ -783,12 +783,22 @@
if country_names:
if isinstance(country_names, str):
country_names = [country_names]

# print warning if ISO code not recognized
for country_name in country_names:
if not country_name in nat_earth[["ISO_A3", "WB_A3", "ADM0_A3"]].values:
LOGGER.warning(f"ISO code {country_name} not recognized.")

Check warning on line 790 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Pylint

logging-fstring-interpolation

NORMAL: Use lazy % formatting in logging functions
Raw output
no description found

Check warning on line 790 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Code Coverage

Not covered line

Line 790 is not covered by tests
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't it be better to simply throw an exception in case the country is not recognized?
I can't see the point of filtering out the unrecognizable countries in here. If a user runs this with a typo in one of the country names would they be upset if it fails?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I agree, this would be better. I changed it to raise a ValueError now.

My only doubt is that the function util.coordinates.get_country_geometries() existed before and if you input a list of ISO codes with one invalid code, it did NOT raise an error before (this is why I "only" added a warning). Wouldn't this be a potential problem for existing code to break down due to the error, or is it better to make aware that the input was not correct?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@emanuel-schmid What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Imo, it is better to make them finally aware that their input has been wrong all the time.
We just need to add a note in the change log.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, agreed. I'm adapting the change log accordingly.


country_mask = np.isin(
nat_earth[["ISO_A3", "WB_A3", "ADM0_A3"]].values,
country_names,
).any(axis=1)
out = out[country_mask]

# exit with Value error if no country code was recognized
if out.size == 0:
raise ValueError(f"None of the given country codes were recognized.")

Check warning on line 800 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Pylint

f-string-without-interpolation

NORMAL: Using an f-string that does not have any interpolated variables
Raw output
no description found

Check warning on line 800 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Code Coverage

Not covered line

Line 800 is not covered by tests

if extent:
if extent[1] - extent[0] > 360:
raise ValueError(
Expand Down Expand Up @@ -1687,6 +1697,84 @@
return admin1_info, admin1_shapes


def global_bounding_box():
"""
Return global bounds in EPSG 4326

Returns
-------
tuple:
The global bounding box as (min_lon, min_lat, max_lon, max_lat)
"""
return (-180, -90, 180, 90)

Check warning on line 1709 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Code Coverage

Not covered line

Line 1709 is not covered by tests


def get_country_bounding_box(country_names, buffer=1.0):
"""
Return bounding box in EPSG 4326 containing given countries.

Parameters
----------
country_names : list or str
list with ISO 3166 alpha-3 codes of countries, e.g ['ZWE', 'GBR', 'VNM', 'UZB']
buffer : float, optional
Buffer to add to both sides of the bounding box. Default: 1.0.

Returns
-------
tuple
The bounding box containing all given coutries as (min_lon, min_lat, max_lon, max_lat)
"""

country_geometry = get_country_geometries(country_names).geometry
longitudes, latitudes = [], []
for multipolygon in country_geometry:
for polygon in multipolygon.geoms: # Loop through each polygon
for coord in polygon.exterior.coords: # Extract exterior coordinates
longitudes.append(coord[0])
latitudes.append(coord[1])

return latlon_bounds(np.array(latitudes), np.array(longitudes), buffer=buffer)


def bounds_from_cardinal_bounds(*, northern, eastern, western, southern):
"""
Return and normalize bounding box in EPSG 4326 from given cardinal bounds.

Parameters
----------
northern : (int, float)
Northern boundary of bounding box
eastern : (int, float)
Eastern boundary of bounding box
western : (int, float)
Western boundary of bounding box
southern : (int, float)
Southern boundary of bounding box

Returns
-------
tuple
The resulting normalized bounding box (min_lon, min_lat, max_lon, max_lat) with -180 <= min_lon < max_lon < 540

Check warning on line 1758 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Pylint

line-too-long

LOW: Line too long (119/100)
Raw output
Used when a line is longer than a given number of characters.

"""

# latitude bounds check
if not ((90 >= northern > southern >= -90)):

Check warning on line 1763 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Pylint

superfluous-parens

LOW: Unnecessary parens after 'not' keyword
Raw output
Used when a single item in parentheses follows an if, for, or other keyword.
raise ValueError(

Check warning on line 1764 in climada/util/coordinates.py

View check run for this annotation

Jenkins - WCR / Code Coverage

Not covered line

Line 1764 is not covered by tests
"Given northern bound is below given southern bound or out of bounds"
)

eastern = (eastern + 180) % 360 - 180
western = (western + 180) % 360 - 180

# Ensure eastern > western
if western > eastern:
eastern += 360

return (western, southern, eastern, northern)


def get_admin1_geometries(countries):
"""
return geometries, names and codes of admin 1 regions in given countries
Expand Down
64 changes: 64 additions & 0 deletions climada/util/test/test_coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2294,6 +2294,69 @@ def test_mask_raster_with_geometry(self):
)


class TestBoundsFromUserInput(unittest.TestCase):
"""Unit tests for the bounds_from_user_input function."""

def global_bounding_box(self):
"""Test for 'global' area selection."""
result = u_coord.global_bounding_box()
expected = (-180, -90, 180, 90)
np.testing.assert_almost_equal(result, expected)

def test_get_country_bounding_box(self):
"""Test for a list of ISO country codes."""
result = u_coord.get_country_bounding_box(
["ITA"], buffer=1.0
) # Testing with Italy (ITA)
# Real expected bounds for Italy (calculated or manually known)
expected = [
5.6027283120000675,
34.48924388200004,
19.517425977000073,
48.08521494500006,
] # Italy's bounding box

np.testing.assert_array_almost_equal(result, expected, decimal=4)

def test_bounds_from_cardinal_bounds(self):
"""Test for conversion from cardinal bounds to bounds."""
np.testing.assert_array_almost_equal(
u_coord.bounds_from_cardinal_bounds(
northern=90, southern=-20, eastern=30, western=20
),
(20, -20, 30, 90),
)
np.testing.assert_array_almost_equal(
u_coord.bounds_from_cardinal_bounds(
northern=90, southern=-20, eastern=20, western=30
),
(30, -20, 380, 90),
)
np.testing.assert_array_almost_equal(
u_coord.bounds_from_cardinal_bounds(
northern=90, southern=-20, eastern=170, western=-170
),
(-170, -20, 170, 90),
)
np.testing.assert_array_almost_equal(
u_coord.bounds_from_cardinal_bounds(
northern=90, southern=-20, eastern=-170, western=170
),
(170, -20, 190, 90),
)
np.testing.assert_array_almost_equal(
u_coord.bounds_from_cardinal_bounds(
northern=90, southern=-20, eastern=170, western=175
),
(175, -20, 530, 90),
)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should add tests for invalid bounding box.

def test_invalid_input_string(self):
"""Test for invalid string input."""
with self.assertRaises(Exception):
u_coord.get_bound("invalid_ISO")


# Execute Tests
if __name__ == "__main__":
TESTS = unittest.TestLoader().loadTestsFromTestCase(TestFunc)
Expand All @@ -2302,4 +2365,5 @@ def test_mask_raster_with_geometry(self):
TESTS.addTests(unittest.TestLoader().loadTestsFromTestCase(TestRasterMeta))
TESTS.addTests(unittest.TestLoader().loadTestsFromTestCase(TestRasterIO))
TESTS.addTests(unittest.TestLoader().loadTestsFromTestCase(TestDistance))
TESTS.addTests(unittest.TestLoader().loadTestsFromTestCase(TestBoundsFromUserInput))
unittest.TextTestRunner(verbosity=2).run(TESTS)
Loading