From 8bb0886cdbfcf20c2bca00a97b722e20f4f09e9f Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Wed, 5 Feb 2025 14:22:53 +0100 Subject: [PATCH 01/38] add function density_track --- climada/hazard/tc_tracks.py | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index fce41053a..0cc07673c 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2867,3 +2867,46 @@ def _zlib_from_dataarray(data_var: xr.DataArray) -> bool: if np.issubdtype(data_var.dtype, float) or np.issubdtype(data_var.dtype, int): return True return False + + +def compute_track_density( + tc_track: TCTracks, res: int = 5, time_step: float = 0.5 +) -> tuple[np.ndarray, tuple]: + """Compute normalized tropical cyclone track density. + Parameters: + ---------- + res: int (optional) + resolution in degrees of the grid bins in which the density will be computed + time_step: float (optional) + temporal resolution in hours to be apllied to the tracks, to ensure that every track + will have the same resolution. + + Returns: + ------- + hist: 2D np.ndarray + 2D matrix containing the track density + """ + + # ensure equal time step + tc_track.equal_timestep(time_step) + + # Concatenate datasets along a new "track" dimension + all_tracks_ds = xr.concat(tc_track.data, dim="track") + + # Extract flattened latitude and longitude arrays (all values) + latitudes = all_tracks_ds["lat"].values.flatten() + longitudes = all_tracks_ds["lon"].values.flatten() + + # Define grid resolution and bounds for density computation + lat_bins = np.arange(-90, 90, res) # res-degree latitude bins + lon_bins = np.arange(-180, 180, res) # res-degree longitude bins + + # Compute 2D density + hist, lat_edges, lon_edges = np.histogram2d( + latitudes, longitudes, bins=[lat_bins, lon_bins] + ) + + # Normalized + hist = hist / hist.sum() + + return hist, lat_bins, lon_bins From 40d63e07915253440a51f8b69e769985f613c93c Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Thu, 6 Feb 2025 09:47:31 +0100 Subject: [PATCH 02/38] update --- climada/hazard/tc_tracks.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 0cc07673c..8b7c02a35 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2870,9 +2870,10 @@ def _zlib_from_dataarray(data_var: xr.DataArray) -> bool: def compute_track_density( - tc_track: TCTracks, res: int = 5, time_step: float = 0.5 + tc_track: TCTracks, res: int = 5, time_step: float = 0.5, mode: str = "normalized" ) -> tuple[np.ndarray, tuple]: - """Compute normalized tropical cyclone track density. + """Compute absolute and normalized tropical cyclone track density as the number of points per + grid cell. Parameters: ---------- res: int (optional) @@ -2880,6 +2881,8 @@ def compute_track_density( time_step: float (optional) temporal resolution in hours to be apllied to the tracks, to ensure that every track will have the same resolution. + mode: str (optional) + "normalized" or "absolute" density Returns: ------- @@ -2906,7 +2909,6 @@ def compute_track_density( latitudes, longitudes, bins=[lat_bins, lon_bins] ) - # Normalized - hist = hist / hist.sum() + hist = hist / hist.sum() if mode == "normalized" else hist return hist, lat_bins, lon_bins From 7e3a70e3e14b08977f7b23067cec063a2b7b2044 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Thu, 6 Feb 2025 11:22:38 +0100 Subject: [PATCH 03/38] add test --- climada/hazard/tc_tracks.py | 6 ++-- climada/hazard/test/test_tc_tracks.py | 50 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 8b7c02a35..15c088fb2 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2891,7 +2891,7 @@ def compute_track_density( """ # ensure equal time step - tc_track.equal_timestep(time_step) + # tc_track.equal_timestep(time_step_h=time_step) # Concatenate datasets along a new "track" dimension all_tracks_ds = xr.concat(tc_track.data, dim="track") @@ -2901,8 +2901,8 @@ def compute_track_density( longitudes = all_tracks_ds["lon"].values.flatten() # Define grid resolution and bounds for density computation - lat_bins = np.arange(-90, 90, res) # res-degree latitude bins - lon_bins = np.arange(-180, 180, res) # res-degree longitude bins + lat_bins = np.arange(-90, 91, res) # 91 and not 90 for the bins (90 included) + lon_bins = np.arange(-180, 181, res) # Compute 2D density hist, lat_edges, lon_edges = np.histogram2d( diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index 56005b51a..060475673 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -1202,6 +1202,56 @@ def test_track_land_params(self): on_land, ) + def test_compute_density_tracks(self): + """Test compute density track to ensure proper density count.""" + # create track + track = xr.Dataset( + { + "time_step": ("time", np.timedelta64(1, "h") * np.arange(4)), + "max_sustained_wind": ("time", [3600, 3600, 3600, 3600]), + "central_pressure": ("time", [3600, 3600, 3600, 3600]), + "radius_max_wind": ("time", [3600, 3600, 3600, 3600]), + "environnmental_pressure": ("time", [3600, 3600, 3600, 3600]), + "basin": ("time", ["NA", "NA", "NA", "NA"]), + }, + coords={ + "time": ("time", pd.date_range("2025-01-01", periods=4, freq="12H")), + "lat": ("time", [-90, -90, -90, -90]), + "lon": ("time", [-179, -169, -159, -149]), + }, + attrs={ + "max_sustained_wind_unit": "m/s", + "central_pressure_unit": "hPa", + "name": "storm_0", + "sid": "0", + "orig_event_flag": True, + "data_provider": "FAST", + "id_no": "0", + "category": "1", + }, + ) + + tc_tracks = tc.TCTracks([track]) + + hist_abs, lat_bins, lon_bins = tc.compute_track_density( + tc_tracks, time_step=1, res=10, mode="absolute" + ) + hist_norm, lat_bins, lon_bins = tc.compute_track_density( + tc_tracks, time_step=1, res=10, mode="normalized" + ) + + self.assertEqual( + hist_abs.shape, (18, 36) + ) # 18 latitude bins, 36 longitude bins + self.assertEqual(hist_abs.sum(), 4) # verify the density counts + self.assertAlmostEqual( + hist_norm.sum(), 1 + ) # sum of normalized density should be 1 + + # the track above occupy positions [0,0:4] of hist + np.testing.assert_array_equal(hist_abs[0, 0:4], [1, 1, 1, 1]) + np.testing.assert_array_equal(hist_norm[0, 0:4], [0.25, 0.25, 0.25, 0.25]) + # Execute Tests if __name__ == "__main__": From e0f20a3c7ff9d9f17ae84d5ff955e30a02308fa8 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Thu, 6 Feb 2025 11:58:04 +0100 Subject: [PATCH 04/38] update docstrings and changelog --- CHANGELOG.md | 1 + climada/hazard/tc_tracks.py | 17 +++++++++++------ climada/hazard/test/test_tc_tracks.py | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd94a2063..7c493684b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Code freeze date: YYYY-MM-DD ### Added +- `climada.hazard.tc_tracks.compute_track_density` function [#1003](https://github.com/CLIMADA-project/climada_python/pull/1003) - Add `osm-flex` package to CLIMADA core [#981](https://github.com/CLIMADA-project/climada_python/pull/981) - `doc.tutorial.climada_entity_Exposures_osm.ipynb` tutorial explaining how to use `osm-flex`with CLIMADA - `climada.util.coordinates.bounding_box_global` function [#980](https://github.com/CLIMADA-project/climada_python/pull/980) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 15c088fb2..0a30ce603 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2870,18 +2870,23 @@ def _zlib_from_dataarray(data_var: xr.DataArray) -> bool: def compute_track_density( - tc_track: TCTracks, res: int = 5, time_step: float = 0.5, mode: str = "normalized" + tc_track: TCTracks, res: int = 5, time_step: float = 1, mode: str = "absolute" ) -> tuple[np.ndarray, tuple]: - """Compute absolute and normalized tropical cyclone track density as the number of points per - grid cell. + """Compute absolute and normalized tropical cyclone track density. First, the function ensure + the same temporal resolution of all tracks by calling :py:meth:`equal_timestep`. Second, it + creates 2D bins of the specified resolution (e.g. 1° x 1°). Third, since tracks are not lines + but a series of points, it counts the number of points per bin. Lastly, it returns the absolute + or normalized count per bin. This function works under the hood of :py:meth:`plot_track_density` + but can be used separtly as input data for more sophisticated track density plots. + Parameters: ---------- - res: int (optional) + res: int (optional) Default: 5° resolution in degrees of the grid bins in which the density will be computed - time_step: float (optional) + time_step: float (optional) default: 1h temporal resolution in hours to be apllied to the tracks, to ensure that every track will have the same resolution. - mode: str (optional) + mode: str (optional) default: absolute "normalized" or "absolute" density Returns: diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index 060475673..2d9a0231e 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -1203,7 +1203,7 @@ def test_track_land_params(self): ) def test_compute_density_tracks(self): - """Test compute density track to ensure proper density count.""" + """Test :py:meth:`compute_track_density` to ensure proper density count.""" # create track track = xr.Dataset( { From cc406856fe43a45ddddc32b7c03030a8dfcd9057 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Thu, 6 Feb 2025 13:25:52 +0100 Subject: [PATCH 05/38] use np.linspace and scipy.sparse --- climada/hazard/tc_tracks.py | 20 ++++++++++---------- climada/hazard/test/test_tc_tracks.py | 19 ++++++------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 0a30ce603..4e92b7a43 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -49,6 +49,7 @@ from matplotlib.collections import LineCollection from matplotlib.colors import BoundaryNorm, ListedColormap from matplotlib.lines import Line2D +from scipy.sparse import csr_matrix from shapely.geometry import LineString, MultiLineString, Point from sklearn.metrics import DistanceMetric @@ -2870,7 +2871,7 @@ def _zlib_from_dataarray(data_var: xr.DataArray) -> bool: def compute_track_density( - tc_track: TCTracks, res: int = 5, time_step: float = 1, mode: str = "absolute" + tc_track: TCTracks, res: int = 5, time_step: float = 1, density: bool = True ) -> tuple[np.ndarray, tuple]: """Compute absolute and normalized tropical cyclone track density. First, the function ensure the same temporal resolution of all tracks by calling :py:meth:`equal_timestep`. Second, it @@ -2886,8 +2887,9 @@ def compute_track_density( time_step: float (optional) default: 1h temporal resolution in hours to be apllied to the tracks, to ensure that every track will have the same resolution. - mode: str (optional) default: absolute - "normalized" or "absolute" density + mode: bool (optional) default: True + If False it returns the number of samples in each bin. If True, returns the + probability density function at each bin computed as count_bin / tot_count * bin_area. Returns: ------- @@ -2906,14 +2908,12 @@ def compute_track_density( longitudes = all_tracks_ds["lon"].values.flatten() # Define grid resolution and bounds for density computation - lat_bins = np.arange(-90, 91, res) # 91 and not 90 for the bins (90 included) - lon_bins = np.arange(-180, 181, res) + lat_bins = np.linspace(-90, 90, int(180 / res)) + lon_bins = np.linspace(-180, 180, int(360 / res)) # Compute 2D density - hist, lat_edges, lon_edges = np.histogram2d( - latitudes, longitudes, bins=[lat_bins, lon_bins] + hist, _, _ = np.histogram2d( + latitudes, longitudes, bins=[lat_bins, lon_bins], density=density ) - hist = hist / hist.sum() if mode == "normalized" else hist - - return hist, lat_bins, lon_bins + return csr_matrix(hist), lat_bins, lon_bins diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index 2d9a0231e..65bddce3c 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -1234,23 +1234,16 @@ def test_compute_density_tracks(self): tc_tracks = tc.TCTracks([track]) hist_abs, lat_bins, lon_bins = tc.compute_track_density( - tc_tracks, time_step=1, res=10, mode="absolute" + tc_tracks, time_step=1, res=10, density=False ) hist_norm, lat_bins, lon_bins = tc.compute_track_density( - tc_tracks, time_step=1, res=10, mode="normalized" + tc_tracks, time_step=1, res=10, density=True ) - - self.assertEqual( - hist_abs.shape, (18, 36) - ) # 18 latitude bins, 36 longitude bins - self.assertEqual(hist_abs.sum(), 4) # verify the density counts - self.assertAlmostEqual( - hist_norm.sum(), 1 - ) # sum of normalized density should be 1 - + self.assertEqual(hist_abs.shape, (17, 35)) + self.assertEqual(hist_norm.shape, (17, 35)) + self.assertEqual(hist_abs.sum(), 4) # the track above occupy positions [0,0:4] of hist - np.testing.assert_array_equal(hist_abs[0, 0:4], [1, 1, 1, 1]) - np.testing.assert_array_equal(hist_norm[0, 0:4], [0.25, 0.25, 0.25, 0.25]) + np.testing.assert_array_equal(hist_abs.toarray()[0, 0:4], [1, 1, 1, 1]) # Execute Tests From fb7aa7cf76634b63db562be06a6d31b8893dd61b Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Thu, 6 Feb 2025 15:39:32 +0100 Subject: [PATCH 06/38] count only track once per grid cell --- climada/hazard/tc_tracks.py | 24 +++++++++++++++++------- climada/hazard/test/test_tc_tracks.py | 8 +++++++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 4e92b7a43..95ae8d3ae 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2871,7 +2871,7 @@ def _zlib_from_dataarray(data_var: xr.DataArray) -> bool: def compute_track_density( - tc_track: TCTracks, res: int = 5, time_step: float = 1, density: bool = True + tc_track: TCTracks, res: int = 5, time_step: float = 1, density: bool = False ) -> tuple[np.ndarray, tuple]: """Compute absolute and normalized tropical cyclone track density. First, the function ensure the same temporal resolution of all tracks by calling :py:meth:`equal_timestep`. Second, it @@ -2887,9 +2887,9 @@ def compute_track_density( time_step: float (optional) default: 1h temporal resolution in hours to be apllied to the tracks, to ensure that every track will have the same resolution. - mode: bool (optional) default: True + density: bool (optional) default: False If False it returns the number of samples in each bin. If True, returns the - probability density function at each bin computed as count_bin / tot_count * bin_area. + probability density function at each bin computed as count_bin / tot_count. Returns: ------- @@ -2912,8 +2912,18 @@ def compute_track_density( lon_bins = np.linspace(-180, 180, int(360 / res)) # Compute 2D density - hist, _, _ = np.histogram2d( - latitudes, longitudes, bins=[lat_bins, lon_bins], density=density - ) + hist_count = csr_matrix((len(lat_bins) - 1, len(lon_bins) - 1)) + for track in tc_track.data: + + # Compute 2D density + hist_new, _, _ = np.histogram2d( + track.lat.values, track.lon.values, bins=[lat_bins, lon_bins], density=False + ) + hist_new = csr_matrix(hist_new) + hist_new[hist_new > 1] = 1 + + hist_count += hist_new + + hist_count = hist_count / hist_count.sum() if density else hist_count - return csr_matrix(hist), lat_bins, lon_bins + return hist_count, lat_bins, lon_bins diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index 65bddce3c..58d9bfb14 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -1242,8 +1242,14 @@ def test_compute_density_tracks(self): self.assertEqual(hist_abs.shape, (17, 35)) self.assertEqual(hist_norm.shape, (17, 35)) self.assertEqual(hist_abs.sum(), 4) + self.assertEqual(hist_norm.sum(), 1) # the track above occupy positions [0,0:4] of hist - np.testing.assert_array_equal(hist_abs.toarray()[0, 0:4], [1, 1, 1, 1]) + np.testing.assert_array_equal( + hist_abs.toarray()[0, 0:4], [1, 1, 1, 1] + ) # .toarray() + np.testing.assert_array_equal( + hist_norm.toarray()[0, 0:4], [0.25, 0.25, 0.25, 0.25] + ) # .toarray() # Execute Tests From fea61a2c9f50b640e0e793c6dfcc2dd8f41909a0 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Thu, 6 Feb 2025 16:35:29 +0100 Subject: [PATCH 07/38] add argument to filter different tracks for density --- climada/hazard/tc_tracks.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 95ae8d3ae..d5fde797a 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2871,7 +2871,11 @@ def _zlib_from_dataarray(data_var: xr.DataArray) -> bool: def compute_track_density( - tc_track: TCTracks, res: int = 5, time_step: float = 1, density: bool = False + tc_track: TCTracks, + res: int = 5, + time_step: float = 1, + density: bool = False, + filter_tracks: bool = True, ) -> tuple[np.ndarray, tuple]: """Compute absolute and normalized tropical cyclone track density. First, the function ensure the same temporal resolution of all tracks by calling :py:meth:`equal_timestep`. Second, it @@ -2879,6 +2883,8 @@ def compute_track_density( but a series of points, it counts the number of points per bin. Lastly, it returns the absolute or normalized count per bin. This function works under the hood of :py:meth:`plot_track_density` but can be used separtly as input data for more sophisticated track density plots. + If filter track is True, only a maximum of one point is added to each grid cell for every track. + Hence, the resulting density will represent the number of differt tracksper grid cell. Parameters: ---------- @@ -2890,6 +2896,10 @@ def compute_track_density( density: bool (optional) default: False If False it returns the number of samples in each bin. If True, returns the probability density function at each bin computed as count_bin / tot_count. + filter_tracks: bool (optional) default: True + If True the track density is computed as the number of different tracks crossing a grid + cell. If False, the track density takes into account how long the track stayed in each + grid cell. Hence slower tracks increase the density if the parameter is set to False. Returns: ------- @@ -2900,13 +2910,6 @@ def compute_track_density( # ensure equal time step # tc_track.equal_timestep(time_step_h=time_step) - # Concatenate datasets along a new "track" dimension - all_tracks_ds = xr.concat(tc_track.data, dim="track") - - # Extract flattened latitude and longitude arrays (all values) - latitudes = all_tracks_ds["lat"].values.flatten() - longitudes = all_tracks_ds["lon"].values.flatten() - # Define grid resolution and bounds for density computation lat_bins = np.linspace(-90, 90, int(180 / res)) lon_bins = np.linspace(-180, 180, int(360 / res)) @@ -2920,8 +2923,7 @@ def compute_track_density( track.lat.values, track.lon.values, bins=[lat_bins, lon_bins], density=False ) hist_new = csr_matrix(hist_new) - hist_new[hist_new > 1] = 1 - + hist_new[hist_new > 1] = 1 if filter_tracks else hist_new hist_count += hist_new hist_count = hist_count / hist_count.sum() if density else hist_count From f9cd50d47aaaeb66b70fabd87c6884bffe04b9af Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Sat, 8 Feb 2025 16:51:16 +0100 Subject: [PATCH 08/38] add wind speed selection --- climada/hazard/tc_tracks.py | 65 ++++++++++++++++++--------- climada/hazard/test/test_tc_tracks.py | 39 ++++++++++------ 2 files changed, 70 insertions(+), 34 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index d5fde797a..0dd7f3e77 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2873,26 +2873,23 @@ def _zlib_from_dataarray(data_var: xr.DataArray) -> bool: def compute_track_density( tc_track: TCTracks, res: int = 5, - time_step: float = 1, density: bool = False, filter_tracks: bool = True, + wind_min: float = None, + wind_max: float = None, ) -> tuple[np.ndarray, tuple]: - """Compute absolute and normalized tropical cyclone track density. First, the function ensure - the same temporal resolution of all tracks by calling :py:meth:`equal_timestep`. Second, it - creates 2D bins of the specified resolution (e.g. 1° x 1°). Third, since tracks are not lines - but a series of points, it counts the number of points per bin. Lastly, it returns the absolute - or normalized count per bin. This function works under the hood of :py:meth:`plot_track_density` - but can be used separtly as input data for more sophisticated track density plots. - If filter track is True, only a maximum of one point is added to each grid cell for every track. - Hence, the resulting density will represent the number of differt tracksper grid cell. + """Compute absolute and normalized tropical cyclone track density. Before using this function, + apply the same temporal resolution to all tracks by calling :py:meth:`equal_timestep` on the + TCTrack object. Due to the computational cost of the this function, it is not recommended to + use a grid resolution higher tha 0.1°. This function it creates 2D bins of the specified + resolution (e.g. 1° x 1°). Second, since tracks are not lines but a series of points, it counts + the number of points per bin. Lastly, it returns the absolute or normalized count per bin. + To plot the output of this function use :py:meth:`plot_track_density`. Parameters: ---------- res: int (optional) Default: 5° resolution in degrees of the grid bins in which the density will be computed - time_step: float (optional) default: 1h - temporal resolution in hours to be apllied to the tracks, to ensure that every track - will have the same resolution. density: bool (optional) default: False If False it returns the number of samples in each bin. If True, returns the probability density function at each bin computed as count_bin / tot_count. @@ -2900,27 +2897,53 @@ def compute_track_density( If True the track density is computed as the number of different tracks crossing a grid cell. If False, the track density takes into account how long the track stayed in each grid cell. Hence slower tracks increase the density if the parameter is set to False. - + wind_min: float (optional) default: None + Minimum wind speed above which to select tracks. + wind_max: float (optional) default: None + Maximal wind speed below which to select tracks. Returns: ------- - hist: 2D np.ndarray + hist: 2D np.ndwind_speeday 2D matrix containing the track density """ - # ensure equal time step - # tc_track.equal_timestep(time_step_h=time_step) + limit_ratio = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin + + if tc_track.data[0].time_step[0].item() > res / limit_ratio: + warnings.warn( + f"The time step is too big. For the desired resolution, apply a time step \n" + "of {res/limit_ratio}h." + ) + elif res < 0.01: + warnings.warn( + "The resolution is too high. The computation might take several minutes \n" + "to hours. Consider using a resolution below 0.1°." + ) - # Define grid resolution and bounds for density computation + # define grid resolution and bounds for density computation lat_bins = np.linspace(-90, 90, int(180 / res)) lon_bins = np.linspace(-180, 180, int(360 / res)) - - # Compute 2D density + # compute 2D density hist_count = csr_matrix((len(lat_bins) - 1, len(lon_bins) - 1)) for track in tc_track.data: - # Compute 2D density + # select according to wind speed + wind_speed = track.max_sustained_wind.values + if wind_min and wind_max: + index = np.where((wind_speed >= wind_min) & (wind_speed <= wind_max))[0] + elif wind_min and not wind_max: + index = np.where(wind_speed >= wind_min)[0] + elif wind_max and not wind_min: + index = np.where(wind_speed <= wind_max)[0] + else: + index = slice(None) # select all the track + + # compute 2D density hist_new, _, _ = np.histogram2d( - track.lat.values, track.lon.values, bins=[lat_bins, lon_bins], density=False + track.lat.values[index], + track.lon.values[index], + bins=[lat_bins, lon_bins], + density=False, ) hist_new = csr_matrix(hist_new) hist_new[hist_new > 1] = 1 if filter_tracks else hist_new diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index 58d9bfb14..76481cdb4 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -1203,15 +1203,15 @@ def test_track_land_params(self): ) def test_compute_density_tracks(self): - """Test :py:meth:`compute_track_density` to ensure proper density count.""" + """Test `compute_track_density` to ensure proper density count.""" # create track track = xr.Dataset( { "time_step": ("time", np.timedelta64(1, "h") * np.arange(4)), - "max_sustained_wind": ("time", [3600, 3600, 3600, 3600]), - "central_pressure": ("time", [3600, 3600, 3600, 3600]), - "radius_max_wind": ("time", [3600, 3600, 3600, 3600]), - "environnmental_pressure": ("time", [3600, 3600, 3600, 3600]), + "max_sustained_wind": ("time", [10, 20, 30, 20]), + "central_pressure": ("time", [1, 1, 1, 1]), + "radius_max_wind": ("time", [1, 1, 1, 1]), + "environnmental_pressure": ("time", [1, 1, 1, 1]), "basin": ("time", ["NA", "NA", "NA", "NA"]), }, coords={ @@ -1233,23 +1233,36 @@ def test_compute_density_tracks(self): tc_tracks = tc.TCTracks([track]) - hist_abs, lat_bins, lon_bins = tc.compute_track_density( - tc_tracks, time_step=1, res=10, density=False + hist_abs, *_ = tc.compute_track_density( + tc_tracks, + res=10, + density=False, ) - hist_norm, lat_bins, lon_bins = tc.compute_track_density( - tc_tracks, time_step=1, res=10, density=True + hist_norm, *_ = tc.compute_track_density(tc_tracks, res=10, density=True) + hist_wind_min, *_ = tc.compute_track_density( + tc_tracks, res=10, density=False, wind_min=11, wind_max=None + ) + hist_wind_max, *_ = tc.compute_track_density( + tc_tracks, res=10, density=False, wind_min=None, wind_max=30 + ) + hist_wind_max, *_ = tc.compute_track_density( + tc_tracks, res=10, density=False, wind_min=None, wind_max=30 + ) + hist_wind_both, *_ = tc.compute_track_density( + tc_tracks, res=10, density=False, wind_min=11, wind_max=29 ) self.assertEqual(hist_abs.shape, (17, 35)) self.assertEqual(hist_norm.shape, (17, 35)) self.assertEqual(hist_abs.sum(), 4) self.assertEqual(hist_norm.sum(), 1) + self.assertEqual(hist_wind_min.sum(), 3) + self.assertEqual(hist_wind_max.sum(), 4) + self.assertEqual(hist_wind_both.sum(), 2) # the track above occupy positions [0,0:4] of hist - np.testing.assert_array_equal( - hist_abs.toarray()[0, 0:4], [1, 1, 1, 1] - ) # .toarray() + np.testing.assert_array_equal(hist_abs.toarray()[0, 0:4], [1, 1, 1, 1]) np.testing.assert_array_equal( hist_norm.toarray()[0, 0:4], [0.25, 0.25, 0.25, 0.25] - ) # .toarray() + ) # Execute Tests From 49ebfb2397b7f132731a06ee4df8fffb7d1f695b Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Sat, 8 Feb 2025 19:43:25 +0100 Subject: [PATCH 09/38] optimize after profiling --- climada/hazard/tc_tracks.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 0dd7f3e77..e4eeb1277 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -38,7 +38,6 @@ import matplotlib.cm as cm_mp import matplotlib.pyplot as plt import netCDF4 as nc -import numba import numpy as np import pandas as pd import pathos @@ -52,6 +51,7 @@ from scipy.sparse import csr_matrix from shapely.geometry import LineString, MultiLineString, Point from sklearn.metrics import DistanceMetric +from tqdm import tqdm import climada.hazard.tc_tracks_synth import climada.util.coordinates as u_coord @@ -2924,8 +2924,8 @@ def compute_track_density( lat_bins = np.linspace(-90, 90, int(180 / res)) lon_bins = np.linspace(-180, 180, int(360 / res)) # compute 2D density - hist_count = csr_matrix((len(lat_bins) - 1, len(lon_bins) - 1)) - for track in tc_track.data: + hist_count = np.zeros((len(lat_bins) - 1, len(lon_bins) - 1)) + for track in tqdm(tc_track.data, desc="Processing Tracks"): # select according to wind speed wind_speed = track.max_sustained_wind.values @@ -2945,7 +2945,6 @@ def compute_track_density( bins=[lat_bins, lon_bins], density=False, ) - hist_new = csr_matrix(hist_new) hist_new[hist_new > 1] = 1 if filter_tracks else hist_new hist_count += hist_new From 509d60b2e869cad0476d9f97d71b28b48fc52db7 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Mon, 10 Feb 2025 08:03:55 +0100 Subject: [PATCH 10/38] add function compute grid cell area --- CHANGELOG.md | 3 +- climada/hazard/tc_tracks.py | 84 ++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c493684b..818ffc47e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ Code freeze date: YYYY-MM-DD ### Added -- `climada.hazard.tc_tracks.compute_track_density` function [#1003](https://github.com/CLIMADA-project/climada_python/pull/1003) +- `climada.hazard.tc_tracks.compute_track_density` function, `climada.hazard.tc_tracks.compute_grid_cell_area` +function [#1003](https://github.com/CLIMADA-project/climada_python/pull/1003) - Add `osm-flex` package to CLIMADA core [#981](https://github.com/CLIMADA-project/climada_python/pull/981) - `doc.tutorial.climada_entity_Exposures_osm.ipynb` tutorial explaining how to use `osm-flex`with CLIMADA - `climada.util.coordinates.bounding_box_global` function [#980](https://github.com/CLIMADA-project/climada_python/pull/980) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index e4eeb1277..aa842ff69 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2892,7 +2892,7 @@ def compute_track_density( resolution in degrees of the grid bins in which the density will be computed density: bool (optional) default: False If False it returns the number of samples in each bin. If True, returns the - probability density function at each bin computed as count_bin / tot_count. + probability density function at each bin computed as count_bin / grid_area. filter_tracks: bool (optional) default: True If True the track density is computed as the number of different tracks crossing a grid cell. If False, the track density takes into account how long the track stayed in each @@ -2905,6 +2905,17 @@ def compute_track_density( ------- hist: 2D np.ndwind_speeday 2D matrix containing the track density + + Example: + -------- + >>> tc_tracks = TCTrack.from_IBTRACKS("path_to_file") + >>> tc_tracks.equal_timestep(time_steph_h = 1) + >>> hist_count = compute_track_density() + + >>> print(area.shape) + (18, 36) + >>> print(area) # Displays a 2D array of grid cell areas + """ limit_ratio = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin @@ -2948,6 +2959,75 @@ def compute_track_density( hist_new[hist_new > 1] = 1 if filter_tracks else hist_new hist_count += hist_new - hist_count = hist_count / hist_count.sum() if density else hist_count + if density: + grid_area, _ = compute_grid_cell_area(res=res) + hist_count = hist_count / grid_area return hist_count, lat_bins, lon_bins + + +def compute_grid_cell_area(res) -> tuple[np.ndarray]: + """ + This function computes the area of each grid cell on a sphere (Earth), using latitude and + longitude bins based on the given resolution. The area is computed using the spherical cap + approximation for each grid cell. The function return a 2D matrix with the corresponding area. + + The formula used to compute the area of each grid cell is derived from the integral of the + surface area of a spherical cap between squared latitude and longitude bins: + + A = R**2 * (Δλ) * (sin(ϕ2) - sin(ϕ1)) + + Where: + - R is the radius of the Earth (in km). + - Δλ is the width of the grid cell in terms of longitude (in radians). + - sin(ϕ2) - sin(ϕ1) is the difference in the sine of the latitudes, which + accounts for the varying horizontal distance between longitudinal lines at different latitudes. + + This formula is the direct integration over λ and ϕ2 of: + + A = R**2 * Δλ * Δϕ * cos(ϕ1) + + which approximate the grid cell area as a square computed by the multiplication of two + arc legths, with the logitudal arc length ajdusted by latitude. + + Parameters: + ---------- + res: int + The resolution of the grid in degrees. The grid will have cells of size `res x res` in + terms of latitude and longitude. + + Returns: + ------- + grid_area: np.ndarray + A 2D array of shape `(len(lat_bins)-1, len(lon_bins)-1)` containing the area of each grid + cell, in square kilometers. Each entry in the array corresponds to the area of the + corresponding grid cell on Earth. + + Example: + -------- + >>> res = 10 #10° resolution + >>> area = compute_grid_cell_area(res = res) + >>> print(area.shape) # (180/10, 360/10) + (18, 36) + """ + + lat_bins = np.linspace(-90, 90, int(180 / res)) # lat bins + lon_bins = np.linspace(-180, 180, int(360 / res)) + + R = 6371 # Earth's radius [km] + # Convert to radians + lat_bin_edges = np.radians(lat_bins) + lon_res_rad = np.radians(res) + + # Compute area + areas = ( + R**2 + * lon_res_rad + * np.abs(np.sin(lat_bin_edges[1:]) - np.sin(lat_bin_edges[:-1])) + ) + # Broadcast to create a full 2D grid of areas + grid_area = np.tile( + areas[:, np.newaxis], (1, len(lon_bins) - 1) + ) # each row as same area + + return grid_area, [lat_bins, lon_bins] From fd7be94775791308855bfd4363e882dcedb26883 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Tue, 11 Feb 2025 11:42:39 +0100 Subject: [PATCH 11/38] add plotting function --- climada/hazard/plot.py | 113 ++++++++++++++++++++++++++++++++++++ climada/hazard/tc_tracks.py | 9 +-- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/climada/hazard/plot.py b/climada/hazard/plot.py index 3ab1cec8b..dc0f03fec 100644 --- a/climada/hazard/plot.py +++ b/climada/hazard/plot.py @@ -21,6 +21,8 @@ import logging +import cartopy.crs as ccrs +import cartopy.feature as cfeature import matplotlib.pyplot as plt import numpy as np from deprecation import deprecated @@ -158,6 +160,117 @@ def plot_intensity( raise ValueError("Provide one event id or one centroid id.") + def plot_track_density( + hist: np.ndarray, + ax=None, + projection=ccrs.Mollweide(), + add_features: dict = None, + title: str = None, + figsize=(12, 6), + cbar_kwargs: dict = { + "orientation": "horizontal", + "pad": 0.05, + "shrink": 0.8, + "label": "Track Density [n° tracks / km²]", + }, + **kwargs, + ): + """ + Plot the track density of tropical cyclone tracks on a customizable world map. + + Parameters: + ---------- + hist: np.ndarray + 2D histogram of track density. + ax: GeoAxes, optional + Existing Cartopy axis. + projection: cartopy.crs, optional + Projection for the map. + add_features: dict + Dictionary of map features to add. Keys can be 'land', 'coastline', 'borders', and 'lakes'. + Values are Booleans indicating whether to include each feature. + title: str + Title of the plot. + figsize: tuple + Figure size when creating a new figure. + cbar_kwargs: dict + dictionary containing keyword arguments passed to cbar + kwargs: + Additional keyword arguments passed to `ax.contourf`. + + Returns: + ------- + ax: GeoAxes + The plot axis. + + + Example: + -------- + >>> ax = plot_track_density( + ... hist=hist, + ... cmap='Spectral_r', + ... cbar_kwargs={'shrink': 0.8, 'label': 'Cyclone Density [n° tracks / km²]', 'pad': 0.1}, + ... add_features={ + ... 'land': True, + ... 'coastline': True, + ... 'borders': True, + ... 'lakes': True + ... }, + ... title='Custom Cyclone Track Density Map', + ... figsize=(10, 5), + ... levels=20 + ... ) + + """ + + # Default features + default_features = { + "land": True, + "coastline": True, + "borders": False, + "lakes": False, + } + add_features = add_features or default_features + + # Sample data + x = np.linspace(-180, 180, hist.shape[1]) + y = np.linspace(-90, 90, hist.shape[0]) + z = hist + + # Create figure and axis if not provided + if ax is None: + fig, ax = plt.subplots( + figsize=figsize, subplot_kw={"projection": projection} + ) + + # Add requested features + if add_features.get("land", False): + land = cfeature.NaturalEarthFeature( + category="physical", + name="land", + scale="50m", + facecolor="lightgrey", + alpha=0.6, + ) + ax.add_feature(land) + if add_features.get("coastline", False): + ax.add_feature(cfeature.COASTLINE, linewidth=0.5) + if add_features.get("borders", False): + ax.add_feature(cfeature.BORDERS, linestyle=":") + if add_features.get("lakes", False): + ax.add_feature(cfeature.LAKES, alpha=0.4, edgecolor="black") + + # Plot density with contourf + contourf = ax.contourf(x, y, z, transform=ccrs.PlateCarree(), **kwargs) + + # Add colorbar + cbar = plt.colorbar(contourf, ax=ax, **cbar_kwargs) + # Title setup + if title: + ax.set_title(title, fontsize=16) + + return ax + def plot_fraction(self, event=None, centr=None, smooth=True, axis=None, **kwargs): """Plot fraction values for a selected event or centroid. diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index aa842ff69..7044b7602 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2908,13 +2908,10 @@ def compute_track_density( Example: -------- - >>> tc_tracks = TCTrack.from_IBTRACKS("path_to_file") + >>> tc_tracks = TCTrack.from_ibtracs_netcdf("path_to_file") >>> tc_tracks.equal_timestep(time_steph_h = 1) - >>> hist_count = compute_track_density() - - >>> print(area.shape) - (18, 36) - >>> print(area) # Displays a 2D array of grid cell areas + >>> hist_count = compute_track_density(res = 1) + >>> plot_track_density(hist_count) """ From 6016af5d1f6820e53461af53a74efa6191e09d97 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Tue, 11 Feb 2025 14:28:56 +0100 Subject: [PATCH 12/38] update changelog --- CHANGELOG.md | 2 +- climada/hazard/plot.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 818ffc47e..5f90dab26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Code freeze date: YYYY-MM-DD ### Added - `climada.hazard.tc_tracks.compute_track_density` function, `climada.hazard.tc_tracks.compute_grid_cell_area` -function [#1003](https://github.com/CLIMADA-project/climada_python/pull/1003) +function, `climada.hazard.plot.plot_track_density` function [#1003](https://github.com/CLIMADA-project/climada_python/pull/1003) - Add `osm-flex` package to CLIMADA core [#981](https://github.com/CLIMADA-project/climada_python/pull/981) - `doc.tutorial.climada_entity_Exposures_osm.ipynb` tutorial explaining how to use `osm-flex`with CLIMADA - `climada.util.coordinates.bounding_box_global` function [#980](https://github.com/CLIMADA-project/climada_python/pull/980) diff --git a/climada/hazard/plot.py b/climada/hazard/plot.py index dc0f03fec..4c91f3e89 100644 --- a/climada/hazard/plot.py +++ b/climada/hazard/plot.py @@ -213,10 +213,10 @@ def plot_track_density( ... add_features={ ... 'land': True, ... 'coastline': True, - ... 'borders': True, - ... 'lakes': True + ... 'borders': False, + ... 'lakes': False ... }, - ... title='Custom Cyclone Track Density Map', + ... title='My Tropical Cyclone Track Density Map', ... figsize=(10, 5), ... levels=20 ... ) From 541937ee76f232b141179b4ab3517ba02c1a930b Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Wed, 12 Feb 2025 09:27:15 +0100 Subject: [PATCH 13/38] move grid area function to util --- climada/hazard/tc_tracks.py | 70 +------------------------------------ climada/util/coordinates.py | 67 +++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 7044b7602..bd9af7676 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -48,7 +48,6 @@ from matplotlib.collections import LineCollection from matplotlib.colors import BoundaryNorm, ListedColormap from matplotlib.lines import Line2D -from scipy.sparse import csr_matrix from shapely.geometry import LineString, MultiLineString, Point from sklearn.metrics import DistanceMetric from tqdm import tqdm @@ -2957,74 +2956,7 @@ def compute_track_density( hist_count += hist_new if density: - grid_area, _ = compute_grid_cell_area(res=res) + grid_area, _ = u_coord.compute_grid_cell_area(res=res) hist_count = hist_count / grid_area return hist_count, lat_bins, lon_bins - - -def compute_grid_cell_area(res) -> tuple[np.ndarray]: - """ - This function computes the area of each grid cell on a sphere (Earth), using latitude and - longitude bins based on the given resolution. The area is computed using the spherical cap - approximation for each grid cell. The function return a 2D matrix with the corresponding area. - - The formula used to compute the area of each grid cell is derived from the integral of the - surface area of a spherical cap between squared latitude and longitude bins: - - A = R**2 * (Δλ) * (sin(ϕ2) - sin(ϕ1)) - - Where: - - R is the radius of the Earth (in km). - - Δλ is the width of the grid cell in terms of longitude (in radians). - - sin(ϕ2) - sin(ϕ1) is the difference in the sine of the latitudes, which - accounts for the varying horizontal distance between longitudinal lines at different latitudes. - - This formula is the direct integration over λ and ϕ2 of: - - A = R**2 * Δλ * Δϕ * cos(ϕ1) - - which approximate the grid cell area as a square computed by the multiplication of two - arc legths, with the logitudal arc length ajdusted by latitude. - - Parameters: - ---------- - res: int - The resolution of the grid in degrees. The grid will have cells of size `res x res` in - terms of latitude and longitude. - - Returns: - ------- - grid_area: np.ndarray - A 2D array of shape `(len(lat_bins)-1, len(lon_bins)-1)` containing the area of each grid - cell, in square kilometers. Each entry in the array corresponds to the area of the - corresponding grid cell on Earth. - - Example: - -------- - >>> res = 10 #10° resolution - >>> area = compute_grid_cell_area(res = res) - >>> print(area.shape) # (180/10, 360/10) - (18, 36) - """ - - lat_bins = np.linspace(-90, 90, int(180 / res)) # lat bins - lon_bins = np.linspace(-180, 180, int(360 / res)) - - R = 6371 # Earth's radius [km] - # Convert to radians - lat_bin_edges = np.radians(lat_bins) - lon_res_rad = np.radians(res) - - # Compute area - areas = ( - R**2 - * lon_res_rad - * np.abs(np.sin(lat_bin_edges[1:]) - np.sin(lat_bin_edges[:-1])) - ) - # Broadcast to create a full 2D grid of areas - grid_area = np.tile( - areas[:, np.newaxis], (1, len(lon_bins) - 1) - ) # each row as same area - - return grid_area, [lat_bins, lon_bins] diff --git a/climada/util/coordinates.py b/climada/util/coordinates.py index 351263e62..ee2fdb919 100644 --- a/climada/util/coordinates.py +++ b/climada/util/coordinates.py @@ -459,6 +459,73 @@ def get_gridcellarea(lat, resolution=0.5, unit="ha"): return area +def compute_grid_cell_area(res: float) -> tuple[np.ndarray]: + """ + This function computes the area of each grid cell on a sphere (Earth), using latitude and + longitude bins based on the given resolution. The area is computed using the spherical cap + approximation for each grid cell. The function return a 2D matrix with the corresponding area. + + The formula used to compute the area of each grid cell is derived from the integral of the + surface area of a spherical cap between squared latitude and longitude bins: + + A = R**2 * (Δλ) * (sin(ϕ2) - sin(ϕ1)) + + Where: + - R is the radius of the Earth (in km). + - Δλ is the width of the grid cell in terms of longitude (in radians). + - sin(ϕ2) - sin(ϕ1) is the difference in the sine of the latitudes, which + accounts for the varying horizontal distance between longitudinal lines at different latitudes. + + This formula is the direct integration over λ and ϕ2 of: + + A = R**2 * Δλ * Δϕ * cos(ϕ1) + + which approximate the grid cell area as a square computed by the multiplication of two + arc legths, with the logitudal arc length ajdusted by latitude. + + Parameters: + ---------- + res: int + The resolution of the grid in degrees. The grid will have cells of size `res x res` in + terms of latitude and longitude. + + Returns: + ------- + grid_area: np.ndarray + A 2D array of shape `(len(lat_bins)-1, len(lon_bins)-1)` containing the area of each grid + cell, in square kilometers. Each entry in the array corresponds to the area of the + corresponding grid cell on Earth. + + Example: + -------- + >>> res = 10 #10° resolution + >>> area = compute_grid_cell_area(res = res) + >>> print(area.shape) # (180/10, 360/10) + (18, 36) + """ + + lat_bins = np.linspace(-90, 90, int(180 / res)) # lat bins + lon_bins = np.linspace(-180, 180, int(360 / res)) + + R = 6371 # Earth's radius [km] + # Convert to radians + lat_bin_edges = np.radians(lat_bins) + lon_res_rad = np.radians(res) + + # Compute area + areas = ( + R**2 + * lon_res_rad + * np.abs(np.sin(lat_bin_edges[1:]) - np.sin(lat_bin_edges[:-1])) + ) + # Broadcast to create a full 2D grid of areas + grid_area = np.tile( + areas[:, np.newaxis], (1, len(lon_bins) - 1) + ) # each row as same area + + return grid_area, [lat_bins, lon_bins] + + def grid_is_regular(coord): """Return True if grid is regular. If True, returns height and width. From 45ccd8abae0279038f0fdb501171f334a5b6aad2 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Wed, 12 Feb 2025 09:51:10 +0100 Subject: [PATCH 14/38] fix pylint --- climada/util/coordinates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/climada/util/coordinates.py b/climada/util/coordinates.py index ee2fdb919..a918ec235 100644 --- a/climada/util/coordinates.py +++ b/climada/util/coordinates.py @@ -507,14 +507,14 @@ def compute_grid_cell_area(res: float) -> tuple[np.ndarray]: lat_bins = np.linspace(-90, 90, int(180 / res)) # lat bins lon_bins = np.linspace(-180, 180, int(360 / res)) - R = 6371 # Earth's radius [km] + r = 6371 # Earth's radius [km] # Convert to radians lat_bin_edges = np.radians(lat_bins) lon_res_rad = np.radians(res) # Compute area areas = ( - R**2 + r**2 * lon_res_rad * np.abs(np.sin(lat_bin_edges[1:]) - np.sin(lat_bin_edges[:-1])) ) From ec4a819df4781173bb398090109b4f289b2dcad9 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Wed, 12 Feb 2025 09:54:16 +0100 Subject: [PATCH 15/38] fix pylint f string --- climada/hazard/tc_tracks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index bd9af7676..6bf65e980 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2918,10 +2918,10 @@ def compute_track_density( if tc_track.data[0].time_step[0].item() > res / limit_ratio: warnings.warn( - f"The time step is too big. For the desired resolution, apply a time step \n" - "of {res/limit_ratio}h." + "The time step is too big for the current resolution. For the desired resolution, \n" + f"apply a time step of {res/limit_ratio}h." ) - elif res < 0.01: + elif res < 0.1: warnings.warn( "The resolution is too high. The computation might take several minutes \n" "to hours. Consider using a resolution below 0.1°." From 6f06f76026ac24fe9340eac910109815030d4de8 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Wed, 12 Feb 2025 11:57:23 +0100 Subject: [PATCH 16/38] add second function to compute grid area with projections --- climada/util/coordinates.py | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/climada/util/coordinates.py b/climada/util/coordinates.py index a918ec235..403d1a2ee 100644 --- a/climada/util/coordinates.py +++ b/climada/util/coordinates.py @@ -46,6 +46,7 @@ import shapely.vectorized import shapely.wkt from cartopy.io import shapereader +from pyproj import Geod from shapely.geometry import MultiPolygon, Point, Polygon, box from sklearn.neighbors import BallTree @@ -526,6 +527,59 @@ def compute_grid_cell_area(res: float) -> tuple[np.ndarray]: return grid_area, [lat_bins, lon_bins] +def compute_grid_cell_area_( + res: float = 1.0, projection: str = "WGS84", units: str = "km^2" +) -> np.ndarray: + """ + Compute the area of each grid cell in a latitude-longitude grid. + + Parameters: + ----------- + res: float + Grid resolution in degrees (default is 1° x 1°) + projection: str + Ellipsoid or spherical projection to approximate Earth. To get the complete list of + projections call :py:meth:`pyproj.get_ellps_map()`. Widely used projections: + - "WGS84": Uses the WGS84 ellipsoid (default) + - "GRS80" + - "IAU76" + - "sphere": Uses a perfect sphere with Earth's mean radius (6371 km) + units: str (optional) Default "km^2" + units of the area. Either km^2 or m^2. + + Returns: + -------- + area: np.ndarray + A 2D numpy array of grid cell areas in km² + Example: + -------- + >>> area = compute_grid_areas(res = 1, projection ="sphere", units = "m^2") + """ + geod = Geod(ellps=projection) # Use specified ellipsoid model + + lat_edges = np.linspace(-90, 90, int(180 / res)) # Latitude edges + lon_edges = np.linspace(-180, 180, int(360 / res)) # Longitude edges + + area = np.zeros((len(lat_edges) - 1, len(lon_edges) - 1)) # Create an empty grid + + # Iterate over consecutive latitude and longitude edges + for i, (lat1, lat2) in enumerate(zip(lat_edges[:-1], lat_edges[1:])): + for j, (lon1, lon2) in enumerate(zip(lon_edges[:-1], lon_edges[1:])): + + # 5th point to close the loop + poly_lons = [lon1, lon2, lon2, lon1, lon1] + poly_lats = [lat1, lat1, lat2, lat2, lat1] + + # Compute the area of the grid cell + poly_area, _ = geod.polygon_area_perimeter(poly_lons, poly_lats) + area[i, j] = abs(poly_area) # Convert from m² to km² + + if units == "km^2": + area = area / 1e6 + + return area + + def grid_is_regular(coord): """Return True if grid is regular. If True, returns height and width. From 26460a7202947ada290ddeb509ec569178182ad6 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Wed, 12 Feb 2025 12:04:26 +0100 Subject: [PATCH 17/38] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f90dab26..d24127fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Code freeze date: YYYY-MM-DD ### Added -- `climada.hazard.tc_tracks.compute_track_density` function, `climada.hazard.tc_tracks.compute_grid_cell_area` +- `climada.hazard.tc_tracks.compute_track_density` function, `climada.util.coordinates.compute_grid_cell_area` function, `climada.hazard.plot.plot_track_density` function [#1003](https://github.com/CLIMADA-project/climada_python/pull/1003) - Add `osm-flex` package to CLIMADA core [#981](https://github.com/CLIMADA-project/climada_python/pull/981) - `doc.tutorial.climada_entity_Exposures_osm.ipynb` tutorial explaining how to use `osm-flex`with CLIMADA From 8a66674047d11b228c4dc457758870caf47bd220 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Tue, 18 Feb 2025 10:17:58 +0100 Subject: [PATCH 18/38] fix unit test --- climada/hazard/test/test_tc_tracks.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index be241a776..7eccfeef5 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -1284,7 +1284,7 @@ def test_compute_density_tracks(self): res=10, density=False, ) - hist_norm, *_ = tc.compute_track_density(tc_tracks, res=10, density=True) + # hist_norm, *_ = tc.compute_track_density(tc_tracks, res=10, density=True) hist_wind_min, *_ = tc.compute_track_density( tc_tracks, res=10, density=False, wind_min=11, wind_max=None ) @@ -1298,17 +1298,17 @@ def test_compute_density_tracks(self): tc_tracks, res=10, density=False, wind_min=11, wind_max=29 ) self.assertEqual(hist_abs.shape, (17, 35)) - self.assertEqual(hist_norm.shape, (17, 35)) + # self.assertEqual(hist_norm.shape, (17, 35)) + # self.assertEqual(hist_norm.sum(), 1) self.assertEqual(hist_abs.sum(), 4) - self.assertEqual(hist_norm.sum(), 1) self.assertEqual(hist_wind_min.sum(), 3) self.assertEqual(hist_wind_max.sum(), 4) self.assertEqual(hist_wind_both.sum(), 2) # the track above occupy positions [0,0:4] of hist - np.testing.assert_array_equal(hist_abs.toarray()[0, 0:4], [1, 1, 1, 1]) - np.testing.assert_array_equal( - hist_norm.toarray()[0, 0:4], [0.25, 0.25, 0.25, 0.25] - ) + np.testing.assert_array_equal(hist_abs[0, 0:4], [1, 1, 1, 1]) + # np.testing.assert_array_equal( + # hist_norm[0, 0:4], [0.25, 0.25, 0.25, 0.25] + # ) # Execute Tests From 870ec7c65ea618fb21934e9237ae166a50b48a3e Mon Sep 17 00:00:00 2001 From: Nicolas Colombi <115944312+NicolasColombi@users.noreply.github.com> Date: Tue, 18 Feb 2025 11:32:24 +0100 Subject: [PATCH 19/38] Update tc_tracks.py float issue jenkins --- climada/hazard/tc_tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 1fe611e65..4fadcfc4a 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3029,7 +3029,7 @@ def compute_track_density( limit_ratio = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin - if tc_track.data[0].time_step[0].item() > res / limit_ratio: + if float(tc_track.data[0].time_step[0].item()) > res / limit_ratio: warnings.warn( "The time step is too big for the current resolution. For the desired resolution, \n" f"apply a time step of {res/limit_ratio}h." From 9919d1122cb7875df7968a93a3c2be19d2bb93bf Mon Sep 17 00:00:00 2001 From: Nicolas Colombi <115944312+NicolasColombi@users.noreply.github.com> Date: Tue, 18 Feb 2025 13:26:05 +0100 Subject: [PATCH 20/38] Update tc_tracks.py --- climada/hazard/tc_tracks.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 4fadcfc4a..460e5c223 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3029,12 +3029,12 @@ def compute_track_density( limit_ratio = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin - if float(tc_track.data[0].time_step[0].item()) > res / limit_ratio: - warnings.warn( - "The time step is too big for the current resolution. For the desired resolution, \n" - f"apply a time step of {res/limit_ratio}h." - ) - elif res < 0.1: + #if tc_track.data[0].time_step[0].item() > res / limit_ratio: + # warnings.warn( + # "The time step is too big for the current resolution. For the desired resolution, \n" + # f"apply a time step of {res/limit_ratio}h." + #) + if res < 0.1: warnings.warn( "The resolution is too high. The computation might take several minutes \n" "to hours. Consider using a resolution below 0.1°." From 0b6a81e876e814cb97f25e27d07eda1a12b8ee89 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Tue, 18 Feb 2025 15:32:03 +0100 Subject: [PATCH 21/38] fix typeError --- climada/hazard/tc_tracks.py | 40 +++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 460e5c223..28ead1cff 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3029,12 +3029,12 @@ def compute_track_density( limit_ratio = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin - #if tc_track.data[0].time_step[0].item() > res / limit_ratio: - # warnings.warn( - # "The time step is too big for the current resolution. For the desired resolution, \n" - # f"apply a time step of {res/limit_ratio}h." - #) - if res < 0.1: + if tc_track.data[0].time_step[0].values > res / limit_ratio: + warnings.warn( + "The time step is too big for the current resolution. For the desired resolution, \n" + f"apply a time step of {res/limit_ratio}h." + ) + elif res < 0.1: warnings.warn( "The resolution is too high. The computation might take several minutes \n" "to hours. Consider using a resolution below 0.1°." @@ -3069,7 +3069,31 @@ def compute_track_density( hist_count += hist_new if density: - grid_area, _ = u_coord.compute_grid_cell_area(res=res) - hist_count = hist_count / grid_area + hist_count = normalize_density() return hist_count, lat_bins, lon_bins + + +def compute_genesis_index(track, lat_bins, lon_bins): + + # Extract the first lat and lon from each dataset + first_lats = np.array([ds.lat.values[0] for ds in track]) + first_lons = np.array([ds.lon.values[0] for ds in track]) + + # compute 2D density of genesis points + hist_count, _, _ = np.histogram2d( + first_lats, + first_lons, + bins=[lat_bins, lon_bins], + density=False, + ) + + return hist_count + + +def normalize_density(res): + + grid_area, _ = u_coord.compute_grid_cell_area(res=res) + hist_count = hist_count / grid_area + + pass From 5ca286eb24e7395ab35061b29146c5388b85442d Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Fri, 21 Feb 2025 10:54:11 +0100 Subject: [PATCH 22/38] restructure function and add tests --- climada/hazard/tc_tracks.py | 139 ++++++++++++++++++-------- climada/hazard/test/test_tc_tracks.py | 63 ++++++++++-- climada/util/test/test_coordinates.py | 3 + 3 files changed, 153 insertions(+), 52 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 28ead1cff..de5526ce1 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2985,7 +2985,8 @@ def _zlib_from_dataarray(data_var: xr.DataArray) -> bool: def compute_track_density( tc_track: TCTracks, res: int = 5, - density: bool = False, + genesis: bool = False, + norm: str = None, filter_tracks: bool = True, wind_min: float = None, wind_max: float = None, @@ -2993,43 +2994,48 @@ def compute_track_density( """Compute absolute and normalized tropical cyclone track density. Before using this function, apply the same temporal resolution to all tracks by calling :py:meth:`equal_timestep` on the TCTrack object. Due to the computational cost of the this function, it is not recommended to - use a grid resolution higher tha 0.1°. This function it creates 2D bins of the specified + use a grid resolution higher tha 0.1°. First, this function creates 2D bins of the specified resolution (e.g. 1° x 1°). Second, since tracks are not lines but a series of points, it counts the number of points per bin. Lastly, it returns the absolute or normalized count per bin. To plot the output of this function use :py:meth:`plot_track_density`. Parameters: ---------- - res: int (optional) Default: 5° + tc_track: TCT track object + track object containing a list of all tracks + res: int (optional), default: 5° resolution in degrees of the grid bins in which the density will be computed - density: bool (optional) default: False + genesis: bool, Default = False + If true the function computes the track density of only the genesis location of tracks + norm: bool (optional), default = False If False it returns the number of samples in each bin. If True, returns the - probability density function at each bin computed as count_bin / grid_area. + specified normalization function at each bin computed as count_bin / grid_area. filter_tracks: bool (optional) default: True If True the track density is computed as the number of different tracks crossing a grid cell. If False, the track density takes into account how long the track stayed in each grid cell. Hence slower tracks increase the density if the parameter is set to False. - wind_min: float (optional) default: None + wind_min: float (optional), default: None Minimum wind speed above which to select tracks. - wind_max: float (optional) default: None + wind_max: float (optional), default: None Maximal wind speed below which to select tracks. Returns: ------- - hist: 2D np.ndwind_speeday - 2D matrix containing the track density + hist_count: np.ndarray + 2D matrix containing the the absolute count per gridd cell of track point or the normalized + number of track points, depending on the norm parameter. Example: -------- >>> tc_tracks = TCTrack.from_ibtracs_netcdf("path_to_file") >>> tc_tracks.equal_timestep(time_steph_h = 1) - >>> hist_count = compute_track_density(res = 1) + >>> hist_count, *_ = compute_track_density(res = 1) >>> plot_track_density(hist_count) """ limit_ratio = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin - if tc_track.data[0].time_step[0].values > res / limit_ratio: + if tc_track.data[0].time_step[0].values > (res / limit_ratio): warnings.warn( "The time step is too big for the current resolution. For the desired resolution, \n" f"apply a time step of {res/limit_ratio}h." @@ -3044,41 +3050,68 @@ def compute_track_density( lat_bins = np.linspace(-90, 90, int(180 / res)) lon_bins = np.linspace(-180, 180, int(360 / res)) # compute 2D density - hist_count = np.zeros((len(lat_bins) - 1, len(lon_bins) - 1)) - for track in tqdm(tc_track.data, desc="Processing Tracks"): - - # select according to wind speed - wind_speed = track.max_sustained_wind.values - if wind_min and wind_max: - index = np.where((wind_speed >= wind_min) & (wind_speed <= wind_max))[0] - elif wind_min and not wind_max: - index = np.where(wind_speed >= wind_min)[0] - elif wind_max and not wind_min: - index = np.where(wind_speed <= wind_max)[0] - else: - index = slice(None) # select all the track - - # compute 2D density - hist_new, _, _ = np.histogram2d( - track.lat.values[index], - track.lon.values[index], - bins=[lat_bins, lon_bins], - density=False, + if genesis: + hist_count = compute_genesis_density( + tc_track=tc_track, lat_bins=lat_bins, lon_bins=lon_bins ) - hist_new[hist_new > 1] = 1 if filter_tracks else hist_new - hist_count += hist_new + else: + hist_count = np.zeros((len(lat_bins) - 1, len(lon_bins) - 1)) + for track in tqdm(tc_track.data, desc="Processing Tracks"): + + # select according to wind speed + wind_speed = track.max_sustained_wind.values + if wind_min and wind_max: + index = np.where((wind_speed >= wind_min) & (wind_speed <= wind_max))[0] + elif wind_min and not wind_max: + index = np.where(wind_speed >= wind_min)[0] + elif wind_max and not wind_min: + index = np.where(wind_speed <= wind_max)[0] + else: + index = slice(None) # select all the track + + # compute 2D density + hist_new, _, _ = np.histogram2d( + track.lat.values[index], + track.lon.values[index], + bins=[lat_bins, lon_bins], + density=False, + ) + if filter_tracks: + hist_new[hist_new > 1] = 1 - if density: - hist_count = normalize_density() + hist_count += hist_new + + if norm: + hist_count = normalize_hist(res=res, hist_count=hist_count, norm=norm) return hist_count, lat_bins, lon_bins -def compute_genesis_index(track, lat_bins, lon_bins): +def compute_genesis_density( + tc_track: TCTracks, lat_bins: np.ndarray, lon_bins: np.ndarray +) -> np.ndarray: + """Compute the density of track genesis locations. This function works under the hood + of :py:meth:`compute_track_density`. If it is called with the parameter genesis = True, + the function return the number of genesis points per grid cell. + + Parameters: + ----------- + + tc_track: TCT track object + track object containing a list of all tracks + lat_bins: 1D np.array + array containg the latitude bins + lon_bins: 1D np.array + array containg the longitude bins + Returns: + -------- + hist_count: 2D np.array + array containing the number of genesis points per grid cell + """ # Extract the first lat and lon from each dataset - first_lats = np.array([ds.lat.values[0] for ds in track]) - first_lons = np.array([ds.lon.values[0] for ds in track]) + first_lats = np.array([ds.lat.values[0] for ds in tc_track.data]) + first_lons = np.array([ds.lon.values[0] for ds in tc_track.data]) # compute 2D density of genesis points hist_count, _, _ = np.histogram2d( @@ -3091,9 +3124,31 @@ def compute_genesis_index(track, lat_bins, lon_bins): return hist_count -def normalize_density(res): +def normalize_hist( + res: int, + hist_count: np.ndarray, + norm: str, +) -> np.ndarray: + """Normalize the number of points per grid cell by the grid cell area or by the total sum of + the histogram count. + + Parameters: + ----------- + res: float + resolution of grid cells + hist_count: 2D np.array + Array containing the count of tracks or genesis locations + norm: str + if norm = area normalize by gird cell area, if norm = sum normalize by the total sum + Returns: + --------- + + """ - grid_area, _ = u_coord.compute_grid_cell_area(res=res) - hist_count = hist_count / grid_area + if norm == "area": + grid_area, _ = u_coord.compute_grid_cell_area(res=res) + norm_hist = hist_count / grid_area + elif norm == "sum": + norm_hist = hist_count / hist_count.sum() - pass + return norm_hist diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index 7eccfeef5..395fa4037 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -1286,29 +1286,72 @@ def test_compute_density_tracks(self): ) # hist_norm, *_ = tc.compute_track_density(tc_tracks, res=10, density=True) hist_wind_min, *_ = tc.compute_track_density( - tc_tracks, res=10, density=False, wind_min=11, wind_max=None + tc_tracks, res=10, norm=False, wind_min=11, wind_max=None ) hist_wind_max, *_ = tc.compute_track_density( - tc_tracks, res=10, density=False, wind_min=None, wind_max=30 + tc_tracks, res=10, norm=False, wind_min=None, wind_max=30 ) hist_wind_max, *_ = tc.compute_track_density( - tc_tracks, res=10, density=False, wind_min=None, wind_max=30 + tc_tracks, res=10, norm=False, wind_min=None, wind_max=30 ) hist_wind_both, *_ = tc.compute_track_density( - tc_tracks, res=10, density=False, wind_min=11, wind_max=29 + tc_tracks, res=10, norm=False, wind_min=11, wind_max=29 ) self.assertEqual(hist_abs.shape, (17, 35)) - # self.assertEqual(hist_norm.shape, (17, 35)) - # self.assertEqual(hist_norm.sum(), 1) self.assertEqual(hist_abs.sum(), 4) self.assertEqual(hist_wind_min.sum(), 3) self.assertEqual(hist_wind_max.sum(), 4) self.assertEqual(hist_wind_both.sum(), 2) - # the track above occupy positions [0,0:4] of hist + # the track defined above occupy positions 0 to 4 np.testing.assert_array_equal(hist_abs[0, 0:4], [1, 1, 1, 1]) - # np.testing.assert_array_equal( - # hist_norm[0, 0:4], [0.25, 0.25, 0.25, 0.25] - # ) + + def test_normalize_hist(self): + """test the correct normalization of a 2D matrix by grid cell area and sum of + values of the matrix.""" + + M = np.ones((10, 10)) + M_norm = tc.normalize_hist(res=10, hist_count=M, norm="sum") + np.testing.assert_array_equal(M, M_norm * 100) + + def test_compute_genesis_density(self): + """Check that the correct number of grid point is computed per grid cell for the starting + location of cyclone tracks""" + # create track + track = xr.Dataset( + { + "time_step": ("time", np.timedelta64(1, "h") * np.arange(4)), + "max_sustained_wind": ("time", [10, 20, 30, 20]), + "central_pressure": ("time", [1, 1, 1, 1]), + "radius_max_wind": ("time", [1, 1, 1, 1]), + "environnmental_pressure": ("time", [1, 1, 1, 1]), + "basin": ("time", ["NA", "NA", "NA", "NA"]), + }, + coords={ + "time": ("time", pd.date_range("2025-01-01", periods=4, freq="12H")), + "lat": ("time", [-90, -89, -88, -87]), + "lon": ("time", [-179, -169, -159, -149]), + }, + attrs={ + "max_sustained_wind_unit": "m/s", + "central_pressure_unit": "hPa", + "name": "storm_0", + "sid": "0", + "orig_event_flag": True, + "data_provider": "FAST", + "id_no": "0", + "category": "1", + }, + ) + res = 10 + tc_tracks = tc.TCTracks([track]) + lat_bins = np.linspace(-90, 90, int(180 / res)) + lon_bins = np.linspace(-180, 180, int(360 / res)) + hist = tc.compute_genesis_density( + tc_track=tc_tracks, lat_bins=lat_bins, lon_bins=lon_bins + ) + self.assertEqual(hist.shape, (17, 35)) + self.assertEqual(hist.sum(), 1) + self.assertEqual(hist[0, 0], 1) # Execute Tests diff --git a/climada/util/test/test_coordinates.py b/climada/util/test/test_coordinates.py index 86f95f764..ef8bac290 100644 --- a/climada/util/test/test_coordinates.py +++ b/climada/util/test/test_coordinates.py @@ -565,6 +565,9 @@ def test_get_gridcellarea(self): self.assertAlmostEqual(area2[0], 1781.5973363005) self.assertTrue(area2[0] <= 2500) + def test_compute_grid_area_(): + pass + def test_read_vector_pass(self): """Test one columns data""" shp_file = shapereader.natural_earth( From 9537568078b4e261f4a52435fba095598cb5fb59 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Fri, 21 Feb 2025 13:53:34 +0100 Subject: [PATCH 23/38] fix pylint --- climada/hazard/plot.py | 42 +++++++++++++++++++------------------ climada/util/coordinates.py | 4 ++-- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/climada/hazard/plot.py b/climada/hazard/plot.py index 4c91f3e89..586fea2c5 100644 --- a/climada/hazard/plot.py +++ b/climada/hazard/plot.py @@ -160,9 +160,10 @@ def plot_intensity( raise ValueError("Provide one event id or one centroid id.") + @staticmethod def plot_track_density( hist: np.ndarray, - ax=None, + axis=None, projection=ccrs.Mollweide(), add_features: dict = None, title: str = None, @@ -182,13 +183,13 @@ def plot_track_density( ---------- hist: np.ndarray 2D histogram of track density. - ax: GeoAxes, optional + axis: GeoAxes, optional Existing Cartopy axis. projection: cartopy.crs, optional Projection for the map. add_features: dict - Dictionary of map features to add. Keys can be 'land', 'coastline', 'borders', and 'lakes'. - Values are Booleans indicating whether to include each feature. + Dictionary of map features to add. Keys can be 'land', 'coastline', 'borders', and + 'lakes'. Values are Booleans indicating whether to include each feature. title: str Title of the plot. figsize: tuple @@ -200,16 +201,18 @@ def plot_track_density( Returns: ------- - ax: GeoAxes + axis: GeoAxes The plot axis. Example: -------- - >>> ax = plot_track_density( + >>> axis = plot_track_density( ... hist=hist, ... cmap='Spectral_r', - ... cbar_kwargs={'shrink': 0.8, 'label': 'Cyclone Density [n° tracks / km²]', 'pad': 0.1}, + ... cbar_kwargs={'shrink': 0.8, + 'label': 'Cyclone Density [n° tracks / km²]', + 'pad': 0.1}, ... add_features={ ... 'land': True, ... 'coastline': True, @@ -233,13 +236,12 @@ def plot_track_density( add_features = add_features or default_features # Sample data - x = np.linspace(-180, 180, hist.shape[1]) - y = np.linspace(-90, 90, hist.shape[0]) - z = hist + lon = np.linspace(-180, 180, hist.shape[1]) + lat = np.linspace(-90, 90, hist.shape[0]) # Create figure and axis if not provided - if ax is None: - fig, ax = plt.subplots( + if axis is None: + _, axis = plt.subplots( figsize=figsize, subplot_kw={"projection": projection} ) @@ -252,24 +254,24 @@ def plot_track_density( facecolor="lightgrey", alpha=0.6, ) - ax.add_feature(land) + axis.add_feature(land) if add_features.get("coastline", False): - ax.add_feature(cfeature.COASTLINE, linewidth=0.5) + axis.add_feature(cfeature.COASTLINE, linewidth=0.5) if add_features.get("borders", False): - ax.add_feature(cfeature.BORDERS, linestyle=":") + axis.add_feature(cfeature.BORDERS, linestyle=":") if add_features.get("lakes", False): - ax.add_feature(cfeature.LAKES, alpha=0.4, edgecolor="black") + axis.add_feature(cfeature.LAKES, alpha=0.4, edgecolor="black") # Plot density with contourf - contourf = ax.contourf(x, y, z, transform=ccrs.PlateCarree(), **kwargs) + contourf = axis.contourf(lon, lat, hist, transform=ccrs.PlateCarree(), **kwargs) # Add colorbar - cbar = plt.colorbar(contourf, ax=ax, **cbar_kwargs) + plt.colorbar(contourf, ax=axis, **cbar_kwargs) # Title setup if title: - ax.set_title(title, fontsize=16) + axis.set_title(title, fontsize=16) - return ax + return axis def plot_fraction(self, event=None, centr=None, smooth=True, axis=None, **kwargs): """Plot fraction values for a selected event or centroid. diff --git a/climada/util/coordinates.py b/climada/util/coordinates.py index 403d1a2ee..35565e596 100644 --- a/climada/util/coordinates.py +++ b/climada/util/coordinates.py @@ -508,14 +508,14 @@ def compute_grid_cell_area(res: float) -> tuple[np.ndarray]: lat_bins = np.linspace(-90, 90, int(180 / res)) # lat bins lon_bins = np.linspace(-180, 180, int(360 / res)) - r = 6371 # Earth's radius [km] + r_earth = 6371 # Earth's radius [km] # Convert to radians lat_bin_edges = np.radians(lat_bins) lon_res_rad = np.radians(res) # Compute area areas = ( - r**2 + r_earth**2 * lon_res_rad * np.abs(np.sin(lat_bin_edges[1:]) - np.sin(lat_bin_edges[:-1])) ) From b983c7de0466929588aec38f4001668c5f910156 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Fri, 21 Feb 2025 17:28:35 +0100 Subject: [PATCH 24/38] add test grid cell area --- climada/util/coordinates.py | 7 +++---- climada/util/test/test_coordinates.py | 26 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/climada/util/coordinates.py b/climada/util/coordinates.py index 35565e596..1cce70e58 100644 --- a/climada/util/coordinates.py +++ b/climada/util/coordinates.py @@ -460,7 +460,7 @@ def get_gridcellarea(lat, resolution=0.5, unit="ha"): return area -def compute_grid_cell_area(res: float) -> tuple[np.ndarray]: +def compute_grid_cell_area_validation(res: float) -> tuple[np.ndarray]: """ This function computes the area of each grid cell on a sphere (Earth), using latitude and longitude bins based on the given resolution. The area is computed using the spherical cap @@ -524,10 +524,10 @@ def compute_grid_cell_area(res: float) -> tuple[np.ndarray]: areas[:, np.newaxis], (1, len(lon_bins) - 1) ) # each row as same area - return grid_area, [lat_bins, lon_bins] + return grid_area, lat_bins, lon_bins -def compute_grid_cell_area_( +def compute_grid_cell_area( res: float = 1.0, projection: str = "WGS84", units: str = "km^2" ) -> np.ndarray: """ @@ -556,7 +556,6 @@ def compute_grid_cell_area_( >>> area = compute_grid_areas(res = 1, projection ="sphere", units = "m^2") """ geod = Geod(ellps=projection) # Use specified ellipsoid model - lat_edges = np.linspace(-90, 90, int(180 / res)) # Latitude edges lon_edges = np.linspace(-180, 180, int(360 / res)) # Longitude edges diff --git a/climada/util/test/test_coordinates.py b/climada/util/test/test_coordinates.py index ef8bac290..55152416d 100644 --- a/climada/util/test/test_coordinates.py +++ b/climada/util/test/test_coordinates.py @@ -565,8 +565,30 @@ def test_get_gridcellarea(self): self.assertAlmostEqual(area2[0], 1781.5973363005) self.assertTrue(area2[0] <= 2500) - def test_compute_grid_area_(): - pass + def test_compute_grid_area(self): + """Test that the two twin functions calculate the area of a gridcell correctly. Using + an absolute reference and mutual validation by comparison.""" + res = 1 + area = u_coord.compute_grid_cell_area( + res=res, projection="sphere", units="km^2" + ) + area_test, *_ = u_coord.compute_grid_cell_area_validation(res=res) + + self.assertEqual(area.shape, (179, 359)) + self.assertEqual(area_test.shape, (179, 359)) + # check that all rows have equal area with 1e-5 tolerance for relative and absolute precision + for i in range(area.shape[0]): + self.assertTrue( + np.all(np.isclose(area[i, :], area[i, 0], rtol=1e-5, atol=1e-5)) + ) + self.assertTrue( + np.all( + np.isclose(area_test[i, :], area_test[i, 0], rtol=1e-5, atol=1e-5) + ) + ) + + # check that both methods give similar results with 0.01% tolerance in relative difference + self.assertTrue(np.allclose(area, area_test, rtol=1e-2, atol=1e-5)) def test_read_vector_pass(self): """Test one columns data""" From 1046328b55f28c95dee4a8e42aaf30d3279686ef Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Fri, 21 Feb 2025 17:39:28 +0100 Subject: [PATCH 25/38] fix jenkins wrong interpretation of type --- climada/hazard/tc_tracks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index de5526ce1..a40a309fb 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3034,8 +3034,9 @@ def compute_track_density( """ limit_ratio = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin + time_value: float = tc_track.data[0].time_step[0].values # Type hint for jenkins - if tc_track.data[0].time_step[0].values > (res / limit_ratio): + if time_value > (res / limit_ratio): warnings.warn( "The time step is too big for the current resolution. For the desired resolution, \n" f"apply a time step of {res/limit_ratio}h." From ee152ec7735b86d4322675bdbc42e3c286611dc3 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Fri, 21 Feb 2025 18:04:17 +0100 Subject: [PATCH 26/38] add datatype to from_FAST and fix test --- climada/hazard/tc_tracks.py | 40 +++++++++++++++------------ climada/hazard/test/test_tc_tracks.py | 10 +++---- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index a40a309fb..6b78d6334 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -1644,7 +1644,7 @@ def from_FAST(cls, folder_name: str): """ LOGGER.info("Reading %s files.", len(get_file_names(folder_name))) - data = [] + data: list = [] for file in get_file_names(folder_name): if Path(file).suffix != ".nc": continue @@ -1653,28 +1653,32 @@ def from_FAST(cls, folder_name: str): for i in dataset.n_trk: # Select track - track = dataset.sel(n_trk=i, year=year) + track: xr.Dataset = dataset.sel(n_trk=i, year=year) # chunk dataset at first NaN value - lon = track.lon_trks.data - last_valid_index = np.where(np.isfinite(lon))[0][-1] - track = track.isel(time=slice(0, last_valid_index + 1)) + lon: np.ndarray = track.lon_trks.data + last_valid_index: int = np.where(np.isfinite(lon))[0][-1] + track: xr.Dataset = track.isel( + time=slice(0, last_valid_index + 1) + ) # Select lat, lon - lat = track.lat_trks.data - lon = track.lon_trks.data + lat: np.ndarray = track.lat_trks.data + lon: np.ndarray = track.lon_trks.data # Convert lon from 0-360 to -180 - 180 - lon = ((lon + 180) % 360) - 180 + lon: np.ndarray = ((lon + 180) % 360) - 180 # Convert time to pandas Datetime "yyyy.mm.dd" reference_time = ( f"{track.tc_years.item()}-{int(track.tc_month.item())}-01" ) - time = pd.to_datetime( + time: np.datetime64 = pd.to_datetime( track.time.data, unit="s", origin=reference_time ).astype("datetime64[s]") # Define variables - ms_to_kn = 1.943844 - max_wind_kn = track.vmax_trks.data * ms_to_kn - env_pressure = BASIN_ENV_PRESSURE[track.tc_basins.data.item()] - cen_pres = _estimate_pressure( + ms_to_kn: float = 1.943844 + max_wind_kn: np.ndarray = track.vmax_trks.data * ms_to_kn + env_pressure: float = BASIN_ENV_PRESSURE[ + track.tc_basins.data.item() + ] + cen_pres: np.ndarray = _estimate_pressure( np.full(lat.shape, np.nan), lat, lon, @@ -3033,7 +3037,7 @@ def compute_track_density( """ - limit_ratio = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin + limit_ratio: float = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin time_value: float = tc_track.data[0].time_step[0].values # Type hint for jenkins if time_value > (res / limit_ratio): @@ -3048,8 +3052,8 @@ def compute_track_density( ) # define grid resolution and bounds for density computation - lat_bins = np.linspace(-90, 90, int(180 / res)) - lon_bins = np.linspace(-180, 180, int(360 / res)) + lat_bins: np.ndarray = np.linspace(-90, 90, int(180 / res)) + lon_bins: np.ndarray = np.linspace(-180, 180, int(360 / res)) # compute 2D density if genesis: hist_count = compute_genesis_density( @@ -3148,8 +3152,8 @@ def normalize_hist( if norm == "area": grid_area, _ = u_coord.compute_grid_cell_area(res=res) - norm_hist = hist_count / grid_area + norm_hist: np.ndarray = hist_count / grid_area elif norm == "sum": - norm_hist = hist_count / hist_count.sum() + norm_hist: np.ndarray = hist_count / hist_count.sum() return norm_hist diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index 395fa4037..bb5dd314a 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -1282,20 +1282,20 @@ def test_compute_density_tracks(self): hist_abs, *_ = tc.compute_track_density( tc_tracks, res=10, - density=False, + norm=None, ) # hist_norm, *_ = tc.compute_track_density(tc_tracks, res=10, density=True) hist_wind_min, *_ = tc.compute_track_density( - tc_tracks, res=10, norm=False, wind_min=11, wind_max=None + tc_tracks, res=10, norm=None, wind_min=11, wind_max=None ) hist_wind_max, *_ = tc.compute_track_density( - tc_tracks, res=10, norm=False, wind_min=None, wind_max=30 + tc_tracks, res=10, norm=None, wind_min=None, wind_max=30 ) hist_wind_max, *_ = tc.compute_track_density( - tc_tracks, res=10, norm=False, wind_min=None, wind_max=30 + tc_tracks, res=10, norm=None, wind_min=None, wind_max=30 ) hist_wind_both, *_ = tc.compute_track_density( - tc_tracks, res=10, norm=False, wind_min=11, wind_max=29 + tc_tracks, res=10, norm=None, wind_min=11, wind_max=29 ) self.assertEqual(hist_abs.shape, (17, 35)) self.assertEqual(hist_abs.sum(), 4) From 3d96d790e739a0556602ceb516a4142687986836 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Fri, 21 Feb 2025 18:09:28 +0100 Subject: [PATCH 27/38] remove from fast data type --- climada/hazard/tc_tracks.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 6b78d6334..839f3ce96 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -1644,7 +1644,7 @@ def from_FAST(cls, folder_name: str): """ LOGGER.info("Reading %s files.", len(get_file_names(folder_name))) - data: list = [] + data = [] for file in get_file_names(folder_name): if Path(file).suffix != ".nc": continue @@ -1653,32 +1653,28 @@ def from_FAST(cls, folder_name: str): for i in dataset.n_trk: # Select track - track: xr.Dataset = dataset.sel(n_trk=i, year=year) + track = dataset.sel(n_trk=i, year=year) # chunk dataset at first NaN value - lon: np.ndarray = track.lon_trks.data - last_valid_index: int = np.where(np.isfinite(lon))[0][-1] - track: xr.Dataset = track.isel( - time=slice(0, last_valid_index + 1) - ) + lon = track.lon_trks.data + last_valid_index = np.where(np.isfinite(lon))[0][-1] + track = track.isel(time=slice(0, last_valid_index + 1)) # Select lat, lon - lat: np.ndarray = track.lat_trks.data - lon: np.ndarray = track.lon_trks.data + lat = track.lat_trks.data + lon = track.lon_trks.data # Convert lon from 0-360 to -180 - 180 - lon: np.ndarray = ((lon + 180) % 360) - 180 + lon = ((lon + 180) % 360) - 180 # Convert time to pandas Datetime "yyyy.mm.dd" reference_time = ( f"{track.tc_years.item()}-{int(track.tc_month.item())}-01" ) - time: np.datetime64 = pd.to_datetime( + time = pd.to_datetime( track.time.data, unit="s", origin=reference_time ).astype("datetime64[s]") # Define variables - ms_to_kn: float = 1.943844 - max_wind_kn: np.ndarray = track.vmax_trks.data * ms_to_kn - env_pressure: float = BASIN_ENV_PRESSURE[ - track.tc_basins.data.item() - ] - cen_pres: np.ndarray = _estimate_pressure( + ms_to_kn = 1.943844 + max_wind_kn = track.vmax_trks.data * ms_to_kn + env_pressure = BASIN_ENV_PRESSURE[track.tc_basins.data.item()] + cen_pres = _estimate_pressure( np.full(lat.shape, np.nan), lat, lon, From c622d6e90d628ec9e20c1609848855a9a0e76525 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Fri, 21 Feb 2025 18:15:00 +0100 Subject: [PATCH 28/38] fix test --- climada/hazard/tc_tracks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 839f3ce96..83be4cebd 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3034,7 +3034,9 @@ def compute_track_density( """ limit_ratio: float = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin - time_value: float = tc_track.data[0].time_step[0].values # Type hint for jenkins + time_value: float = ( + tc_track.data[0].time_step[0].values.astype(float) + ) # Type hint for jenkins if time_value > (res / limit_ratio): warnings.warn( From 29247b1d99c50d50b7c894e200b5eb0f176bc025 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Sun, 23 Feb 2025 16:25:15 +0100 Subject: [PATCH 29/38] possible fix to pre-commit --- climada/hazard/tc_tracks.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 83be4cebd..5547f8cae 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3034,9 +3034,7 @@ def compute_track_density( """ limit_ratio: float = 1.12 * 1.1 # record tc speed 112km/h -> 1.12°/h + 10% margin - time_value: float = ( - tc_track.data[0].time_step[0].values.astype(float) - ) # Type hint for jenkins + time_value: float = tc_track.data[0].time_step[0].values.astype(float) if time_value > (res / limit_ratio): warnings.warn( From 32a0fde15b9a9490ae1c45b094b1ff13765bb214 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Wed, 5 Mar 2025 15:38:50 +0100 Subject: [PATCH 30/38] update function --- climada/hazard/plot.py | 30 ++++++++++++++++++++++++------ climada/hazard/tc_tracks.py | 34 +++++++++++++++++++++++++--------- climada/util/coordinates.py | 19 +++++++++++++++---- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/climada/hazard/plot.py b/climada/hazard/plot.py index 586fea2c5..1591b74cf 100644 --- a/climada/hazard/plot.py +++ b/climada/hazard/plot.py @@ -23,6 +23,7 @@ import cartopy.crs as ccrs import cartopy.feature as cfeature +import matplotlib.colors as mcolors import matplotlib.pyplot as plt import numpy as np from deprecation import deprecated @@ -168,11 +169,13 @@ def plot_track_density( add_features: dict = None, title: str = None, figsize=(12, 6), + div_cmap=False, + cbar=True, cbar_kwargs: dict = { "orientation": "horizontal", "pad": 0.05, "shrink": 0.8, - "label": "Track Density [n° tracks / km²]", + "label": "n° tracks per 1° x 1° grid cell", }, **kwargs, ): @@ -194,6 +197,10 @@ def plot_track_density( Title of the plot. figsize: tuple Figure size when creating a new figure. + div_cmap: bool, default = False + If True, the colormap will be centered to 0. + cbar: bool, Default = True + If True, the color bar is added cbar_kwargs: dict dictionary containing keyword arguments passed to cbar kwargs: @@ -262,12 +269,23 @@ def plot_track_density( if add_features.get("lakes", False): axis.add_feature(cfeature.LAKES, alpha=0.4, edgecolor="black") - # Plot density with contourf - contourf = axis.contourf(lon, lat, hist, transform=ccrs.PlateCarree(), **kwargs) + if div_cmap: + norm = mcolors.TwoSlopeNorm( + vmin=np.nanmin(hist), vcenter=0, vmax=np.nanmax(hist) + ) + kwargs["norm"] = norm + + # contourf = axis.contourf(lon, lat, hist, transform=ccrs.PlateCarree(), **kwargs) + contourf = axis.imshow( + hist, + extent=[lon.min(), lon.max(), lat.min(), lat.max()], + transform=ccrs.PlateCarree(), + origin="lower", + **kwargs, + ) - # Add colorbar - plt.colorbar(contourf, ax=axis, **cbar_kwargs) - # Title setup + if cbar: + plt.colorbar(contourf, ax=axis, **cbar_kwargs) if title: axis.set_title(title, fontsize=16) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 5547f8cae..bb0b1dedc 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2985,6 +2985,7 @@ def _zlib_from_dataarray(data_var: xr.DataArray) -> bool: def compute_track_density( tc_track: TCTracks, res: int = 5, + bounds: tuple = None, genesis: bool = False, norm: str = None, filter_tracks: bool = True, @@ -3005,19 +3006,23 @@ def compute_track_density( track object containing a list of all tracks res: int (optional), default: 5° resolution in degrees of the grid bins in which the density will be computed + bounds: tuple, dafault: None + (lat_min,lat_max,lon_min,lon_max) latitude and longitude bounds to compute track density. genesis: bool, Default = False If true the function computes the track density of only the genesis location of tracks - norm: bool (optional), default = False - If False it returns the number of samples in each bin. If True, returns the - specified normalization function at each bin computed as count_bin / grid_area. + norm: str (optional), default = None + If None the function returns the number of samples in each bin. If True, it normalize the + bin count as specified: if norm = area -> normalize by gird cell area. If norm = sum -> + normalize by the total sum of each bin. filter_tracks: bool (optional) default: True If True the track density is computed as the number of different tracks crossing a grid cell. If False, the track density takes into account how long the track stayed in each grid cell. Hence slower tracks increase the density if the parameter is set to False. wind_min: float (optional), default: None - Minimum wind speed above which to select tracks. + Minimum wind speed above which to select tracks (inclusive). wind_max: float (optional), default: None - Maximal wind speed below which to select tracks. + Maximal wind speed below which to select tracks (exclusive if wind_min is also provided, + otherwise inclusive). Returns: ------- hist_count: np.ndarray @@ -3048,8 +3053,14 @@ def compute_track_density( ) # define grid resolution and bounds for density computation - lat_bins: np.ndarray = np.linspace(-90, 90, int(180 / res)) - lon_bins: np.ndarray = np.linspace(-180, 180, int(360 / res)) + if not bounds: + lat_min, lat_max, lon_min, lon_max = -90, 90, -180, 180 + else: + lat_min, lat_max, lon_min, lon_max = bounds[0], bounds[1], bounds[2], bounds[3] + + lat_bins: np.ndarray = np.linspace(lat_min, lat_max, int(180 / res)) + lon_bins: np.ndarray = np.linspace(lon_min, lon_max, int(360 / res)) + # compute 2D density if genesis: hist_count = compute_genesis_density( @@ -3062,7 +3073,7 @@ def compute_track_density( # select according to wind speed wind_speed = track.max_sustained_wind.values if wind_min and wind_max: - index = np.where((wind_speed >= wind_min) & (wind_speed <= wind_max))[0] + index = np.where((wind_speed >= wind_min) & (wind_speed < wind_max))[0] elif wind_min and not wind_max: index = np.where(wind_speed >= wind_min)[0] elif wind_max and not wind_min: @@ -3147,9 +3158,14 @@ def normalize_hist( """ if norm == "area": - grid_area, _ = u_coord.compute_grid_cell_area(res=res) + grid_area = u_coord.compute_grid_cell_area(res=res) norm_hist: np.ndarray = hist_count / grid_area elif norm == "sum": norm_hist: np.ndarray = hist_count / hist_count.sum() + else: + raise ValueError( + "Invalid value for input parameter 'norm':\n" + "it should be either 'area' or 'sum'" + ) return norm_hist diff --git a/climada/util/coordinates.py b/climada/util/coordinates.py index 1cce70e58..84056239b 100644 --- a/climada/util/coordinates.py +++ b/climada/util/coordinates.py @@ -528,7 +528,10 @@ def compute_grid_cell_area_validation(res: float) -> tuple[np.ndarray]: def compute_grid_cell_area( - res: float = 1.0, projection: str = "WGS84", units: str = "km^2" + res: float = None, + bounds: tuple = None, + projection: str = "WGS84", + units: str = "km^2", ) -> np.ndarray: """ Compute the area of each grid cell in a latitude-longitude grid. @@ -536,7 +539,9 @@ def compute_grid_cell_area( Parameters: ----------- res: float - Grid resolution in degrees (default is 1° x 1°) + Grid resolution in degrees + bounds: tuple, dafault: None + (lat_min,lat_max,lon_min,lon_max) latitude and longitude bounds to compute grid cell area projection: str Ellipsoid or spherical projection to approximate Earth. To get the complete list of projections call :py:meth:`pyproj.get_ellps_map()`. Widely used projections: @@ -556,8 +561,14 @@ def compute_grid_cell_area( >>> area = compute_grid_areas(res = 1, projection ="sphere", units = "m^2") """ geod = Geod(ellps=projection) # Use specified ellipsoid model - lat_edges = np.linspace(-90, 90, int(180 / res)) # Latitude edges - lon_edges = np.linspace(-180, 180, int(360 / res)) # Longitude edges + + if not bounds: + lat_min, lat_max, lon_min, lon_max = -90, 90, -180, 180 + else: + lat_min, lat_max, lon_min, lon_max = bounds[0], bounds[1], bounds[2], bounds[3] + + lat_edges: np.ndarray = np.linspace(lat_min, lat_max, int(180 / res)) + lon_edges: np.ndarray = np.linspace(lon_min, lon_max, int(360 / res)) area = np.zeros((len(lat_edges) - 1, len(lon_edges) - 1)) # Create an empty grid From 1f84c40b33dd7a04be5ee68176a9d5c8cf1ae04a Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Wed, 5 Mar 2025 15:49:43 +0100 Subject: [PATCH 31/38] update doc strings --- climada/hazard/tc_tracks.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index bb0b1dedc..b333df2ac 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -2991,7 +2991,7 @@ def compute_track_density( filter_tracks: bool = True, wind_min: float = None, wind_max: float = None, -) -> tuple[np.ndarray, tuple]: +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Compute absolute and normalized tropical cyclone track density. Before using this function, apply the same temporal resolution to all tracks by calling :py:meth:`equal_timestep` on the TCTrack object. Due to the computational cost of the this function, it is not recommended to @@ -3006,11 +3006,11 @@ def compute_track_density( track object containing a list of all tracks res: int (optional), default: 5° resolution in degrees of the grid bins in which the density will be computed - bounds: tuple, dafault: None - (lat_min,lat_max,lon_min,lon_max) latitude and longitude bounds to compute track density. - genesis: bool, Default = False + bounds: tuple, (optional) dafault: None + (lat_min,lat_max,lon_min,lon_max) latitude and longitude bounds. + genesis: bool, (optional) default = False If true the function computes the track density of only the genesis location of tracks - norm: str (optional), default = None + norm: str (optional), default: None If None the function returns the number of samples in each bin. If True, it normalize the bin count as specified: if norm = area -> normalize by gird cell area. If norm = sum -> normalize by the total sum of each bin. @@ -3028,13 +3028,17 @@ def compute_track_density( hist_count: np.ndarray 2D matrix containing the the absolute count per gridd cell of track point or the normalized number of track points, depending on the norm parameter. + lat_bins: np.ndarray + latitude bins in which the point where counted + lon_bins: np.ndarray + laongitude bins in which the point where counted Example: -------- >>> tc_tracks = TCTrack.from_ibtracs_netcdf("path_to_file") >>> tc_tracks.equal_timestep(time_steph_h = 1) - >>> hist_count, *_ = compute_track_density(res = 1) - >>> plot_track_density(hist_count) + >>> hist_count, *_ = compute_track_density(tc_track = tc_tracks, res = 1) + >>> ax = plot_track_density(hist_count) """ @@ -3110,11 +3114,11 @@ def compute_genesis_density( ----------- tc_track: TCT track object - track object containing a list of all tracks + TC track object containing a list of all tracks. lat_bins: 1D np.array - array containg the latitude bins + array containg the latitude bins. lon_bins: 1D np.array - array containg the longitude bins + array containg the longitude bins. Returns: -------- From 7414fd55ee30360eb2254963829d00fa17d47d3a Mon Sep 17 00:00:00 2001 From: Nicolas Colombi <115944312+NicolasColombi@users.noreply.github.com> Date: Fri, 14 Mar 2025 09:58:59 +0100 Subject: [PATCH 32/38] Update climada/hazard/tc_tracks.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sarah Hülsen <49907095+sarah-hlsn@users.noreply.github.com> --- climada/hazard/tc_tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index dd5047a66..9d44dece5 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3021,7 +3021,7 @@ def compute_track_density( norm: str (optional), default: None If None the function returns the number of samples in each bin. If True, it normalize the bin count as specified: if norm = area -> normalize by gird cell area. If norm = sum -> - normalize by the total sum of each bin. + normalize by the total sum across all bins. filter_tracks: bool (optional) default: True If True the track density is computed as the number of different tracks crossing a grid cell. If False, the track density takes into account how long the track stayed in each From 2f6ee27bde55ac45fa015de69ee535c11a61a28a Mon Sep 17 00:00:00 2001 From: Nicolas Colombi <115944312+NicolasColombi@users.noreply.github.com> Date: Fri, 14 Mar 2025 09:59:21 +0100 Subject: [PATCH 33/38] Update climada/hazard/tc_tracks.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sarah Hülsen <49907095+sarah-hlsn@users.noreply.github.com> --- climada/hazard/tc_tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 9d44dece5..bbb2b9e0a 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3044,7 +3044,7 @@ def compute_track_density( Example: -------- >>> tc_tracks = TCTrack.from_ibtracs_netcdf("path_to_file") - >>> tc_tracks.equal_timestep(time_steph_h = 1) + >>> tc_tracks.equal_timestep(time_step_h = 1) >>> hist_count, *_ = compute_track_density(tc_track = tc_tracks, res = 1) >>> ax = plot_track_density(hist_count) From f398b59c72f11f27ee6c097a715100593d1fdc4f Mon Sep 17 00:00:00 2001 From: Nicolas Colombi <115944312+NicolasColombi@users.noreply.github.com> Date: Fri, 14 Mar 2025 09:59:35 +0100 Subject: [PATCH 34/38] Update climada/hazard/tc_tracks.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sarah Hülsen <49907095+sarah-hlsn@users.noreply.github.com> --- climada/hazard/tc_tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index bbb2b9e0a..cce52adc2 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3037,7 +3037,7 @@ def compute_track_density( 2D matrix containing the the absolute count per gridd cell of track point or the normalized number of track points, depending on the norm parameter. lat_bins: np.ndarray - latitude bins in which the point where counted + latitude bins in which the point were counted lon_bins: np.ndarray laongitude bins in which the point where counted From 11daf462ff9c136488a4d30a23bddcdf3483df29 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi <115944312+NicolasColombi@users.noreply.github.com> Date: Fri, 14 Mar 2025 09:59:52 +0100 Subject: [PATCH 35/38] Update climada/hazard/tc_tracks.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sarah Hülsen <49907095+sarah-hlsn@users.noreply.github.com> --- climada/hazard/tc_tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index cce52adc2..3d286e18c 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3039,7 +3039,7 @@ def compute_track_density( lat_bins: np.ndarray latitude bins in which the point were counted lon_bins: np.ndarray - laongitude bins in which the point where counted + longitude bins in which the point were counted Example: -------- From d41c268c7e2496aea4933594dd28cf873d2d62d6 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi <115944312+NicolasColombi@users.noreply.github.com> Date: Fri, 14 Mar 2025 10:00:05 +0100 Subject: [PATCH 36/38] Update climada/hazard/tc_tracks.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sarah Hülsen <49907095+sarah-hlsn@users.noreply.github.com> --- climada/hazard/tc_tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 3d286e18c..77c6f63a8 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3034,7 +3034,7 @@ def compute_track_density( Returns: ------- hist_count: np.ndarray - 2D matrix containing the the absolute count per gridd cell of track point or the normalized + 2D matrix containing the the absolute count per grid cell of track point or the normalized number of track points, depending on the norm parameter. lat_bins: np.ndarray latitude bins in which the point were counted From d483f09b5ff6956ab86523d8b0b3bb80137ab018 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi <115944312+NicolasColombi@users.noreply.github.com> Date: Fri, 14 Mar 2025 10:00:30 +0100 Subject: [PATCH 37/38] Update climada/hazard/tc_tracks.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sarah Hülsen <49907095+sarah-hlsn@users.noreply.github.com> --- climada/hazard/tc_tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index 77c6f63a8..6249a4cd6 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3010,7 +3010,7 @@ def compute_track_density( Parameters: ---------- - tc_track: TCT track object + tc_track: TCTracks object track object containing a list of all tracks res: int (optional), default: 5° resolution in degrees of the grid bins in which the density will be computed From 04e289ddcfec2cb33831f3af4564f2aa06a3e3b4 Mon Sep 17 00:00:00 2001 From: Nicolas Colombi Date: Fri, 14 Mar 2025 10:10:52 +0100 Subject: [PATCH 38/38] change bounds order for consistency --- climada/hazard/tc_tracks.py | 6 +++--- climada/util/coordinates.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/climada/hazard/tc_tracks.py b/climada/hazard/tc_tracks.py index dd5047a66..e766c092e 100644 --- a/climada/hazard/tc_tracks.py +++ b/climada/hazard/tc_tracks.py @@ -3015,7 +3015,7 @@ def compute_track_density( res: int (optional), default: 5° resolution in degrees of the grid bins in which the density will be computed bounds: tuple, (optional) dafault: None - (lat_min,lat_max,lon_min,lon_max) latitude and longitude bounds. + (lon_min, lat_min, lon_max, lat_max) latitude and longitude bounds. genesis: bool, (optional) default = False If true the function computes the track density of only the genesis location of tracks norm: str (optional), default: None @@ -3066,9 +3066,9 @@ def compute_track_density( # define grid resolution and bounds for density computation if not bounds: - lat_min, lat_max, lon_min, lon_max = -90, 90, -180, 180 + lon_min, lat_min, lon_max, lat_max = -180, -90, 180, 90 else: - lat_min, lat_max, lon_min, lon_max = bounds[0], bounds[1], bounds[2], bounds[3] + lon_min, lat_min, lon_max, lat_max = bounds[0], bounds[1], bounds[2], bounds[3] lat_bins: np.ndarray = np.linspace(lat_min, lat_max, int(180 / res)) lon_bins: np.ndarray = np.linspace(lon_min, lon_max, int(360 / res)) diff --git a/climada/util/coordinates.py b/climada/util/coordinates.py index 0d41f4e91..073253b38 100644 --- a/climada/util/coordinates.py +++ b/climada/util/coordinates.py @@ -541,7 +541,7 @@ def compute_grid_cell_area( res: float Grid resolution in degrees bounds: tuple, dafault: None - (lat_min,lat_max,lon_min,lon_max) latitude and longitude bounds to compute grid cell area + (lon_min, lat_min, lon_max, lat_max) latitude and longitude bounds to compute grid cell area projection: str Ellipsoid or spherical projection to approximate Earth. To get the complete list of projections call :py:meth:`pyproj.get_ellps_map()`. Widely used projections: @@ -560,17 +560,17 @@ def compute_grid_cell_area( -------- >>> area = compute_grid_areas(res = 1, projection ="sphere", units = "m^2") """ - geod = Geod(ellps=projection) # Use specified ellipsoid model + geod = Geod(ellps=projection) if not bounds: - lat_min, lat_max, lon_min, lon_max = -90, 90, -180, 180 + lon_min, lat_min, lon_max, lat_max = -180, -90, 180, 90 else: - lat_min, lat_max, lon_min, lon_max = bounds[0], bounds[1], bounds[2], bounds[3] + lon_min, lat_min, lon_max, lat_max = bounds[0], bounds[1], bounds[2], bounds[3] lat_edges: np.ndarray = np.linspace(lat_min, lat_max, int(180 / res)) lon_edges: np.ndarray = np.linspace(lon_min, lon_max, int(360 / res)) - area = np.zeros((len(lat_edges) - 1, len(lon_edges) - 1)) # Create an empty grid + area = np.zeros((len(lat_edges) - 1, len(lon_edges) - 1)) # Iterate over consecutive latitude and longitude edges for i, (lat1, lat2) in enumerate(zip(lat_edges[:-1], lat_edges[1:])):