Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9c9150b
add bounds
NicolasColombi Mar 23, 2025
a111937
update CHANGELOG
NicolasColombi Mar 23, 2025
f37212f
modify NA
NicolasColombi Mar 23, 2025
7e82b8f
modify NI
NicolasColombi Mar 24, 2025
030bcb8
make basin a data class, add function to split by basin
NicolasColombi Mar 24, 2025
73889b9
Merge branch 'develop' into feature/basins-bounds
NicolasColombi Mar 24, 2025
cadf062
convert data cls to enum cls
NicolasColombi Mar 25, 2025
5a5c128
Merge branch 'develop' into feature/basins-bounds
NicolasColombi Apr 8, 2025
6056e6b
add test and fix pylits
NicolasColombi Apr 8, 2025
6e497ac
rename enum class
NicolasColombi Apr 22, 2025
200a4eb
implement Emanuel method and add origin arg
NicolasColombi Aug 21, 2025
a72876d
Merge branch 'develop' into feature/basins-bounds
NicolasColombi Aug 21, 2025
9ffa59c
add docstrings, return TCTtracks, move basin GDF
NicolasColombi Sep 19, 2025
53b5bd6
Merge branch 'develop' into feature/basins-bounds
NicolasColombi Sep 19, 2025
9ca2ce3
update changelog
NicolasColombi Sep 19, 2025
5695e89
Update climada/hazard/tc_tracks.py
NicolasColombi Sep 23, 2025
1f10bbc
Update CHANGELOG.md
NicolasColombi Sep 23, 2025
dc60e2f
Update climada/hazard/tc_tracks.py
NicolasColombi Sep 23, 2025
26e02b0
Update climada/hazard/tc_tracks.py
NicolasColombi Sep 23, 2025
734f968
Update climada/hazard/tc_tracks.py
NicolasColombi Sep 23, 2025
795071b
update test and fix lon format bug
NicolasColombi Sep 23, 2025
f582249
Merge branch 'develop' into feature/basins-bounds
NicolasColombi Sep 23, 2025
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)
Comment thread
chahank marked this conversation as resolved.
Outdated
- 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,6 +195,97 @@
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:
"""Contains tropical cyclone tracks.

Expand Down Expand Up @@ -322,6 +415,68 @@ def subset(self, filterdict):

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
Copy Markdown
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)

Copy link
Copy Markdown
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
Copy Markdown
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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks @emanuel-schmid for the update, sorry for the (very) late reply. If I understand it correctly, if a track crosses multiple basin it will be present in the output dictionary in all the basins that it crossed, which is fine, just different than attributing the origin basin, as I had in mind. If we want only the origin, I guess we can still use your method but select only the first row of the gdf ?

@chahank : Conceptually, do we want to assign basin to tracks based on the origin basin or all the basins they cross ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have no strong opinion, but I would say the second makes more sense as a default. Ideally, just allow for both.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ok! I implemented Emanuel version and allowed the flexibility of choosing only the origin basin or not.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@chahank I will be on holidays for the next two week, I think this PR should be ready now. If you require additional changes I will be available from Sept.15, or if the changes are really small, I might carve out some time tomorrow.


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(
self,
start_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)

Comment thread
chahank marked this conversation as resolved.
def test_get_extent(self):
"""Test extent/bounds attributes."""
storms = ["1988169N14259", "2002073S16161", "2002143S07157"]
Expand Down