Skip to content

Commit 64b7b7c

Browse files
Merge branch 'develop' into feature/density_tracks
2 parents 466eb74 + 4b93d1d commit 64b7b7c

File tree

3 files changed

+271
-1
lines changed

3 files changed

+271
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ Removed:
107107
### Added
108108

109109
- `climada.entity.impact_funcs.trop_cyclone.ImpfSetTropCyclone.get_impf_id_regions_per_countries` function [#1034](https://github.com/CLIMADA-project/climada_python/pull/1034)
110+
- `climada.hazard.tc_tracks.BasinBoundsStorm` Enum class `climada.hazard.tc_tracks.subset_by_basin` function [#1031](https://github.com/CLIMADA-project/climada_python/pull/1031)
110111
- `climada.hazard.tc_tracks.TCTracks.subset_years` function [#1023](https://github.com/CLIMADA-project/climada_python/pull/1023)
111112
-`climada.hazard.tc_tracks.compute_track_density` function, `climada.hazard.tc_tracks.compute_genesis_density` function, `climada.hazard.plot.plot_track_density` function
112113
[#1003](https://github.com/CLIMADA-project/climada_python/pull/1003)

climada/hazard/tc_tracks.py

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
import re
2929
import shutil
3030
import warnings
31+
32+
from collections import defaultdict
33+
from enum import Enum
3134
from pathlib import Path
3235
from typing import List, Optional
3336

@@ -50,7 +53,8 @@
5053
from matplotlib.collections import LineCollection
5154
from matplotlib.colors import BoundaryNorm, ListedColormap
5255
from matplotlib.lines import Line2D
53-
from shapely.geometry import LineString, MultiLineString, Point
56+
from shapely.geometry import LineString, MultiLineString, Point, Polygon
57+
from shapely.ops import unary_union
5458
from sklearn.metrics import DistanceMetric
5559
from tqdm import tqdm
5660

@@ -194,6 +198,97 @@
194198
dataset using STORM. Scientific Data 7(1): 40."""
195199

196200

201+
class BasinBoundsStorm(Enum):
202+
"""
203+
Store tropical cyclones basin geographical extent.
204+
The boundaries of the basin are represented as a polygon (using the `shapely` Polygon object)
205+
and follows the definition of the STORM dataset. Important note: tropical cyclone boundaries
206+
may vary bewteen datasets. The following boundaries follows the STORM definition:
207+
https://www.nature.com/articles/s41597-020-0381-2
208+
209+
Attributes:
210+
----------
211+
*name : str
212+
The name of the tropical cyclone basin (e.g., "NA" for North Atlantic).
213+
*polygon : Polygon
214+
A shapely Polygon object that represents the geographical boundary of the basin.
215+
216+
"""
217+
218+
NA = Polygon(
219+
[
220+
(-100, 19),
221+
(-94.21951983987083, 17.039584804350312),
222+
(-88.75211790888072, 14.837521327451947),
223+
(-84.96610530622198, 12.214318798718033),
224+
(-84.89823142225451, 12.181148019885352),
225+
(-82.59052306410497, 8.777858931465238),
226+
(-81.09730008320902, 8.358383265470449),
227+
(-79.50226644452471, 9.196860922133856),
228+
(-78.58597052442947, 9.213610839871123),
229+
(-77.02487377167459, 7.299350879751048),
230+
(-77.02487377167459, 5),
231+
(0.0, 5.0),
232+
(0.0, 60.0),
233+
(-100.0, 60.0),
234+
(-100, 19),
235+
]
236+
)
237+
238+
EP = Polygon(
239+
[
240+
(-180.0, 5.0),
241+
(-77.02487377167459, 5),
242+
(-77.02487377167459, 7.299350879751048),
243+
(-78.58597052442947, 9.213610839871123),
244+
(-79.50226644452471, 9.196860922133856),
245+
(-81.09730008320902, 8.358383265470449),
246+
(-82.59052306410497, 8.777858931465238),
247+
(-84.89823142225451, 12.181148019885352),
248+
(-84.96610530622198, 12.214318798718033),
249+
(-88.75211790888072, 14.837521327451947),
250+
(-94.21951983987083, 17.039584804350312),
251+
(-100, 19),
252+
(-100.0, 60.0),
253+
(-180.0, 60.0),
254+
(-180.0, 5.0),
255+
]
256+
)
257+
258+
WP = Polygon(
259+
[(100.0, 5.0), (180.0, 5.0), (180.0, 60.0), (100.0, 60.0), (100.0, 5.0)]
260+
)
261+
262+
NI = Polygon([(30.0, 5.0), (100.0, 5.0), (100.0, 60.0), (30.0, 60.0), (30.0, 5.0)])
263+
264+
SI = Polygon(
265+
[(10.0, -60.0), (135.0, -60.0), (135.0, -5.0), (10.0, -5.0), (10.0, -60.0)]
266+
)
267+
268+
SP = unary_union(
269+
[
270+
Polygon( # west side of antimeridian
271+
[
272+
(135.0, -60.0),
273+
(180.0, -60.0),
274+
(180.0, -5.0),
275+
(135.0, -5.0),
276+
(135.0, -60.0),
277+
]
278+
),
279+
Polygon( # east side
280+
[
281+
(-180.0, -60.0),
282+
(-120.0, -60.0),
283+
(-120.0, -5.0),
284+
(-180.0, -5.0),
285+
(-180.0, -60.0),
286+
]
287+
),
288+
]
289+
)
290+
291+
197292
class TCTracks:
198293
"""Contains tropical cyclone tracks.
199294
@@ -323,6 +418,96 @@ def subset(self, filterdict):
323418

324419
return out
325420

421+
def get_basins(track):
422+
"""Identify the tropical-cyclone basins crossed by a single track.
423+
424+
Provides the basin name for every point along the track.
425+
The basins are defined according to the STORM definition:
426+
https://www.nature.com/articles/s41597-020-0381-2
427+
The names are:
428+
WP: West Pacific
429+
NA: North Atlantic
430+
NI: North Indian
431+
SP: South Pacific
432+
SI: South Indian
433+
EP: East Pacific
434+
435+
Parameters
436+
----------
437+
track : xarray.Dataset
438+
Tropical cyclone track
439+
Returns
440+
-------
441+
pandas.Series
442+
A Series of basin identifiers (e.g., "NA", "EP", "WP", …), one for each
443+
track point. Points that fall outside any basin have a value of ``NaN``.
444+
The index matches the index of the input track coordinates."""
445+
446+
basins_gdf = gpd.GeoDataFrame(
447+
{"basin": b, "geometry": b.value} for b in BasinBoundsStorm
448+
)
449+
450+
# convert 0-360 to -180 +180 longitude
451+
lon = u_coord.lon_normalize(track.lon)
452+
453+
track_coordinates = gpd.GeoDataFrame(
454+
geometry=gpd.points_from_xy(lon, track.lat)
455+
)
456+
return track_coordinates.sjoin(basins_gdf, how="left", predicate="within").basin
457+
458+
def subset_by_basin(self, origin: bool = False):
459+
"""Subset all tropical cyclones tracks by basin.
460+
461+
This function collects for every basin the tracks that crossed them. The resulting dictionary
462+
maps each basin's name to a list of tropical cyclones tracks that intersected them.
463+
464+
Parameters
465+
----------
466+
self : TCTtracks object
467+
The object instance containing the tropical cyclone data (`self.data`).
468+
origin : bool
469+
Either True or False. If True, the outputs basin will contain only the tracks that originated there.
470+
If False, every track that crossed a basin will be present in the basin.
471+
472+
Returns
473+
-------
474+
dict_tc_basins : dict
475+
A dictionary where the keys are basin names (e.g., "NA", "EP", "WP", etc.) and the
476+
values are instances of the `TCTracks` class: effectively all tracks that intersected or originated
477+
in each basin, depending on the argument "origin".
478+
tracks_outside_basin : list
479+
A list of all tracks that did not cross any basin.
480+
481+
"""
482+
483+
basins_dict: dict = defaultdict(list)
484+
tracks_outside_basin: list = []
485+
486+
for track in self.data:
487+
# if only origin basin is of interest (origin = True)
488+
if origin:
489+
origin_basin = TCTracks.get_basins(track)[0]
490+
if not isinstance(origin_basin, float): # nan are evaluated as floats
491+
basins_dict[origin_basin.name].append(track)
492+
else:
493+
tracks_outside_basin.append(track)
494+
else: # if every basin crossed is of interest (origin = False)
495+
touched = TCTracks.get_basins(track).dropna().drop_duplicates()
496+
if touched.size:
497+
for basin in touched:
498+
basins_dict[basin.name].append(track)
499+
else:
500+
tracks_outside_basin.append(track)
501+
502+
# return TCTracks objetcs
503+
for basin in BasinBoundsStorm:
504+
if not basins_dict[basin.name]:
505+
basins_dict[basin.name] = TCTracks([])
506+
else:
507+
basins_dict[basin.name] = TCTracks(basins_dict[basin.name])
508+
509+
return basins_dict, TCTracks(tracks_outside_basin)
510+
326511
def subset_year(
327512
self,
328513
start_date: tuple = (False, False, False),

climada/hazard/test/test_tc_tracks.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,90 @@ def test_subset_years(self):
900900
):
901901
tc_test.subset_year((2100, False, False), (2150, False, False))
902902

903+
def test_subset_basin(self):
904+
"""test the correct splitting of a single tc object into different tc objets by basin"""
905+
906+
# EMMANUEL tracks
907+
tc_test = tc.TCTracks.from_simulations_emanuel(TEST_TRACK_EMANUEL)
908+
909+
# all basins
910+
dict_basins, tracks_outside_basin = tc_test.subset_by_basin(origin=False)
911+
912+
self.assertEqual(len(dict_basins["NA"].data), 1)
913+
self.assertEqual(len(dict_basins["EP"].data), 2)
914+
self.assertEqual(len(dict_basins["WP"].data), 2)
915+
self.assertEqual(len(dict_basins["NI"].data), 0)
916+
self.assertEqual(len(dict_basins["SI"].data), 1)
917+
self.assertEqual(len(dict_basins["SP"].data), 0)
918+
self.assertEqual(len(tracks_outside_basin.data), 0)
919+
920+
# only origin basin
921+
dict_basins, tracks_outside_basin = tc_test.subset_by_basin(origin=True)
922+
923+
self.assertEqual(len(dict_basins["NA"].data), 0)
924+
self.assertEqual(len(dict_basins["EP"].data), 2)
925+
self.assertEqual(len(dict_basins["WP"].data), 2)
926+
self.assertEqual(len(dict_basins["NI"].data), 0)
927+
self.assertEqual(len(dict_basins["SI"].data), 1)
928+
self.assertEqual(len(dict_basins["SP"].data), 0)
929+
self.assertEqual(len(tracks_outside_basin.data), 0)
930+
931+
# STORM tracks
932+
tc_test = tc.TCTracks.from_simulations_storm(TEST_TRACK_STORM)
933+
# only origin basin (True) and all crossed basins (False)
934+
for bool in [True, False]:
935+
dict_basins, tracks_outside_basin = tc_test.subset_by_basin(origin=bool)
936+
937+
self.assertEqual(len(dict_basins["NA"].data), 0)
938+
self.assertEqual(len(dict_basins["EP"].data), 6)
939+
self.assertEqual(len(dict_basins["WP"].data), 0)
940+
self.assertEqual(len(dict_basins["NI"].data), 0)
941+
self.assertEqual(len(dict_basins["SI"].data), 0)
942+
self.assertEqual(len(dict_basins["SP"].data), 0)
943+
self.assertEqual(len(tracks_outside_basin.data), 0)
944+
945+
# FAST tracks
946+
tc_test = tc.TCTracks.from_FAST(TEST_TRACK_FAST)
947+
# only origin basin (True) and all crossed basins (False)
948+
for bool in [True, False]:
949+
dict_basins, tracks_outside_basin = tc_test.subset_by_basin(origin=bool)
950+
951+
self.assertEqual(len(dict_basins["NA"].data), 5)
952+
self.assertEqual(len(dict_basins["EP"].data), 0)
953+
self.assertEqual(len(dict_basins["WP"].data), 0)
954+
self.assertEqual(len(dict_basins["NI"].data), 0)
955+
self.assertEqual(len(dict_basins["SI"].data), 0)
956+
self.assertEqual(len(dict_basins["SP"].data), 0)
957+
self.assertEqual(len(tracks_outside_basin.data), 0)
958+
959+
# CHAZ tracks
960+
tc_test = tc.TCTracks.from_simulations_chaz(TEST_TRACK_CHAZ)
961+
# only origin basin (True) and all crossed basins (False)
962+
for bool in [True, False]:
963+
dict_basins, tracks_outside_basin = tc_test.subset_by_basin(origin=True)
964+
965+
self.assertEqual(len(dict_basins["NA"].data), 0)
966+
self.assertEqual(len(dict_basins["EP"].data), 0)
967+
self.assertEqual(len(dict_basins["WP"].data), 0)
968+
self.assertEqual(len(dict_basins["NI"].data), 0)
969+
self.assertEqual(len(dict_basins["SI"].data), 6)
970+
self.assertEqual(len(dict_basins["SP"].data), 7)
971+
self.assertEqual(len(tracks_outside_basin.data), 0)
972+
973+
# GETTELMAN tracks
974+
tc_test = tc.TCTracks.from_gettelman(TEST_TRACK_GETTELMAN)
975+
# only origin basin (True) and all crossed basins (False)
976+
for bool in [True, False]:
977+
dict_basins, tracks_outside_basin = tc_test.subset_by_basin(origin=True)
978+
979+
self.assertEqual(len(dict_basins["NA"].data), 0)
980+
self.assertEqual(len(dict_basins["EP"].data), 0)
981+
self.assertEqual(len(dict_basins["WP"].data), 2)
982+
self.assertEqual(len(dict_basins["NI"].data), 1)
983+
self.assertEqual(len(dict_basins["SI"].data), 0)
984+
self.assertEqual(len(dict_basins["SP"].data), 0)
985+
self.assertEqual(len(tracks_outside_basin.data), 0)
986+
903987
def test_get_extent(self):
904988
"""Test extent/bounds attributes."""
905989
storms = ["1988169N14259", "2002073S16161", "2002143S07157"]

0 commit comments

Comments
 (0)