Skip to content

Commit 67ef797

Browse files
committed
Merge branch 'calibrate-impact-functions' of https://github.com/CLIMADA-project/climada_python into calibrate-impact-functions
2 parents 8349016 + 2ace55f commit 67ef797

File tree

15 files changed

+343
-75
lines changed

15 files changed

+343
-75
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,13 @@ Code freeze date: YYYY-MM-DD
1414

1515
### Changed
1616

17+
- Update `CONTRIBUTING.md` to better explain types of contributions to this repository [#797](https://github.com/CLIMADA-project/climada_python/pull/797)
18+
- The default tile layer in Exposures maps is not Stamen Terrain anymore, but [CartoDB Positron](https://github.com/CartoDB/basemap-styles). Affected methods are `climada.engine.Impact.plot_basemap_eai_exposure`,`climada.engine.Impact.plot_basemap_impact_exposure` and `climada.entity.Exposures.plot_basemap`. [#798](https://github.com/CLIMADA-project/climada_python/pull/798)
19+
1720
### Fixed
1821

22+
- Fix the dist_approx util function when used with method="geosphere" and log=True and points that are very close. [#792](https://github.com/CLIMADA-project/climada_python/pull/792)
23+
1924
### Deprecated
2025

2126
### Removed

CONTRIBUTING.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
11
# CLIMADA Contribution Guide
22

3-
Thank you for contributing to CLIMADA!
3+
We welcome any contribution to CLIMADA and want to express our thanks to everybody who contributes.
44

5-
## Overview
5+
## What Warrants a Contribution?
6+
7+
Anything!
8+
For orientation, these are some categories of possible contributions we can think of:
9+
10+
* **Technical problems and bugs:** Did you encounter a problem when using CLIMADA? Raise an [issue](https://github.com/CLIMADA-project/climada_python/issues) in our repository, providing a description or ideally a code replicating the error. Did you already find a solution to the problem? Please raise a pull request to help us resolve the issue!
11+
* **Documentation and Tutorial Updates:** Found a typo in the documentation? Is a tutorial lacking some information you find important? Simply fix a line, or add a paragraph. We are happy to incorporate you additions! Please raise a pull request!
12+
* **New Modules and Utility Functions:** Did you create a function or an entire module you find useful for your work? Maybe you are not the only one! Feel free to simply raise a pull request for functions that improve, e.g., plotting or data handling. As an entire module has to be carefully integrated into the framework, it might help if you talk to us first so we can design the module and plan the next steps. You can do that by raising an issue or starting a [discussion](https://github.com/CLIMADA-project/climada_python/discussions) on GitHub.
13+
14+
A good place to start a personal discussion is our monthly CLIMADA developers call.
15+
Please contact the [lead developers](https://wcr.ethz.ch/research/climada.html) if you want to join.
16+
17+
## Why Should You Contribute?
18+
19+
* You will be listed as author of the CLIMADA repository in the [AUTHORS](AUTHORS.md) file.
20+
* You will improve the quality of the CLIMADA software for you and for everybody else using it.
21+
* You will gain insights into scientific software development.
22+
23+
## Minimal Steps to Contribute
624

725
Before you start, please have a look at our [Developer Guide][devguide].
826

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ Please use the following logo if you are presenting results obtained with or thr
6161

6262
## Contributing
6363

64-
See the [Contribution Guide](CONTRIBUTING.md).
64+
We welcome any contribution to this repository, be it bugfixes and other code changes and additions, documentation improvements, or tutorial updates.
65+
66+
If you would like to contribute, please refer to our [Contribution Guide](CONTRIBUTING.md).
6567

6668
## Versioning
6769

climada/engine/impact.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,7 @@ def plot_raster_eai_exposure(self, res=None, raster_res=None, save_tiff=None,
673673

674674
def plot_basemap_eai_exposure(self, mask=None, ignore_zero=False, pop_name=True,
675675
buffer=0.0, extend='neither', zoom=10,
676-
url=ctx.providers.Stamen.Terrain,
676+
url=ctx.providers.CartoDB.Positron,
677677
axis=None, **kwargs):
678678
"""Plot basemap expected impact of each exposure within a period of 1/frequency_unit.
679679
@@ -694,7 +694,7 @@ def plot_basemap_eai_exposure(self, mask=None, ignore_zero=False, pop_name=True,
694694
zoom : int, optional
695695
zoom coefficient used in the satellite image
696696
url : str, optional
697-
image source, e.g. ctx.providers.OpenStreetMap.Mapnik
697+
image source, default: ctx.providers.CartoDB.Positron
698698
axis : matplotlib.axes.Axes, optional
699699
axis to use
700700
kwargs : dict, optional
@@ -764,7 +764,7 @@ def plot_hexbin_impact_exposure(self, event_id=1, mask=None, ignore_zero=False,
764764

765765
def plot_basemap_impact_exposure(self, event_id=1, mask=None, ignore_zero=False,
766766
pop_name=True, buffer=0.0, extend='neither', zoom=10,
767-
url=ctx.providers.Stamen.Terrain,
767+
url=ctx.providers.CartoDB.Positron,
768768
axis=None, **kwargs):
769769
"""Plot basemap impact of an event at each exposure.
770770
Requires attribute imp_mat.
@@ -789,7 +789,7 @@ def plot_basemap_impact_exposure(self, event_id=1, mask=None, ignore_zero=False,
789789
zoom : int, optional
790790
zoom coefficient used in the satellite image
791791
url : str, optional
792-
image source, e.g. ctx.providers.OpenStreetMap.Mapnik
792+
image source, default: ctx.providers.CartoDB.Positron
793793
axis : matplotlib.axes.Axes, optional
794794
axis to use
795795
kwargs : dict, optional

climada/entity/exposures/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -749,7 +749,7 @@ def plot_raster(self, res=None, raster_res=None, save_tiff=None,
749749

750750
def plot_basemap(self, mask=None, ignore_zero=False, pop_name=True,
751751
buffer=0.0, extend='neither', zoom=10,
752-
url=None, axis=None, **kwargs):
752+
url=ctx.providers.CartoDB.Positron, axis=None, **kwargs):
753753
"""Scatter points over satellite image using contextily
754754
755755
Parameters
@@ -771,7 +771,7 @@ def plot_basemap(self, mask=None, ignore_zero=False, pop_name=True,
771771
zoom coefficient used in the satellite image
772772
url : Any, optional
773773
image source, e.g., ``ctx.providers.OpenStreetMap.Mapnik``.
774-
Default: ``ctx.providers.Stamen.Terrain``
774+
Default: ``ctx.providers.CartoDB.Positron``
775775
axis : matplotlib.axes._subplots.AxesSubplot, optional
776776
axis to use
777777
kwargs : optional

climada/hazard/test/test_trop_cyclone.py

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -304,22 +304,61 @@ def test_v_max_s_holland_2008_pass(self):
304304

305305
def test_holland_2010_pass(self):
306306
"""Test Holland et al. 2010 wind field model."""
307-
# test at centroids within and outside of radius of max wind
307+
# The parameter "x" is designed to be exactly 0.5 inside the radius of max wind (RMW) and
308+
# to increase or decrease linearly outside of it in radial direction.
309+
#
310+
# An increase (decrease) of "x" outside of the RMW is for cases where the max wind is very
311+
# high (low), but the RMW is still comparably large (small). This means, wind speeds need
312+
# to decay very sharply (only moderately) outside of the RMW to reach the low prescribed
313+
# peripheral wind speeds.
314+
#
315+
# The "hol_b" parameter tunes the meaning of a "comparably" large or small RMW.
308316
si_track = xr.Dataset({
309-
"rad": ("time", KM_TO_M * np.array([75, 40])),
310-
"vmax": ("time", [35.0, 40.0]),
311-
"hol_b": ("time", [1.80, 2.5]),
317+
# four test cases:
318+
# - low vmax, moderate RMW: x decreases moderately
319+
# - large hol_b: x decreases sharply
320+
# - very low vmax: x decreases so much, it needs to be clipped at 0
321+
# - large vmax, large RMW: x increases
322+
"rad": ("time", KM_TO_M * np.array([75, 75, 75, 90])),
323+
"vmax": ("time", [35.0, 35.0, 16.0, 90.0]),
324+
"hol_b": ("time", [1.75, 2.5, 1.9, 1.6]),
312325
})
313-
d_centr = KM_TO_M * np.array([[35, 75, 220], [30, 1000, 300]], dtype=float)
314-
close_centr = np.array([[True, True, True], [True, False, True]], dtype=bool)
326+
d_centr = KM_TO_M * np.array([
327+
# first column is for locations within the storm eye
328+
# second column is for locations at or close to the radius of max wind
329+
# third column is for locations outside the storm eye
330+
# fourth column is for locations exactly at the peripheral radius
331+
# fifth column is for locations outside the peripheral radius
332+
[0., 75, 220, 300, 490],
333+
[30, 74, 170, 300, 501],
334+
[21, 76, 230, 300, 431],
335+
[32, 91, 270, 300, 452],
336+
], dtype=float)
337+
close_centr = np.array([
338+
# note that we set one of these to "False" for testing
339+
[True, True, True, True, True],
340+
[True, True, True, True, False],
341+
[True, True, True, True, True],
342+
[True, True, True, True, True],
343+
], dtype=bool)
315344
hol_x = _x_holland_2010(si_track, d_centr, close_centr)
316-
np.testing.assert_array_almost_equal(
317-
hol_x, [[0.5, 0.5, 0.47273], [0.5, 0, 0.211602]])
345+
np.testing.assert_array_almost_equal(hol_x, [
346+
[0.5, 0.500000, 0.485077, 0.476844, 0.457291],
347+
[0.5, 0.500000, 0.410997, 0.289203, 0.000000],
348+
[0.5, 0.497620, 0.131072, 0.000000, 0.000000],
349+
[0.5, 0.505022, 1.403952, 1.554611, 2.317948],
350+
])
318351

319-
# test exactly at radius of maximum wind (35 m/s) and at peripheral radius (17 m/s)
320352
v_ang_norm = _stat_holland_2010(si_track, d_centr, close_centr, hol_x)
321-
np.testing.assert_array_almost_equal(v_ang_norm,
322-
[[15.957853, 35.0, 20.99411], [33.854826, 0, 17.0]])
353+
np.testing.assert_array_almost_equal(v_ang_norm, [
354+
# first column: converge to 0 when approaching storm eye
355+
# second column: vmax at RMW
356+
# fourth column: peripheral speed (17 m/s) at peripheral radius (unless x is clipped!)
357+
[0.0000000, 35.000000, 21.181497, 17.00000, 12.103461],
358+
[1.2964800, 34.990037, 21.593755, 17.00000, 0.0000000],
359+
[0.3219518, 15.997500, 13.585498, 16.00000, 16.000000],
360+
[24.823469, 89.992938, 24.381965, 17.00000, 1.9292020],
361+
])
323362

324363
def test_stat_holland_1980(self):
325364
"""Test _stat_holland_1980 function. Compare to MATLAB reference."""

climada/hazard/trop_cyclone.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1424,7 +1424,13 @@ def _x_holland_2010(
14241424
# linearly interpolate between max exponent and peripheral exponent
14251425
x_max = 0.5
14261426
hol_x[close_centr] = x_max + np.fmax(0, d_centr - r_max) * (x_n - x_max) / (r_n - r_max)
1427-
hol_x[close_centr] = np.clip(hol_x[close_centr], 0.0, 0.5)
1427+
1428+
# Negative hol_x values appear when v_max_s is very close to or even lower than v_n (which
1429+
# should never happen in theory). In those cases, wind speeds might decrease outside of the eye
1430+
# wall and increase again towards the peripheral radius (which is actually unphysical).
1431+
# We clip hol_x to 0, otherwise wind speeds keep increasing indefinitely away from the eye:
1432+
hol_x[close_centr] = np.fmax(hol_x[close_centr], 0.0)
1433+
14281434
return hol_x
14291435

14301436
def _stat_holland_2010(

climada/test/test_plot.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,7 @@ def test_ctx_osm_pass(self):
162162
myexp.gdf['value'] = np.array([1, 1, 1])
163163
myexp.check()
164164

165-
try:
166-
myexp.plot_basemap(url=ctx.providers.OpenStreetMap.Mapnik)
167-
except urllib.error.HTTPError:
168-
self.assertEqual(1, 0)
165+
myexp.plot_basemap(url=ctx.providers.OpenStreetMap.Mapnik)
169166

170167
def test_disc_rates(self):
171168
"""Test plot function of discount rates."""

climada/util/api_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ def _divide_straight_from_multi(properties):
341341
elif isinstance(_v, list):
342342
multis[k] = _v
343343
else:
344-
raise ValueError("properties must be a string or a list of strings")
344+
raise ValueError("the value of a property must be a string or a list of strings")
345345
return straights, multis
346346

347347
@staticmethod

climada/util/calibrate/base.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pandas as pd
99
import numpy as np
1010
from scipy.optimize import Bounds, LinearConstraint, NonlinearConstraint
11+
import matplotlib.pyplot as plt
1112
import seaborn as sns
1213

1314
from climada.hazard import Hazard
@@ -154,6 +155,131 @@ def plot_impf_set(self, **plot_kwargs):
154155
"""
155156
return self.impf_set.plot(**plot_kwargs)
156157

158+
def plot_impf_variability(
159+
self,
160+
cost_func_diff: float = 0.1,
161+
p_space_df: Optional[pd.DataFrame] = None,
162+
plot_haz: bool = True,
163+
plot_impf_kws: Optional[dict] = None,
164+
plot_hist_kws: Optional[dict] = None,
165+
):
166+
167+
"""Plot impact function variability with parameter combinations of
168+
almost equal cost function values
169+
170+
Args:
171+
cost_func_diff (float, optional): Max deviation from optimal cost
172+
function value (as fraction). Defaults to 0.1 (i.e. 10%).
173+
p_space_df (pd.DataFrame, optional): parameter space. Defaults to None.
174+
plot_haz (bool, optional): Whether or not to plot hazard intensity
175+
distibution. Defaults to False.
176+
plot_impf_kws (dict, optional): Keyword arguments for impact
177+
function plot. Defaults to None.
178+
plot_hist_kws (dict, optional): Keyword arguments for hazard
179+
intensity distribution plot. Defaults to None.
180+
"""
181+
182+
# Initialize plot keyword arguments
183+
if plot_impf_kws is None:
184+
plot_impf_kws = {}
185+
if plot_hist_kws is None:
186+
plot_hist_kws = {}
187+
188+
# Retrieve hazard type and parameter space
189+
haz_type = self.input.hazard.haz_type
190+
if p_space_df is None:
191+
# Assert that self.output has the p_space_to_dataframe() method,
192+
# which is defined for the BayesianOptimizerOutput class
193+
if not hasattr(self.output,"p_space_to_dataframe"):
194+
raise TypeError(
195+
"To derive the full impact function parameter space, "
196+
"plot_impf_variability() requires BayesianOptimizerOutput "
197+
"as OutputEvaluator.output attribute, which provides the "
198+
"method p_space_to_dataframe()."
199+
)
200+
p_space_df = self.output.p_space_to_dataframe()
201+
202+
# Retrieve list of parameters required for creating impact functions
203+
# and remove the dimension 'Cost Function'.
204+
params = p_space_df.columns.tolist()
205+
try:
206+
params.remove('Cost Function')
207+
except ValueError:
208+
pass
209+
210+
# Retrieve parameters of impact functions with cost function values
211+
# within 'cost_func_diff' % of the best estimate
212+
params_within_range = p_space_df[params]
213+
plot_space_label = 'Parameter space'
214+
if cost_func_diff is not None:
215+
max_cost_func_val = (p_space_df['Cost Function'].min()*
216+
(1+cost_func_diff))
217+
params_within_range = p_space_df.loc[
218+
p_space_df['Cost Function'] <=max_cost_func_val,params
219+
]
220+
plot_space_label = (f"within {int(cost_func_diff*100)} percent "
221+
f"of best fit")
222+
223+
# Set plot defaults
224+
color = plot_impf_kws.pop('color','tab:blue')
225+
lw = plot_impf_kws.pop('lw',2)
226+
zorder = plot_impf_kws.pop('zorder',3)
227+
label = plot_impf_kws.pop('label','best fit')
228+
229+
#get number of impact functions and create a plot for each
230+
n_impf = len(self.impf_set.get_func(haz_type=haz_type))
231+
axes=[]
232+
233+
for impf_idx in range(n_impf):
234+
235+
_,ax = plt.subplots()
236+
237+
#Plot best-fit impact function
238+
best_impf = self.impf_set.get_func(haz_type=haz_type)[impf_idx]
239+
ax.plot(best_impf.intensity,best_impf.mdd*best_impf.paa*100,
240+
color=color,lw=lw,zorder=zorder,label=label,**plot_impf_kws)
241+
242+
#Plot all impact functions within 'cost_func_diff' % of best estimate
243+
for row in range(params_within_range.shape[0]):
244+
label_temp = plot_space_label if row == 0 else None
245+
246+
sel_params = params_within_range.iloc[row,:].to_dict()
247+
temp_impf_set = self.input.impact_func_creator(**sel_params)
248+
temp_impf = temp_impf_set.get_func(haz_type=haz_type)[impf_idx]
249+
250+
ax.plot(temp_impf.intensity,temp_impf.mdd*temp_impf.paa*100,
251+
color='grey',alpha=0.4,label=label_temp)
252+
253+
# Plot hazard intensity value distributions
254+
if plot_haz:
255+
haz_vals = self.input.hazard.intensity[
256+
:, self.input.exposure.gdf[f"centr_{haz_type}"]
257+
]
258+
259+
#Plot defaults
260+
color_hist = plot_hist_kws.pop('color','tab:orange')
261+
alpha_hist = plot_hist_kws.pop('alpha',0.3)
262+
263+
ax2 = ax.twinx()
264+
ax2.hist(haz_vals.data,bins=40,color=color_hist,
265+
alpha=alpha_hist,label='Hazard intensity\noccurence')
266+
ax2.set(ylabel='Hazard intensity occurence (#Exposure points)')
267+
ax.axvline(x=haz_vals.max(),label='Maximum hazard value',
268+
color='tab:orange')
269+
ax2.legend(loc='lower right')
270+
271+
ax.set(xlabel=f"Intensity ({self.input.hazard.units})",
272+
ylabel="Mean Damage Ratio (MDR) in %",
273+
xlim=(min(best_impf.intensity),max(best_impf.intensity)))
274+
ax.legend()
275+
axes.append(ax)
276+
277+
if n_impf > 1:
278+
return axes
279+
280+
return ax
281+
282+
157283
def plot_at_event(
158284
self,
159285
data_transf: Callable[[pd.DataFrame], pd.DataFrame] = lambda x: x,

0 commit comments

Comments
 (0)