Skip to content

Add tropical cyclones basin bounds #1031

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

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Removed:

### Added

- `climada.hazard.tc_tracks.BASINS_BOUNDS` dictionary [#1031](https://github.com/CLIMADA-project/climada_python/pull/1031)
- `climada.hazard.tc_tracks.TCTracks.subset_years` function [#1023](https://github.com/CLIMADA-project/climada_python/pull/1023)
- `climada.hazard.tc_tracks.TCTracks.from_FAST` function, add Australia basin (AU) [#993](https://github.com/CLIMADA-project/climada_python/pull/993)
- Add `osm-flex` package to CLIMADA core [#981](https://github.com/CLIMADA-project/climada_python/pull/981)
Expand Down
159 changes: 157 additions & 2 deletions climada/hazard/tc_tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
import re
import shutil
import warnings
from operator import itemgetter
from collections import defaultdict
from enum import Enum
from pathlib import Path
from typing import List, Optional

Expand All @@ -50,7 +51,8 @@
from matplotlib.collections import LineCollection
from matplotlib.colors import BoundaryNorm, ListedColormap
from matplotlib.lines import Line2D
from shapely.geometry import LineString, MultiLineString, Point
from shapely.geometry import LineString, MultiLineString, Point, Polygon
from shapely.ops import unary_union
from sklearn.metrics import DistanceMetric

import climada.hazard.tc_tracks_synth
Expand Down Expand Up @@ -193,7 +195,98 @@
dataset using STORM. Scientific Data 7(1): 40."""


class Basin(Enum):
"""
Store tropical cyclones basin geographical extent.
The boundaries of the basin are represented as a polygon (using the `shapely` Polygon object)
and follows the definition of the STORM dataset. Important note: tropical cyclone boundaries
may vary bewteen datasets. The following boundaries follows the STORM definition:
https://www.nature.com/articles/s41597-020-0381-2

Attributes:
----------
*name : str
The name of the tropical cyclone basin (e.g., "NA" for North Atlantic).
*polygon : Polygon
A shapely Polygon object that represents the geographical boundary of the basin.

"""

NA = Polygon(
[
(-100, 19),
(-94.21951983987083, 17.039584804350312),
(-88.75211790888072, 14.837521327451947),
(-84.96610530622198, 12.214318798718033),
(-84.89823142225451, 12.181148019885352),
(-82.59052306410497, 8.777858931465238),
(-81.09730008320902, 8.358383265470449),
(-79.50226644452471, 9.196860922133856),
(-78.58597052442947, 9.213610839871123),
(-77.02487377167459, 7.299350879751048),
(-77.02487377167459, 5),
(0.0, 5.0),
(0.0, 60.0),
(-100.0, 60.0),
(-100, 19),
]
)

EP = Polygon(
[
(-180.0, 5.0),
(-77.02487377167459, 5),
(-77.02487377167459, 7.299350879751048),
(-78.58597052442947, 9.213610839871123),
(-79.50226644452471, 9.196860922133856),
(-81.09730008320902, 8.358383265470449),
(-82.59052306410497, 8.777858931465238),
(-84.89823142225451, 12.181148019885352),
(-84.96610530622198, 12.214318798718033),
(-88.75211790888072, 14.837521327451947),
(-94.21951983987083, 17.039584804350312),
(-100, 19),
(-100.0, 60.0),
(-180.0, 60.0),
(-180.0, 5.0),
]
)

WP = Polygon(
[(100.0, 5.0), (180.0, 5.0), (180.0, 60.0), (100.0, 60.0), (100.0, 5.0)]
)

NI = Polygon([(30.0, 5.0), (100.0, 5.0), (100.0, 60.0), (30.0, 60.0), (30.0, 5.0)])

SI = Polygon(
[(10.0, -60.0), (135.0, -60.0), (135.0, -5.0), (10.0, -5.0), (10.0, -60.0)]
)

SP = unary_union(
[
Polygon( # west side of antimeridian
[
(135.0, -60.0),
(180.0, -60.0),
(180.0, -5.0),
(135.0, -5.0),
(135.0, -60.0),
]
),
Polygon( # east side
[
(-180.0, -60.0),
(-120.0, -60.0),
(-120.0, -5.0),
(-180.0, -5.0),
(-180.0, -60.0),
]
),
]
)


class TCTracks:

Check warning on line 289 in climada/hazard/tc_tracks.py

View check run for this annotation

Jenkins - WCR / Pylint

too-many-public-methods

LOW: Too many public methods (35/20)
Raw output
Used when class has too many public methods, try to reduce this to get asimpler (and so easier to use) class.
"""Contains tropical cyclone tracks.

Attributes
Expand Down Expand Up @@ -322,7 +415,69 @@

return out

def subset_by_basin(self):
"""Subset all tropical cyclones tracks by basin.

This function iterates through the tropical cyclones in the dataset and assigns each cyclone
to a basin based on its geographical location. It checks whether the cyclone's position
(latitude and longitude) lies within the boundaries of any of the predefined basins and
then groups the cyclones into separate categories for each basin. The resulting dictionary
maps each basin's name to a list of tropical cyclones that fall within it.

Parameters
----------
self : TCTtracks object
The object instance containing the tropical cyclone data (`self.data`) to be processed.

Returns
-------
dict_tc_basins : dict
A dictionary where the keys are basin names (e.g., "NA", "EP", "WP", etc.) and the
values are instances of the `TCTracks` class containing the tropical cyclones that
belong to each basin.

Example:
--------
>>> tc = TCTracks.from_ibtracks("")
Copy link
Collaborator

Choose a reason for hiding this comment

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

as an example this is suboptimal:

>>> tc = TCTracks.from_ibtracks("")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: type object 'TCTracks' has no attribute 'from_ibtracks'

>>> tc_basins = tc.split_by_basin()
>>> tc_basins["NA"] # to access tracks in the North Atlantic

"""

# Initialize a defaultdict to store lists for each basin
basins_dict = defaultdict(list)
tracks_outside_basin: list = []
# Iterate over each tropical cyclone
for track in self.data:
lat, lon = track.lat.values[0], track.lon.values[0]
origin_point = Point(lon, lat)
point_in_basin = False

# Find the basin that contains the point
for basin in Basin:
if basin.value.contains(origin_point):
basins_dict[basin.name].append(track)
point_in_basin = True
break

if not point_in_basin:
tracks_outside_basin.append(track.id_no)
Comment on lines +452 to +464
Copy link
Collaborator

Choose a reason for hiding this comment

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

@NicolasColombi as to 1. and 2.
I would first define a method that assigns basins to a track:

BASINS_GDF = gpd.GeoDataFrame(
    {'basin': b, 'geometry': b.value} for b in Basin
)

def get_basins(track):  # this is the method I had in mind for 1. and I'd guess it could be a performance boost
    track_coordinates = GeoDataFrame(
        geometry=gpd.points_from_xy(track.lon, track.lat)
    )
    return track_coordinates.sjoin(BASINS_GDF , how='left', predicate='within').basin

Copy link
Collaborator

Choose a reason for hiding this comment

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

Having that I'd write the track loop like this:

for track in self.data:
    touched = get_basins(track).dropna().drop_duplicates()
    if touched.size:
        for basin in touched:
            basins_dict[basin].append(track)
    else:
        tracks_outside_basin.append(track)


if tracks_outside_basin:
warnings.warn(
f"A total of {len(tracks_outside_basin)} tracks did not originate in any of the \n"
f"defined basins. IDs of the tracks outside the basins: {tracks_outside_basin}",
UserWarning,
)

# Create a dictionary with TCTracks for each basin
dict_tc_basins = {
basin_name: TCTracks(tc_list) for basin_name, tc_list in basins_dict.items()
}

return dict_tc_basins

def subset_year(

Check warning on line 480 in climada/hazard/tc_tracks.py

View check run for this annotation

Jenkins - WCR / Pylint

too-complex

LOW: 'subset_year' is too complex. The McCabe rating is 11
Raw output
no description found

Check warning on line 480 in climada/hazard/tc_tracks.py

View check run for this annotation

Jenkins - WCR / Pylint

too-many-locals

LOW: Too many local variables (20/15)
Raw output
Used when a function or method has too many local variables.
self,
start_date: tuple = (False, False, False),
end_date: tuple = (False, False, False),
Expand Down
22 changes: 22 additions & 0 deletions climada/hazard/test/test_tc_tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,28 @@ def test_subset_years(self):
):
tc_test.subset_year((2100, False, False), (2150, False, False))

def test_subset_basin(self):
"""test the correct splitting of a single tc object into different tc objets by basin"""

tc_test = tc.TCTracks.from_simulations_emanuel(TEST_TRACK_EMANUEL)
tc_test.data[-1].lat[0] = 0 # modify lat of track to exclude it from a basin

with self.assertWarnsRegex(
UserWarning,
"A total of 1 tracks did not originate in any of the \n"
"defined basins. IDs of the tracks outside the basins: \[4\]",
):
dict_basins = tc_test.subset_by_basin()

self.assertEqual(dict_basins["EP"].data[0].lat[0].item(), 12.553)
self.assertEqual(dict_basins["EP"].data[0].lon[0].item(), -109.445)
self.assertEqual(dict_basins["SI"].data[0].lat[0].item(), -8.699)
self.assertEqual(dict_basins["SI"].data[0].lon[0].item(), 52.761)
self.assertEqual(dict_basins["WP"].data[0].lat[0].item(), 8.502)
self.assertEqual(dict_basins["WP"].data[0].lon[0].item(), 164.909)
self.assertEqual(dict_basins["WP"].data[1].lat[0].item(), 16.234)
self.assertEqual(dict_basins["WP"].data[1].lon[0].item(), 116.424)

def test_get_extent(self):
"""Test extent/bounds attributes."""
storms = ["1988169N14259", "2002073S16161", "2002143S07157"]
Expand Down
Loading