diff --git a/climada/entity/measures/__init__.py b/climada/entity/measures/__init__.py index f536fabfa..0676679e9 100644 --- a/climada/entity/measures/__init__.py +++ b/climada/entity/measures/__init__.py @@ -22,6 +22,9 @@ """ +from .cost_income import CostIncome +from .measure import Measure from .measure_config import MeasureConfig +from .measure_set import MeasureSet -__all__ = ["MeasureConfig"] +__all__ = ["CostIncome", "Measure", "MeasureConfig", "MeasureSet"] diff --git a/climada/entity/measures/cost_income.py b/climada/entity/measures/cost_income.py index b369b6bfe..0a33cdd1a 100644 --- a/climada/entity/measures/cost_income.py +++ b/climada/entity/measures/cost_income.py @@ -28,6 +28,8 @@ from climada.entity.measures.measure_config import CostIncomeConfig +__all__ = ["CostIncome"] + class CostIncome: """ diff --git a/climada/entity/measures/helper.py b/climada/entity/measures/helper.py index 771c9deee..6b2130e55 100644 --- a/climada/entity/measures/helper.py +++ b/climada/entity/measures/helper.py @@ -22,9 +22,10 @@ from __future__ import annotations import logging +from collections.abc import Callable from functools import reduce from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast import numpy as np import pandas as pd @@ -46,12 +47,22 @@ HazardChange, ImpfsetChange, ) - from climada.hazard.base import Hazard + from climada.hazard.base import Hazard # noqa: TC004 T = TypeVar("T", Exposures, ImpactFuncSet, Hazard) LOGGER = logging.getLogger(__name__) +__all__ = [ + "change_impfset", + "helper_exposure", + "helper_hazard", + "helper_impfset", + "identity_function", + "impact_intensity_rp_cutoff_helper", + "replace_hazard", +] + def identity_function(x: T, **_kwargs: Any) -> T: """Return the input object unchanged. @@ -193,7 +204,7 @@ def hazard_change( base_exposures: Exposures, base_impfset: ImpactFuncSet, base_hazard: Hazard, - exposures_region_id: Optional[list[int]] = None, + exposures_region_id: list[int] | None = None, **_kwargs, ) -> Hazard: exp_imp = base_exposures @@ -304,7 +315,7 @@ def hazard_change(hazard: Hazard, **_kwargs) -> Hazard: hazard_change = composite_fun( impact_intensity_rp_cutoff_helper(hazard_modifier.impact_rp_cutoff), hazard_change, - ) + ) # ty: ignore[invalid-assignment] if hazard_modifier.new_hazard_path is not None: if not isinstance(hazard_modifier.new_hazard_path, (str, Path)): diff --git a/climada/entity/measures/measure.py b/climada/entity/measures/measure.py index 1c095723a..fd05d1c11 100755 --- a/climada/entity/measures/measure.py +++ b/climada/entity/measures/measure.py @@ -29,8 +29,10 @@ import inspect import logging from functools import wraps -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, TypeVar +from climada.entity.exposures.base import Exposures +from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet from climada.entity.measures.helper import ( helper_exposure, helper_hazard, @@ -38,25 +40,22 @@ identity_function, ) from climada.entity.measures.measure_config import MeasureConfig +from climada.hazard.base import Hazard from .cost_income import CostIncome if TYPE_CHECKING: - from climada.entity.exposures.base import Exposures - from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet + from climada.entity.measures.types import ( ExposuresChange, HazardChange, ImpfsetChange, ) - from climada.hazard.base import Hazard T = TypeVar("T", Exposures, ImpactFuncSet, Hazard) LOGGER = logging.getLogger(__name__) -# TODO: risk transfer? - def allow_kwargs(func): """ @@ -199,10 +198,6 @@ def __init__( self.color_rgb = (0, 0, 0) if color_rgb is None else color_rgb self._config = _config - # DONE always provide exp, impfset and hazard as kwargs by default - # Have a precedence system (if users provide their own it takes over) - # TODO Check that it works - @property def is_serializable(self) -> bool: """Returns True if the ``Measure`` was created from @@ -212,7 +207,7 @@ def is_serializable(self) -> bool: return self._config is not None @classmethod - def from_config(cls, config: MeasureConfig) -> "Measure": + def from_config(cls, config: MeasureConfig) -> Measure: """Create a ``Measure`` from a ``MeasureConfig`` object. The modifiers defined in the configuration are converted into change @@ -245,7 +240,7 @@ def from_config(cls, config: MeasureConfig) -> "Measure": ) @classmethod - def from_dict(cls, d: dict) -> "Measure": + def from_dict(cls, d: dict) -> Measure: """Create a ``Measure`` from a dictionary. The dictionary is first converted to a ``MeasureConfig`` with @@ -337,9 +332,14 @@ def apply_exposures_changes( else exposures ) try: - return self.exposures_changes(changed_exp, **kwargs) + changed_exp = self.exposures_changes(changed_exp, **kwargs) + if not isinstance(changed_exp, Exposures): + raise TypeError( + "Exposures change function did not return an Exposures object." + ) + return changed_exp except TypeError as exc: - # Check if it's a missing argument error + # Make sure it's a missing argument error if "missing" in str(exc) and "required positional argument" in str(exc): raise TypeError( f"The function to apply to the exposures requires additional arguments\ @@ -395,7 +395,12 @@ def apply_impfset_changes( else impfset ) try: - return self.impfset_changes(changed_impfset, **kwargs) + changed_impfset = self.impfset_changes(changed_impfset, **kwargs) + if not isinstance(changed_impfset, ImpactFuncSet): + raise TypeError( + "Impfset change function did not return an ImpactFuncSet object." + ) + return changed_impfset except TypeError as exc: # Check if it's a missing argument error if "missing" in str(exc) and "required positional argument" in str(exc): @@ -452,7 +457,12 @@ def apply_hazard_changes( else hazard ) try: - return self.hazard_changes(changed_hazard, **kwargs) + changed_hazard = self.hazard_changes(changed_hazard, **kwargs) + if not isinstance(changed_hazard, Hazard): + raise TypeError( + "Hazard change function did not return an Hazard object." + ) + return changed_hazard except TypeError as exc: # Check if it's a missing argument error if "missing" in str(exc) and "required positional argument" in str(exc): diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py index 77ec61d32..265b18553 100644 --- a/climada/entity/measures/measure_config.py +++ b/climada/entity/measures/measure_config.py @@ -24,7 +24,6 @@ import logging import warnings from dataclasses import MISSING, dataclass, field, fields -from datetime import datetime from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -37,10 +36,10 @@ LOGGER = logging.getLogger(__name__) __all__ = [ - "HazardModifierConfig", + "CostIncomeConfig", "ExposuresModifierConfig", + "HazardModifierConfig", "ImpfsetModifierConfig", - "CostIncomeConfig", "MeasureConfig", ] @@ -209,10 +208,14 @@ class ImpfsetModifierConfig(ModifierConfig): Additive offset applied to the PAA curve after multiplication. Default is ``0.0``. impf_int_mult : float, optional - Multiplicative factor applied to the intensity axis. + Inverse of the multiplicative factor applied to the intensity axis. + That is ``0.5`` would double the values of the intensity axis, namely reducing by 50% + the damages for a given intensity. Default is ``1.0``. impf_int_add : float, optional - Additive offset applied to the intensity axis after multiplication. + Negative additive offset applied to the intensity axis after multiplication. + That a positive value shifts the values of the intensity axis to the left, namely + shifting the impact function to the right. Default is ``0.0``. new_impfset_path : str, optional Path to an Excel file containing a replacement impact function set. @@ -400,9 +403,9 @@ class CostIncomeConfig(ModifierConfig): Explicit cash flow schedule as a list of records with at minimum a ``"date"`` key (ISO 8601 string) and a value key. If provided, overrides the periodic cost/income logic. - """ # noqa + """ - mkt_price_year: int | None = field(default_factory=lambda: datetime.today().year) + mkt_price_year: int | None = field(default_factory=lambda: pd.Timestamp.now().year) init_cost: float = 0.0 periodic_cost: float = 0.0 periodic_income: float = 0.0 @@ -412,7 +415,7 @@ class CostIncomeConfig(ModifierConfig): custom_cash_flows: list[dict] | None = None @classmethod - def from_cost_income(cls, cost_income: CostIncome) -> "CostIncomeConfig": + def from_cost_income(cls, cost_income: CostIncome) -> CostIncomeConfig: """ Construct a :class:`CostIncomeConfig` from a live :class:`CostIncome` object. @@ -530,7 +533,7 @@ def to_dict(self, omit_default: bool = True) -> dict: } @classmethod - def from_dict(cls, kwargs_dict: dict) -> "MeasureConfig": + def from_dict(cls, kwargs_dict: dict) -> MeasureConfig: """ Instantiate a :class:`MeasureConfig` from a flat dictionary. @@ -604,7 +607,7 @@ def to_yaml(self, path: str) -> None: ) @classmethod - def from_yaml(cls, path: str) -> "MeasureConfig": + def from_yaml(cls, path: str) -> MeasureConfig: """ Load a :class:`MeasureConfig` from a YAML file. @@ -627,7 +630,7 @@ def from_yaml(cls, path: str) -> "MeasureConfig": return cls.from_dict(yaml.safe_load(opened_file)["measures"][0]) @classmethod - def from_row(cls, row: pd.Series) -> "MeasureConfig": + def from_row(cls, row: pd.Series) -> MeasureConfig: """ Construct a :class:`MeasureConfig` from a legacy Excel row. diff --git a/climada/entity/measures/types.py b/climada/entity/measures/types.py index 1ad90986b..d20edeca5 100644 --- a/climada/entity/measures/types.py +++ b/climada/entity/measures/types.py @@ -1,3 +1,24 @@ +""" +This file is part of CLIMADA. + +Copyright (C) 2017 ETH Zurich, CLIMADA contributors listed in AUTHORS. + +CLIMADA is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free +Software Foundation, version 3. + +CLIMADA is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with CLIMADA. If not, see . + +--- + +Define useful types for the measure module. +""" + from collections.abc import Callable from typing import Concatenate diff --git a/climada/test/conftest.py b/climada/test/conftest.py index 727215b2f..d9c3da54f 100644 --- a/climada/test/conftest.py +++ b/climada/test/conftest.py @@ -51,6 +51,7 @@ from shapely.geometry import Point from climada.entity import Exposures, ImpactFunc, ImpactFuncSet +from climada.entity.measures import Measure from climada.hazard import Centroids, Hazard # --------------------------------------------------------------------------- @@ -306,3 +307,13 @@ def _make_impfset( @pytest.fixture def impfset(impfset_factory): return impfset_factory() + + +@pytest.fixture +def measure_on_exposures(): + def reduce_exposure_value(exposures, reduction_factor): + """Reduces the value of all assets by a fixed percentage.""" + exposures.gdf["value"] *= 1 - reduction_factor + return exposures + + return Measure(name="Exposure reduction", exposures_changes=reduce_exposure_value) diff --git a/climada/test/test_measures.py b/climada/test/test_measures.py index 78fc2af85..9b709f891 100644 --- a/climada/test/test_measures.py +++ b/climada/test/test_measures.py @@ -1,306 +1,354 @@ """ -Integration tests for the Measure class. - -These tests use real CLIMADA objects (Exposures, ImpactFuncSet, Hazard) built -from the shared conftest fixtures. They verify that Measure transformations -produce analytically expected results rather than mocking internals. - -Expected impact values (from conftest docstring): - AAI = 18 - RP20 = 0 (event 1, freq 0.03) - RP50 = 0 (event 2, freq 0.01) - RP100 = 500 (event 3, freq 0.006 → 1000 * 0.5) - RP250 = 3750 (event 4, freq 0.004 → 15000 * 0.25) -""" +This file is part of CLIMADA. -import numpy as np -import pytest -from scipy.sparse import csr_matrix +Copyright (C) 2017 ETH Zurich, CLIMADA contributors listed in AUTHORS. -from climada.entity.measures.cost_income import CostIncome -from climada.entity.measures.measure import Measure -from climada.test.conftest import HAZARD_TYPE +CLIMADA is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free +Software Foundation, version 3. -# =========================================================================== -# apply() — structural / immutability tests -# =========================================================================== +CLIMADA is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU General Public License for more details. +You should have received a copy of the GNU General Public License along +with CLIMADA. If not, see . -class TestApplyStructural: - """Verify that apply() correctly transforms the risk triplet.""" +--- - def test_identity_measure_returns_equal_objects(self, exposures, impfset, hazard): - """An identity measure should return objects equal to the originals.""" - m = Measure("identity") - new_exp, new_impfset, new_haz = m.apply(exposures, impfset, hazard) +Integration tests for the measure pipeline: +MeasureConfig -> Measure -> modified (Exposures, ImpactFuncSet, Hazard) -> Impact. - assert new_exp.gdf["value"].tolist() == exposures.gdf["value"].tolist() - assert new_haz.intensity.nnz == hazard.intensity.nnz - assert list(new_impfset.get_func(haz_type=HAZARD_TYPE)) == list( - impfset.get_func(haz_type=HAZARD_TYPE) - ) - - def test_enforce_copy_true_does_not_mutate_originals( - self, exposures, impfset, hazard - ): - """With enforce_copy=True, the originals must not be mutated.""" - original_values = exposures.gdf["value"].copy() +Expected values follow the analytical design of ``conftest.py`` +(linear impact function, baseline AAI = 18, at_event = [0, 0, 500, 3750, 0]). +""" - def double_values(exp, **kw): - exp.gdf["value"] *= 2 - return exp +from unittest.mock import patch - m = Measure("doubler", exposures_changes=double_values) - m.apply(exposures, impfset, hazard, enforce_copy=True) +import numpy as np +import pytest - np.testing.assert_array_equal( - exposures.gdf["value"].values, original_values.values +from climada.engine.impact_calc import ImpactCalc +from climada.engine.test.test_impact_calc import exposure +from climada.entity.measures.measure import Measure +from climada.entity.measures.measure_config import ( + CostIncomeConfig, + ExposuresModifierConfig, + HazardModifierConfig, + ImpfsetModifierConfig, + MeasureConfig, +) +from climada.hazard.base import Hazard + +LOGGER = "climada.entity.measures.measure.LOGGER" +BASELINE_AAI = 18.0 + +# Note: at_event() does not care about the frequency +BASELINE_AT_EVENT = [0.0, 0.0, 500.0, 3750.0, 15000.0] +CATEGORIES = [1, 1, 2, 1, 1, 3] + + +@pytest.fixture +def triplet(exposures_factory, impfset, hazard): + """Return (exposures, impfset, hazard) with centroids assigned.""" + exposures = exposures_factory(categories=CATEGORIES) + exposures.assign_centroids(hazard) + return exposures, impfset, hazard + + +@pytest.fixture +def config_factory(hazard): + """Return a function building a MeasureConfig from modifier overrides.""" + + def _make_config( + name="measure", + impfset=None, + hazard_mod=None, + exposures_mod=None, + cost_income=None, + ): + """ + Parameters + ---------- + name : str, optional + Measure name. + impfset, hazard_mod, exposures_mod, cost_income : dict, optional + Keyword arguments of the corresponding sub-configuration. + + Returns + ------- + MeasureConfig + """ + haz_type = hazard.haz_type + return MeasureConfig( + name=name, + haz_type=haz_type, + impfset_modifier=ImpfsetModifierConfig( + haz_type=haz_type, **(impfset or {}) + ), + hazard_modifier=HazardModifierConfig( + haz_type=haz_type, **(hazard_mod or {}) + ), + exposures_modifier=ExposuresModifierConfig(**(exposures_mod or {})), + cost_income=CostIncomeConfig(**(cost_income or {})), ) - def test_enforce_copy_false_mutates_original(self, exposures, impfset, hazard): - """With enforce_copy=False the change function receives the original object.""" - received_ids = [] - - def record_id(exp, **kw): - received_ids.append(id(exp)) - return exp - - m = Measure("recorder", exposures_changes=record_id) - m.apply(exposures, impfset, hazard, enforce_copy=False) - - assert received_ids[0] == id(exposures) - - def test_base_triplet_available_as_kwargs(self, exposures, impfset, hazard): - """Each change function receives base_exposures/impfset/hazard as kwargs.""" - received = {} - - def capture(**kwargs): - def fn(obj, **kw): - received.update(kw) - return obj - - return fn - - m = Measure( - "capture", - exposures_changes=capture(), + return _make_config + + +def _aai(measure, triplet, **kwargs): + """Return the AAI of the triplet after applying `measure`.""" + return measure.calc_impact(*triplet, **kwargs).aai_agg + + +class TestBaseline: + def test_identity_measure_reproduces_analytical_impact(self, triplet): + impact = Measure("baseline").calc_impact(*triplet) + assert impact.aai_agg == pytest.approx(BASELINE_AAI) + np.testing.assert_allclose(impact.at_event, BASELINE_AT_EVENT) + + def test_default_config_is_identity(self, triplet, config_factory): + measure = Measure.from_config(config_factory()) + assert _aai(measure, triplet) == pytest.approx(BASELINE_AAI) + + +class TestConfigModifiers: + @pytest.mark.parametrize( + "modifiers, expected_aai", + [ + ({"hazard_mod": {"haz_int_mult": 0.5}}, 9.0), + ({"hazard_mod": {"haz_freq_mult": 2.0}}, 36.0), + ( + {"hazard_mod": {"haz_freq_add": -0.005}}, + 0.5, + ), # Only event 3 with 0.001 freq + ({"hazard_mod": {"haz_freq_add": -0.01}}, 0.0), # clipped at zero + ({"hazard_mod": {"impact_rp_cutoff": 200}}, 15.0), # event 3 removed + ({"impfset": {"impf_mdd_mult": 0.5}}, 9.0), + ({"impfset": {"impf_paa_mult": 0.5}}, 9.0), + ({"impfset": {"impf_int_mult": 0.5}}, 9.0), # inverse intensity axis + ({"exposures_mod": {"set_to_zero": [1]}}, 14.0), + ( + { + "hazard_mod": {"haz_int_mult": 0.5}, + "impfset": {"impf_mdd_mult": 0.5}, + }, + 4.5, + ), + ], + ids=[ + "haz_int_mult", + "haz_freq_mult", + "haz_freq_add", + "haz_freq_add_clipped", + "rp_cutoff", + "mdd_mult", + "paa_mult", + "impf_int_mult", + "set_to_zero", + "haz_and_mdd", + ], + ) + def test_aai_after_modifier(self, triplet, config_factory, modifiers, expected_aai): + measure = Measure.from_config(config_factory(**modifiers)) + assert _aai(measure, triplet) == pytest.approx(expected_aai) + + def test_rp_cutoff_keeps_event_below_threshold(self, triplet, config_factory): + measure = Measure.from_config( + config_factory(hazard_mod={"impact_rp_cutoff": 200}) ) - m.apply(exposures, impfset, hazard, enforce_copy=False) - - assert "base_exposures" in received - assert "base_impfset" in received - assert "base_hazard" in received - + impact = measure.calc_impact(*triplet) + np.testing.assert_allclose(impact.at_event, [0.0, 0.0, 0.0, 3750.0, 15000.0]) -# =========================================================================== -# Exposures transformations -# =========================================================================== - - -class TestExposuresTransformations: - """Measures that modify Exposures.""" - - def test_scale_values(self, exposures, impfset, hazard): - """Scaling exposure values by 0.5 should halve every entry.""" - - def scale_half(exp, **kw): - exp.gdf["value"] *= 0.5 - return exp + def test_rp_cutoff_keeps_event_below_threshold_edge(self, triplet, config_factory): + measure = Measure.from_config( + config_factory(hazard_mod={"impact_rp_cutoff": 250}) + ) + impact = measure.calc_impact(*triplet) + np.testing.assert_allclose(impact.at_event, [0.0, 0.0, 0.0, 0.0, 15000.0]) - m = Measure("scale_half", exposures_changes=scale_half) - new_exp, _, _ = m.apply(exposures, impfset, hazard) - np.testing.assert_allclose( - new_exp.gdf["value"].values, exposures.gdf["value"].values * 0.5 +class TestImpfIds: + @pytest.mark.parametrize("impf_ids", [None, "all", 1, [1]]) + def test_targeted_function_is_modified(self, triplet, config_factory, impf_ids): + measure = Measure.from_config( + config_factory(impfset={"impf_mdd_mult": 0.5, "impf_ids": impf_ids}) ) + assert _aai(measure, triplet) == pytest.approx(9.0) - def test_zero_all_values(self, exposures, impfset, hazard): - """Zeroing all exposures should produce zero impacts downstream.""" - - def zero_exp(exp, **kw): - exp.gdf["value"] = 0.0 - return exp + def test_other_function_leaves_impact_unchanged(self, triplet, config_factory): + measure = Measure.from_config( + config_factory(impfset={"impf_mdd_mult": 0.5, "impf_ids": [2]}) + ) + assert _aai(measure, triplet) == pytest.approx(BASELINE_AAI) - m = Measure("zero_exp", exposures_changes=zero_exp) - new_exp, _, _ = m.apply(exposures, impfset, hazard) + def test_invalid_type_raises_value_error(self, triplet, config_factory): + measure = Measure.from_config(config_factory(impfset={"impf_ids": 2.5})) + with pytest.raises(ValueError, match="invalid"): + measure.apply_impfset_changes(triplet[1]) - assert new_exp.gdf["value"].sum() == 0.0 - def test_exposures_change_uses_base_hazard_kwarg( - self, exposures, impfset, hazard_factory +class TestReplacementFromDisk: + def test_hazard_replacement( + self, triplet, config_factory, hazard_factory, tmp_path ): - """Change function can inspect base_hazard via the default kwargs.""" - captured_haz = {} + path = tmp_path / "hazard.hdf5" + hazard_factory(intensity_scale=0.5).write_hdf5(str(path)) - def fn(exp, base_hazard=None, **kw): - captured_haz["haz"] = base_hazard - return exp - - haz = hazard_factory() - m = Measure("haz_aware", exposures_changes=fn) - m.apply(exposures, impfset, haz, enforce_copy=False) - - assert captured_haz["haz"] is haz - - -# =========================================================================== -# Hazard transformations -# =========================================================================== - - -class TestHazardTransformations: - """Measures that modify the Hazard.""" + measure = Measure.from_config( + config_factory(hazard_mod={"new_hazard_path": str(path)}) + ) + assert _aai(measure, triplet) == pytest.approx(9.0) - def test_zero_intensity_eliminates_impacts( - self, exposures, impfset, hazard_factory + def test_hazard_replacement_then_modifier( + self, triplet, config_factory, hazard_factory, tmp_path ): - """Setting hazard intensity to zero should yield zero impacts.""" + path = tmp_path / "hazard.hdf5" + hazard_factory(intensity_scale=0.5).write_hdf5(str(path)) - def zero_intensity(haz, **kw): - haz.intensity = csr_matrix(haz.intensity.shape) - return haz + with pytest.warns(UserWarning, match="Both new hazard"): + config = config_factory( + hazard_mod={"new_hazard_path": str(path), "haz_int_mult": 0.5} + ) + assert _aai(Measure.from_config(config), triplet) == pytest.approx(4.5) - haz = hazard_factory() - m = Measure("zero_haz", hazard_changes=zero_intensity) - new_exp, new_impfset, new_haz = m.apply(exposures, impfset, haz) - - assert new_haz.intensity.nnz == 0 - - def test_scale_intensity(self, exposures, impfset, hazard_factory): - """Halving hazard intensity should halve non-zero entries.""" - original_data = hazard_factory().intensity.data.copy() + def test_impfset_replacement( + self, triplet, config_factory, impfset_factory, tmp_path + ): + path = tmp_path / "impfset.xlsx" + impfset_factory(paa_scale=0.5).write_excel(str(path)) - def half_intensity(haz, **kw): - haz.intensity = haz.intensity * 0.5 - return haz + measure = Measure.from_config( + config_factory(impfset={"new_impfset_path": str(path)}) + ) + assert _aai(measure, triplet) == pytest.approx(9.0) - m = Measure("half_haz", hazard_changes=half_intensity) - _, _, new_haz = m.apply(exposures, impfset, hazard_factory()) + def test_impfset_replacement_then_modifier( + self, triplet, config_factory, impfset_factory, tmp_path + ): + path = tmp_path / "impfset.xlsx" + impfset_factory(paa_scale=0.5).write_excel(str(path)) - np.testing.assert_allclose(new_haz.intensity.data, original_data * 0.5) + with pytest.warns(UserWarning, match="Both new impfset"): + config = config_factory( + impfset={"new_impfset_path": str(path), "impf_mdd_mult": 0.5} + ) + assert _aai(Measure.from_config(config), triplet) == pytest.approx(4.5) + def test_exposures_replacement( + self, triplet, config_factory, exposures_factory, tmp_path + ): + path = tmp_path / "exposures.hdf5" + exposures_factory(categories=CATEGORIES, value_factor=0.5).write_hdf5(str(path)) -# =========================================================================== -# ImpactFuncSet transformations -# =========================================================================== + measure = Measure.from_config( + config_factory(exposures_mod={"new_exposures_path": str(path)}) + ) + assert _aai(measure, triplet) == pytest.approx(9.0) + + @pytest.mark.parametrize( + "override", + [ + {"hazard_mod": {"new_hazard_path": "nil"}}, + {"impfset": {"new_impfset_path": "nil"}}, + {"exposures_mod": {"new_exposures_path": "nil"}}, + ], + ids=["hazard", "impfset", "exposures"], + ) + def test_nil_path_means_no_replacement(self, triplet, config_factory, override): + measure = Measure.from_config(config_factory(**override)) + assert _aai(measure, triplet) == pytest.approx(BASELINE_AAI) + + +class TestCopySemantics: + @pytest.fixture + def measure(self, config_factory): + return Measure.from_config( + config_factory( + hazard_mod={"haz_int_mult": 0.5}, + impfset={"impf_mdd_mult": 0.5}, + exposures_mod={"set_to_zero": [1]}, + ) + ) + def test_enforce_copy_preserves_inputs(self, measure, triplet): + exposures, impfset, hazard = triplet + intensity = hazard.intensity.toarray().copy() + values = exposures.gdf["value"].to_numpy().copy() -class TestImpfsetTransformations: - """Measures that modify the ImpactFuncSet.""" + measure.apply(exposures, impfset, hazard, enforce_copy=True) - def test_scale_paa_to_zero_eliminates_impacts( - self, exposures, impfset_factory, hazard - ): - """Setting PAA to 0 should make all MDD-based impacts vanish.""" + np.testing.assert_array_equal(hazard.intensity.toarray(), intensity) + np.testing.assert_array_equal(exposures.gdf["value"].to_numpy(), values) + assert _aai(Measure("baseline"), triplet) == pytest.approx(BASELINE_AAI) - def zero_paa(ifs, **kw): - for func in ifs.get_func(haz_type=HAZARD_TYPE): - func.paa = np.zeros_like(func.paa) - return ifs + def test_no_copy_modifies_inputs_in_place(self, measure, triplet): + exposures, impfset, hazard = triplet + intensity = hazard.intensity.toarray().copy() - impfset = impfset_factory() - m = Measure("zero_paa", impfset_changes=zero_paa) - _, new_impfset, _ = m.apply(exposures, impfset, hazard) + measure.apply(exposures, impfset, hazard, enforce_copy=False) - for func in new_impfset.get_func(haz_type=HAZARD_TYPE): - np.testing.assert_array_equal(func.paa, 0) + np.testing.assert_allclose(hazard.intensity.toarray(), 0.5 * intensity) + assert exposures.gdf["value"].iloc[1] == 0 -# =========================================================================== -# Combined / multi-component transformations -# =========================================================================== +class TestCalcImpact: + def test_matches_manual_apply_and_impact_calc(self, triplet, config_factory): + measure = Measure.from_config( + config_factory( + hazard_mod={"haz_int_mult": 0.5}, + impfset={"impf_paa_mult": 0.5}, + ) + ) + new_exp, new_impfset, new_haz = measure.apply(*triplet) + manual = ImpactCalc(new_exp, new_impfset, new_haz).impact(save_mat=False) + impact = measure.calc_impact(*triplet) -class TestCombinedTransformations: - """Measures that simultaneously affect more than one risk component.""" + assert impact.aai_agg == pytest.approx(manual.aai_agg) + np.testing.assert_allclose(impact.at_event, manual.at_event) - def test_combined_exposure_and_hazard_change( - self, exposures, impfset, hazard_factory + def test_warns_and_assigns_missing_centroids( + self, exposures_factory, impfset, hazard ): - """Both exposure and hazard transforms should be applied independently.""" + exposures = exposures_factory(categories=CATEGORIES) + with patch(LOGGER) as mock_logger: + impact = Measure("baseline").calc_impact(exposures, impfset, hazard) - def double_values(exp, **kw): - exp.gdf["value"] *= 2 - return exp + mock_logger.warning.assert_called_once() + assert hazard.centr_exp_col in exposures.gdf.columns + assert impact.aai_agg == pytest.approx(BASELINE_AAI) - def half_intensity(haz, **kw): - haz.intensity = haz.intensity * 0.5 - return haz + def test_no_warning_when_centroids_assigned(self, triplet): + with patch(LOGGER) as mock_logger: + Measure("baseline").calc_impact(*triplet) - haz = hazard_factory() - original_intensity_data = haz.intensity.data.copy() + mock_logger.warning.assert_not_called() - m = Measure( - "combined", - exposures_changes=double_values, - hazard_changes=half_intensity, - ) - new_exp, _, new_haz = m.apply(exposures, impfset, haz) - np.testing.assert_allclose( - new_exp.gdf["value"].values, exposures.gdf["value"].values * 2 - ) - np.testing.assert_allclose( - new_haz.intensity.data, original_intensity_data * 0.5 +class TestCustomFunction: + def test_custom_measure(self, measure_on_exposures, triplet): + impact = measure_on_exposures.calc_impact( + *triplet, kwargs_exposures={"reduction_factor": 0.1} ) + assert impact.aai_agg == pytest.approx(BASELINE_AAI * 0.9) - def test_kwargs_exposures_and_kwargs_hazard_routed_correctly( - self, exposures, impfset, hazard - ): - """kwargs_exposures and kwargs_hazard should reach the right function only.""" - exp_extra, haz_extra = {}, {} - - def track_exp(exp, my_exp_param=None, **kw): - exp_extra["val"] = my_exp_param - return exp - - def track_haz(haz, my_haz_param=None, **kw): - haz_extra["val"] = my_haz_param - return haz - - m = Measure("routed", exposures_changes=track_exp, hazard_changes=track_haz) - m.apply( - exposures, - impfset, - hazard, - enforce_copy=False, - kwargs_exposures={"my_exp_param": "exp_value"}, - kwargs_hazard={"my_haz_param": "haz_value"}, - ) + def test_custom_measure_missing_kwarg(self, measure_on_exposures, triplet): + with pytest.raises(TypeError, match="kwargs_exposures"): + measure_on_exposures.calc_impact(*triplet) - assert exp_extra["val"] == "exp_value" - assert haz_extra["val"] == "haz_value" + def test_wrong_custom_measure(self, triplet): + def wrong_change(arg1): + return "wrong return type" + meas = Measure(name="", exposures_changes=wrong_change) + with pytest.raises(TypeError, match="did not return an Exposures"): + meas.apply(*triplet) -# =========================================================================== -# CostIncome integration -# =========================================================================== + meas = Measure(name="", hazard_changes=wrong_change) + with pytest.raises(TypeError, match="did not return an Hazard"): + meas.apply(*triplet) - -class TestCostIncomeIntegration: - """Verify that CostIncome financial data survives through Measure construction.""" - - def test_cost_income_attached(self, exposures, impfset, hazard): - ci = CostIncome(mkt_price_year=2020, init_cost=10_000, periodic_income=500) - m = Measure("with_ci", cost_income=ci) - assert m.cost_income is ci - - def test_total_cost_calculable_after_apply(self, exposures, impfset, hazard): - ci = CostIncome( - mkt_price_year=2020, - init_cost=10_000, - periodic_cost=1_000, - periodic_income=2_000, - ) - m = Measure("ci_measure", cost_income=ci) - m.apply(exposures, impfset, hazard) - - total_net, total_cost, total_inc = m.cost_income.calc_total( - impl_date="2020-01-01", - start_date="2020-01-01", - end_date="2030-01-01", - ) - # Over 10 years: init_cost once + 9 periodic_costs + 10 periodic_incomes - assert total_cost < 0 - assert total_inc > 0 - assert isinstance(total_net, float) + meas = Measure(name="", impfset_changes=wrong_change) + with pytest.raises(TypeError, match="did not return an ImpactFuncSet"): + meas.apply(*triplet) diff --git a/doc/user-guide/climada_cost_income.ipynb b/doc/user-guide/climada_cost_income.ipynb index fd53c2585..381cfe94e 100644 --- a/doc/user-guide/climada_cost_income.ipynb +++ b/doc/user-guide/climada_cost_income.ipynb @@ -181,7 +181,7 @@ } ], "source": [ - "from climada.entity.measures.cost_income import CostIncome\n", + "from climada.entity.measures import CostIncome\n", "\n", "ci = CostIncome(\n", " mkt_price_year=2025,\n", @@ -237,8 +237,6 @@ "metadata": {}, "outputs": [], "source": [ - "from climada.entity.measures.cost_income import CostIncome\n", - "\n", "ci = CostIncome(\n", " mkt_price_year=2025,\n", " init_cost=10_000, # 10 000 upfront\n", @@ -1144,8 +1142,6 @@ } ], "source": [ - "import pandas as pd\n", - "\n", "custom_flows_a = pd.DataFrame(\n", " {\n", " \"date\": [\"2024\", \"2026\", \"2028\"],\n", diff --git a/doc/user-guide/climada_measure_config.ipynb b/doc/user-guide/climada_measure_config.ipynb index ebbeb6df7..cadca28fe 100644 --- a/doc/user-guide/climada_measure_config.ipynb +++ b/doc/user-guide/climada_measure_config.ipynb @@ -136,7 +136,7 @@ } ], "source": [ - "from climada.entity.measures.measure_config import MeasureConfig\n", + "from climada.entity.measures import MeasureConfig\n", "\n", "measure_dict = {\n", " \"name\": \"Tutorial measure\",\n", diff --git a/doc/user-guide/climada_measures.ipynb b/doc/user-guide/climada_measures.ipynb index 735fcdf9c..7de605531 100644 --- a/doc/user-guide/climada_measures.ipynb +++ b/doc/user-guide/climada_measures.ipynb @@ -1,674 +1,681 @@ { "cells": [ - { - "cell_type": "markdown", - "id": "adc83a35", - "metadata": {}, - "source": [ - "(measure-tutorial)=\n", - "\n", - "# Defining Adaptation Measures with python functions" - ] - }, - { - "cell_type": "markdown", - "id": "3dca8f82", - "metadata": {}, - "source": [ - "The `Measure` object is the core component of the **Adaptation** module in CLIMADA. It acts as a wrapper for transformation logic, allowing you to model how specific interventions alter the risk landscape (like building sea walls, retrofitting houses, or implementing early warning systems).\n", - "\n", - "The primary purpose of a `Measure` is to modify one or more parts of the \"Risk Triplet\": **Exposures**, **Impact Functions**, and **Hazard**.\n", - "\n", - "`Measure` objects can be regrouped into a set with the `MeasureSet` object." - ] - }, - { - "cell_type": "markdown", - "id": "f51842c7", - "metadata": {}, - "source": [ - "## Quickstart" - ] - }, - { - "cell_type": "markdown", - "id": "d369e8bd-1f00-4435-9b35-ef03bba2af97", - "metadata": {}, - "source": [ - "Assume you want to model two measures as follow:\n", - "- The first one is a targeted awareness campaign, which effect is modelled as a change of one specific impact function. Costing 500'000.\n", - "- The second one is both building a sea wall which effect is modelled as a 0.2 meter reduction of the flood height everywhere, and a building retrofit, modelled as an overal reduction of the value at risk of the exposure. Costing 1M." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "2c48b021", - "metadata": {}, - "outputs": [], - "source": [ - "from climada.entity.measures.measure import Measure\n", - "from climada.entity.measures.measure_set import MeasureSet\n", - "from climada.entity.measures.cost_income import CostIncome\n", - "\n", - "\n", - "# Define the effect with python functions\n", - "def targeted_campaign(impact_func_set, impf_id, rescale):\n", - " \"\"\"Rescales the impact function with id impf_id\"\"\"\n", - " impact_func_set.get_func(fun_id=imp_id).mdd *= rescale\n", - " return impact_func_set\n", - "\n", - "\n", - "def building_retrofit(exposures, reduction_factor):\n", - " \"\"\"Reduces the value of all assets by a fixed percentage.\"\"\"\n", - " exposures.gdf[\"value\"] *= 1 - reduction_factor\n", - " return exposures\n", - "\n", - "\n", - "def sea_wall_protection(hazard, reduction):\n", - " \"\"\"Reduces flood intensity by an absolute amount.\"\"\"\n", - " hazard.intensity.data -= reduction\n", - " return hazard\n", - "\n", - "\n", - "# Initialize the Measure objects\n", - "campaign = Measure(\n", - " name=\"Targeted awareness campaign\",\n", - " impfset_changes=targeted_campaign,\n", - " cost_income=CostIncome(init_cost=500_000),\n", - ")\n", - "\n", - "retrofitting_and_seawall = Measure(\n", - " name=\"Building Retrofit and seawall construction\",\n", - " exposures_changes=building_retrofit,\n", - " hazard_changes=sea_wall_protection,\n", - " cost_income=CostIncome(init_cost=1_000_000),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "02cd2812", - "metadata": {}, - "source": [ - "The measure can then be used either to create a new triplet corresponding to the situation with measure in effect\n", - "or to compute the impact directly (both lines are independent):\n", - "\n", - "```python\n", - "new_exp, new_impf, new_haz = retrofitting_and_seawall.apply(exp, impf, haz, kwargs_exposure={'reduction_factor': 0.1}, kwargs_hazard={'reduction': 0.3})\n", - "\n", - "# or directly:\n", - "impact_with_campaign_active = campaign.calc_impact(exp, impf, haz, kwargs_impfset={'rescale': 0.2})\n", - "```\n", - "\n", - "Much like impact functions, the measure can also be regrouped into a set with `MeasureSet`:\n", - "\n", - "```python\n", - "\n", - "meas_set = MeasureSet([campaign, retrofitting_and_seawall])\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "6f4650c6", - "metadata": {}, - "source": [ - "## Core logic of the Measure object\n", - "\n", - "When you initialize a `Measure`, you can provide one to three callables (python functions):\n", - "\n", - "1. **`exposures_changes`**: Modifies the `Exposures` object, the asset location, value, or metadata (e.g., relocating houses from a flood zone).\n", - "2. **`impfset_changes`**: Modifies the `ImpactFuncSet` object, the vulnerability of assets (e.g., updating an impact function because houses were elevated).\n", - "3. **`hazard_changes`**: Modifies the `Hazard` object, the intensity or frequency of events (e.g., a dam reducing flood depths).\n", - "\n", - "```{note}\n", - "By default, changes are the `identity_function`, so if you do not provide a function e.g., \"exposures_changes\" the measure once applied will return the exposure unchanged.\n", - "```\n", - "\n", - "The **only** requirement for these python functions is to **take** and **return** an `Exposures` object (or `ImpactFuncSet` or `Hazard` objects respectively). This enables high modeling flexibility where changes from an adaptation option can be modelled from a simple \"loading another file for the hazard or exposure\", to an advanced function.\n", - "\n", - "One thing to note, is that the `apply()` method of a measure always passes the entire triplet (`base_exposures`, `base_impfset`, `base_hazard`, i.e., the risk triplet before the measure is applied) to every transformation function. This means you can easily write complex logic that relie on any of these component. For example:\n", - "\n", - "> \"Only reduce the exposure value (Exposure change) in areas where the peak water depth (Hazard context) exceeds 2 meters.\"\n", - "\n", - "Could be defined like this:" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "7cd53344", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "\n", - "\n", - "def reduce_exposure_by_depth(exposures, reduction_factor=0.2, threshold=2.0, **kwargs):\n", - " \"\"\"\n", - " Reduces asset value by a percentage, but only for assets located where\n", - " the peak hazard intensity (e.g., water depth) exceeds the threshold.\n", - " \"\"\"\n", - " # Retrieve the hazard context passed by Measure.apply\n", - " base_hazard = kwargs.get(\"base_hazard\")\n", - "\n", - " # Get the peak intensity for each centroid across all events\n", - " peak_depths = np.array(base_hazard.intensity.max(axis=0).todense()).flatten()\n", - "\n", - " # Ensure exposures are mapped to hazard centroids\n", - " if \"centr_ID\" not in exposures.gdf.columns:\n", - " exposures.assign_centroids(base_hazard)\n", - "\n", - " # Identify which exposure points meet the depth criteria\n", - " # 'centr_ID' maps each exposure row to a column index in\n", - " # the hazard intensity matrix\n", - " centr_indices = exposures.gdf[\"centr_ID\"].values\n", - " point_peak_depths = peak_depths[centr_indices]\n", - "\n", - " # We apply the reduction only where Depth > threshold\n", - " mask = point_peak_depths > threshold\n", - " exposures.gdf.loc[mask, \"value\"] *= 1 - reduction_factor\n", - "\n", - " return exposures" - ] - }, - { - "cell_type": "markdown", - "id": "10a5ad67", - "metadata": {}, - "source": [ - "```python\n", - "# --- Usage Example ---\n", - "# Define the measure\n", - "flood_protection = Measure(\n", - " name=\"High-Threshold Value Protection\",\n", - " exposures_changes=reduce_exposure_by_depth\n", - ")\n", - "\n", - "# When you call apply, the `Hazard` haz is automatically passed to the function\n", - "# as 'base_hazard'\n", - "new_exp, _, _ = flood_protection.apply(\n", - " exp, impf, haz,\n", - " kwargs_exposures={'reduction_factor': 0.5, 'threshold': 2.0}\n", - ")\n", - "\n", - "# You can also overide this with another \"base\" hazard in the\n", - "# `kwarg_exposures` if you want.\n", - "#\n", - "# This could be for instance if the flood protection is\n", - "# now defined not for the full probabilistic hazard set\n", - "# but for a specific event, or a subset.\n", - "new_exp, _, _ = flood_protection.apply(\n", - " exp, impf, haz,\n", - " kwargs_exposures={'base_hazard'= other_hazard,\n", - " 'reduction_factor': 0.5, 'threshold': 2.0}\n", - ")\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "8baaf95c", - "metadata": {}, - "source": [ - "```{note}\n", - "The functions you define yourself are automatically made to accept keyword arguments if they do not already.\n", - "```\n", - "\n", - "```{note}\n", - "The functions do not necessarily need to use the Exposures|Hazard|ImpactFuncSet object, but they still have to accept it as an argument.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "a984d0a7", - "metadata": {}, - "source": [ - "### Deep copies\n", - "\n", - "To prevent accidental \"in-place\" modification of your baseline data, `Measure` utilizes `copy.deepcopy()`.\n", - "If `enforce_copy=True` in `apply()` (the default case), the object is cloned before the transformation function runs, ensuring the original objects remains untouched." - ] - }, - { - "cell_type": "markdown", - "id": "6d58543f", - "metadata": {}, - "source": [ - "### Measure cash flow (`CostIncome`)\n", - "\n", - "The `cost_income` attribute allows the measure to carry its own. It can notably store:\n", - "\n", - "* Initial investment costs.\n", - "* Maintenance costs over time.\n", - "* Potential indirect income generated by the measure.\n", - "\n", - "For more information and advanced usage see the [detailed tutorial on cost income object](cost-income-tutorial)." - ] - }, - { - "cell_type": "markdown", - "id": "b5c1873f", - "metadata": {}, - "source": [ - "## Defining measures using measure configuration\n", - "\n", - "Defining adaptation measures with Python functions can be difficult if you are not familiar with Python. For common changes, a `Measure` can instead be described *declaratively* with a `MeasureConfig` object. A configuration can also be written to, and read from, a YAML file (see the [detailed tutorial](measure-config-tutorial)).\n", - "\n", - "A `MeasureConfig` aggregates one configuration object per component of the measure:\n", - "\n", - "| Field | Class | Main parameters |\n", - "|---|---|---|\n", - "| `hazard_modifier` | `HazardModifierConfig` | `haz_int_mult`, `haz_int_add`, `impact_rp_cutoff`, `new_hazard_path` |\n", - "| `impfset_modifier` | `ImpfsetModifierConfig` | `impf_ids`, `impf_{mdd,paa,int}_{mult,add}`, `new_impfset_path` |\n", - "| `exposures_modifier` | `ExposuresModifierConfig` | `reassign_impf_id`, `new_exposures_path` |\n", - "| `cost_income` | `CostIncomeConfig` | same parameters as `CostIncome` |\n", - "\n", - "All parameters default to \"no change\" (multipliers of `1.0`, offsets of `0.0`, `None` otherwise). A `new_*_path` loads a replacement object from file; if modifiers are also given, they are applied after the replacement (and a warning is issued)." - ] - }, - { - "cell_type": "markdown", - "id": "4e7a2c91", - "metadata": {}, - "source": [ - "### Creating a measure from a configuration\n", - "\n", - "The measure below reduces the mean damage degree of impact function `1` by 25 %, and removes the events that are more frequent than a 1-in-20-year event (ranked by their **impact** in the baseline situation not their intensity).\n", - "`Measure.from_config` converts the configuration into the corresponding transformation functions." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "c3d90b57", - "metadata": {}, - "outputs": [], - "source": [ - "from climada.entity.measures.measure import Measure\n", - "from climada.entity.measures.measure_config import (\n", - " CostIncomeConfig,\n", - " ExposuresModifierConfig,\n", - " HazardModifierConfig,\n", - " ImpfsetModifierConfig,\n", - " MeasureConfig,\n", - ")\n", - "\n", - "config = MeasureConfig(\n", - " name=\"Flood proofing and protection standard\",\n", - " haz_type=\"FL\",\n", - " impfset_modifier=ImpfsetModifierConfig(\n", - " haz_type=\"FL\", impf_ids=1, impf_mdd_mult=0.75\n", - " ),\n", - " hazard_modifier=HazardModifierConfig(haz_type=\"FL\", impact_rp_cutoff=20),\n", - " exposures_modifier=ExposuresModifierConfig(), # no change, note that you can also just leave it out entirely\n", - " cost_income=CostIncomeConfig(init_cost=1_000_000, periodic_cost=50_000),\n", - " color_rgb=(0.1, 0.4, 0.8),\n", - ")\n", - "\n", - "measure = Measure.from_config(config)" - ] - }, - { - "cell_type": "markdown", - "id": "a81f6d02", - "metadata": {}, - "source": [ - "The resulting `Measure` is used like any other. The `base_*` triplet needed by `impact_rp_cutoff` is provided automatically by `Measure.apply`.\n", - "\n", - "```python\n", - "imp_with_measure = measure.calc_impact(exp, impfset, haz)\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "824e1dd3", - "metadata": {}, - "source": [ - "## Advanced examples of measures" - ] - }, - { - "cell_type": "markdown", - "id": "f1a15910-6de8-416f-982f-dd0e53fc13cf", - "metadata": {}, - "source": [ - "Measures are often modelled as a fixed change. In practice, the effect of an adaptation action often depends on **when** it is evaluated (for instance, a relocation programme that is phased in over several years) or on **additional parameters** (regions targeted, share of value removed, protection threshold).\n", - "\n", - "The examples below illustrate three patterns:\n", - "\n", - "1. **Exposures**: a linear phase-in of asset relocation (`year`, `start_year`, `full_year`, `fraction`, `regions`).\n", - "2. **Impact functions**: a year-dependent reduction of the mean damage ratio.\n", - "3. **Hazard**: a step-function protection standard that needs the baseline impacts, and therefore the `base_*` triplet." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "7e17dfb9-b760-48df-8878-be1d547290a3", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "\n", - "\n", - "def phase_in(year, start_year, full_year):\n", - " \"\"\"Linear effectiveness ramp. (Helper function)\n", - "\n", - " Parameters\n", - " ----------\n", - " year : int\n", - " Year at which the measure is evaluated.\n", - " start_year : int\n", - " Year at which the measure starts having an effect.\n", - " full_year : int\n", - " Year at which the measure is fully effective.\n", - "\n", - " Returns\n", - " -------\n", - " float\n", - " Effectiveness in [0, 1].\n", - " \"\"\"\n", - " return float(np.clip((year - start_year) / (full_year - start_year), 0, 1))\n", - "\n", - "\n", - "def relocate_assets(exposures, *, year, start_year, full_year, fraction, regions):\n", - " \"\"\"Progressively remove asset value from exposed regions.\n", - "\n", - " Parameters\n", - " ----------\n", - " exposures : climada.entity.exposures.base.Exposures\n", - " Exposures to modify (already a copy if ``enforce_copy=True``).\n", - " year : int\n", - " Evaluation year.\n", - " start_year, full_year : int\n", - " Start and end of the linear phase-in.\n", - " fraction : float\n", - " Share of value removed once the measure is fully implemented.\n", - " regions : list of int\n", - " ``region_id`` values targeted by the measure.\n", - "\n", - " Returns\n", - " -------\n", - " climada.entity.exposures.base.Exposures\n", - " Exposures with reduced value in the targeted regions.\n", - " \"\"\"\n", - " ramp = phase_in(year, start_year, full_year)\n", - " mask = exposures.gdf[\"region_id\"].isin(regions)\n", - " exposures.gdf.loc[mask, \"value\"] *= 1 - fraction * ramp\n", - " return exposures\n", - "\n", - "\n", - "def flood_proofing(\n", - " impfset, *, year, start_year, full_year, reduction, haz_type=\"FL\", impf_id=1\n", - "):\n", - " \"\"\"Scale down the mean damage ratio of one impact function.\n", - "\n", - " Parameters\n", - " ----------\n", - " impfset : climada.entity.impact_funcs.impact_func_set.ImpactFuncSet\n", - " Impact function set to modify.\n", - " year : int\n", - " Evaluation year.\n", - " start_year, full_year : int\n", - " Start and end of the linear phase-in.\n", - " reduction : float\n", - " Relative MDD reduction once fully effective.\n", - " haz_type : str, optional\n", - " Hazard type of the targeted function.\n", - " impf_id : int, optional\n", - " Id of the targeted function.\n", - "\n", - " Returns\n", - " -------\n", - " climada.entity.impact_funcs.impact_func_set.ImpactFuncSet\n", - " Impact function set with the modified function.\n", - " \"\"\"\n", - " ramp = phase_in(year, start_year, full_year)\n", - " impf = impfset.get_func(haz_type=haz_type, fun_id=impf_id)\n", - " impf.mdd = impf.mdd * (1 - reduction * ramp)\n", - " return impfset\n", - "\n", - "\n", - "def protection_standard(\n", - " hazard, *, year, start_year, threshold, base_exposures, base_impfset, base_hazard\n", - "):\n", - " \"\"\"Remove events whose baseline impact is below a protection threshold.\n", - "\n", - " Parameters\n", - " ----------\n", - " hazard : climada.hazard.base.Hazard\n", - " Hazard to modify.\n", - " year : int\n", - " Evaluation year.\n", - " start_year : int\n", - " Year the protection is in place (no effect before).\n", - " threshold : float\n", - " Events with baseline total impact below this value are removed.\n", - " base_exposures, base_impfset, base_hazard\n", - " Unmodified triplet, provided by ``Measure.apply``.\n", - "\n", - " Returns\n", - " -------\n", - " climada.hazard.base.Hazard\n", - " Hazard without the protected events. Frequencies of the remaining\n", - " events are left unchanged.\n", - " \"\"\"\n", - " from climada.engine.impact_calc import ImpactCalc\n", - "\n", - " if year < start_year:\n", - " return hazard\n", - " imp = ImpactCalc(base_exposures, base_impfset, base_hazard).impact(\n", - " assign_centroids=False\n", - " )\n", - " return hazard.select(event_id=imp.event_id[imp.at_event >= threshold])" - ] - }, - { - "cell_type": "markdown", - "id": "937ded7c-05be-4708-ad08-8d366e287406", - "metadata": {}, - "source": [ - "The parameters can then be provided when the measure is applied, or the impact is computed:" - ] - }, { "cell_type": "code", "execution_count": null, - "id": "2bcbb4ec-701d-4e38-9321-e86a7dea9538", - "metadata": {}, - "outputs": [], - "source": [ - "from climada.entity.measures.measure import Measure\n", - "\n", - "meas_relocation = Measure(\n", - " \"relocation\",\n", - " exposures_changes=relocate_assets,\n", - ")\n", - "\n", - "meas_relocation_and_standard = Measure(\n", - " \"relocation_and_standard\",\n", - " exposures_changes=relocate_assets,\n", - " hazard_changes=protection_standard,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "4e2ea806-8bca-4681-8543-750b9b303a59", - "metadata": {}, - "source": [ - "```python\n", - "exp_relocated, _, _ = meas_relocation.apply(\n", - " exp,\n", - " impfset,\n", - " haz,\n", - " kwargs_exposures=dict(\n", - " year=2030, start_year=2025, full_year=2045, fraction=0.3, regions=[1, 2]\n", - " ),\n", - ")\n", - "\n", - "imp_with_relocation_and_standards = meas_relocation_and_standard.calc_impact(\n", - " exp,\n", - " impfset,\n", - " haz,\n", - " kwargs_exposures=dict(\n", - " year=2030, start_year=2025, full_year=2045, fraction=0.3, regions=[1, 2]\n", - " ),\n", - " kwargs_hazard=dict(year=2030, start_year=2025, full_year=2045, threshold=100000),\n", - ")\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "fabfd29c-0b5e-442a-912f-3cbfac45f78d", - "metadata": {}, - "source": [ - "## Using `MeasureSet` objects\n", - "\n", - "`MeasureSet` are used to group a set of measure together, notably to compare them in a cost benefit analysis.\n", - "\n", - "`MeasureSet` are build from a list of measures, taking the example from the quickstart:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "04e747b4-f856-43c3-ba43-a1878c112afe", + "id": "da2aeb5c", "metadata": {}, "outputs": [], "source": [ - "from climada.entity.measures.measure import Measure\n", - "from climada.entity.measures.measure_set import MeasureSet\n", - "from climada.entity.measures.cost_income import CostIncome\n", - "\n", - "\n", - "# Define the effect with python functions\n", - "def targeted_campaign(impact_func_set, impf_id, rescale):\n", - " \"\"\"Rescales the impact function with id impf_id\"\"\"\n", - " impact_func_set.get_func(fun_id=imp_id).mdd *= rescale\n", - " return impact_func_set\n", - "\n", - "\n", - "def building_retrofit(exposures, reduction_factor):\n", - " \"\"\"Reduces the value of all assets by a fixed percentage.\"\"\"\n", - " exposures.gdf[\"value\"] *= 1 - reduction_factor\n", - " return exposures\n", - "\n", - "\n", - "def sea_wall_protection(hazard, reduction):\n", - " \"\"\"Reduces flood intensity by an absolute amount.\"\"\"\n", - " hazard.intensity.data -= reduction\n", - " return hazard\n", - "\n", - "\n", - "# Initialize the Measure objects\n", - "campaign = Measure(\n", - " name=\"Targeted awareness campaign\",\n", - " impfset_changes=targeted_campaign,\n", - " cost_income=CostIncome(init_cost=500_000),\n", - ")\n", - "\n", - "retrofitting_and_seawall = Measure(\n", - " name=\"Building Retrofit and seawall construction\",\n", - " exposures_changes=building_retrofit,\n", - " hazard_changes=sea_wall_protection,\n", - " cost_income=CostIncome(init_cost=1_000_000),\n", - ")\n", - "\n", - "meas_set = MeasureSet([campaign, retrofitting_and_seawall])" - ] - }, - { - "cell_type": "markdown", - "id": "024822ed-53fc-406e-a9f0-012b244113c8", - "metadata": {}, - "source": [ - "You can list the measures names of a set, get its size, check if it contains a measure (by name), `append()` an additional measure, `select()` them by their name." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "2d532d22-6b74-45a4-8b14-3e7b9ede2def", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "There are 2 measures in the set:\n", - " ['Targeted awareness campaign', 'Building Retrofit and seawall construction']\n", - "True\n", - "False\n" - ] - } - ], - "source": [ - "print(f\"There are {len(meas_set)} measures in the set:\\n\", meas_set.names)\n", - "print(\"Targeted awareness campaign\" in meas_set)\n", - "print(\"Mangroves\" in meas_set)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "cdfc53b8-5da4-4b72-8fa5-7a7f5e18a6ea", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'Targeted awareness campaign': }" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "meas_set.select(\"Targeted awareness campaign\")" - ] - }, - { - "cell_type": "markdown", - "id": "8a29d4b4-983f-4fbd-894b-01716c899dfc", - "metadata": {}, - "source": [ - "You can also combine or compose measures of a `MeasureSet` into one `Measure`.\n", - "\n", - "1. *Combining* (`.combine()`) merges the effects of all the measures, taking the best possible effect when multiple measures affect the same dimension.\n", - "\n", - "For instance:\n", - "- When several measures of the set reduce the intensity of the hazard, the combined effect will be the point-wise highest reduction across all measures.\n", - "- When several measures modify the same impact function, the combined measure uses the point-wise minimum of all the modified impact functions.\n", - "\n", - "2. *Composing* (`.compose()`) does the mathematical composition of the measures, i.e., for a list of measures `[m1, m2, m3]`, the resulting transformation is `m1(m2(m3(x)))`. Note that this means measure `m3` is applied first, and the order of list of measures given to the `MeasureSet` matters!" + "{\n", + " \"cells\": [\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"adc83a35\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"(measure-tutorial)=\\n\",\n", + " \"\\n\",\n", + " \"# Defining Adaptation Measures with python functions\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"3dca8f82\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"The `Measure` object is the core component of the **Adaptation** module in CLIMADA. It acts as a wrapper for transformation logic, allowing you to model how specific interventions alter the risk landscape (like building sea walls, retrofitting houses, or implementing early warning systems).\\n\",\n", + " \"\\n\",\n", + " 'The primary purpose of a `Measure` is to modify one or more parts of the \"Risk Triplet\": **Exposures**, **Impact Functions**, and **Hazard**.\\n',\n", + " \"\\n\",\n", + " \"`Measure` objects can be regrouped into a set with the `MeasureSet` object.\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"f51842c7\",\n", + " \"metadata\": {},\n", + " \"source\": [\"## Quickstart\"],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"d369e8bd-1f00-4435-9b35-ef03bba2af97\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"Assume you want to model two measures as follow:\\n\",\n", + " \"- The first one is a targeted awareness campaign, which effect is modelled as a change of one specific impact function. Costing 500'000.\\n\",\n", + " \"- The second one is both building a sea wall which effect is modelled as a 0.2 meter reduction of the flood height everywhere, and a building retrofit, modelled as an overal reduction of the value at risk of the exposure. Costing 1M.\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"execution_count\": 1,\n", + " \"id\": \"2c48b021\",\n", + " \"metadata\": {},\n", + " \"outputs\": [],\n", + " \"source\": [\n", + " \"from climada.entity.measures import Measure, CostIncome, MeasureSet\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"# Define the effect with python functions\\n\",\n", + " \"def targeted_campaign(impact_func_set, impf_id, rescale):\\n\",\n", + " ' \"\"\"Rescales the impact function with id impf_id\"\"\"\\n',\n", + " \" impact_func_set.get_func(fun_id=imp_id).mdd *= rescale\\n\",\n", + " \" return impact_func_set\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"def building_retrofit(exposures, reduction_factor):\\n\",\n", + " ' \"\"\"Reduces the value of all assets by a fixed percentage.\"\"\"\\n',\n", + " ' exposures.gdf[\"value\"] *= 1 - reduction_factor\\n',\n", + " \" return exposures\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"def sea_wall_protection(hazard, reduction):\\n\",\n", + " ' \"\"\"Reduces flood intensity by an absolute amount.\"\"\"\\n',\n", + " \" hazard.intensity.data -= reduction\\n\",\n", + " \" return hazard\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"# Initialize the Measure objects\\n\",\n", + " \"campaign = Measure(\\n\",\n", + " ' name=\"Targeted awareness campaign\",\\n',\n", + " \" impfset_changes=targeted_campaign,\\n\",\n", + " \" cost_income=CostIncome(init_cost=500_000),\\n\",\n", + " \")\\n\",\n", + " \"\\n\",\n", + " \"retrofitting_and_seawall = Measure(\\n\",\n", + " ' name=\"Building Retrofit and seawall construction\",\\n',\n", + " \" exposures_changes=building_retrofit,\\n\",\n", + " \" hazard_changes=sea_wall_protection,\\n\",\n", + " \" cost_income=CostIncome(init_cost=1_000_000),\\n\",\n", + " \")\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"02cd2812\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"The measure can then be used either to create a new triplet corresponding to the situation with measure in effect\\n\",\n", + " \"or to compute the impact directly (both lines are independent):\\n\",\n", + " \"\\n\",\n", + " \"```python\\n\",\n", + " \"new_exp, new_impf, new_haz = retrofitting_and_seawall.apply(exp, impf, haz, kwargs_exposure={'reduction_factor': 0.1}, kwargs_hazard={'reduction': 0.3})\\n\",\n", + " \"\\n\",\n", + " \"# or directly:\\n\",\n", + " \"impact_with_campaign_active = campaign.calc_impact(exp, impf, haz, kwargs_impfset={'rescale': 0.2})\\n\",\n", + " \"```\\n\",\n", + " \"\\n\",\n", + " \"Much like impact functions, the measure can also be regrouped into a set with `MeasureSet`:\\n\",\n", + " \"\\n\",\n", + " \"```python\\n\",\n", + " \"\\n\",\n", + " \"meas_set = MeasureSet([campaign, retrofitting_and_seawall])\\n\",\n", + " \"```\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"6f4650c6\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"## Core logic of the Measure object\\n\",\n", + " \"\\n\",\n", + " \"When you initialize a `Measure`, you can provide one to three callables (python functions):\\n\",\n", + " \"\\n\",\n", + " \"1. **`exposures_changes`**: Modifies the `Exposures` object, the asset location, value, or metadata (e.g., relocating houses from a flood zone).\\n\",\n", + " \"2. **`impfset_changes`**: Modifies the `ImpactFuncSet` object, the vulnerability of assets (e.g., updating an impact function because houses were elevated).\\n\",\n", + " \"3. **`hazard_changes`**: Modifies the `Hazard` object, the intensity or frequency of events (e.g., a dam reducing flood depths).\\n\",\n", + " \"\\n\",\n", + " \"```{note}\\n\",\n", + " 'By default, changes are the `identity_function`, so if you do not provide a function e.g., \"exposures_changes\" the measure once applied will return the exposure unchanged.\\n',\n", + " \"```\\n\",\n", + " \"\\n\",\n", + " 'The **only** requirement for these python functions is to **take** and **return** an `Exposures` object (or `ImpactFuncSet` or `Hazard` objects respectively). This enables high modeling flexibility where changes from an adaptation option can be modelled from a simple \"loading another file for the hazard or exposure\", to an advanced function.\\n',\n", + " \"\\n\",\n", + " \"One thing to note, is that the `apply()` method of a measure always passes the entire triplet (`base_exposures`, `base_impfset`, `base_hazard`, i.e., the risk triplet before the measure is applied) to every transformation function. This means you can easily write complex logic that relie on any of these component. For example:\\n\",\n", + " \"\\n\",\n", + " '> \"Only reduce the exposure value (Exposure change) in areas where the peak water depth (Hazard context) exceeds 2 meters.\"\\n',\n", + " \"\\n\",\n", + " \"Could be defined like this:\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"execution_count\": 2,\n", + " \"id\": \"7cd53344\",\n", + " \"metadata\": {},\n", + " \"outputs\": [],\n", + " \"source\": [\n", + " \"import numpy as np\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"def reduce_exposure_by_depth(exposures, reduction_factor=0.2, threshold=2.0, **kwargs):\\n\",\n", + " ' \"\"\"\\n',\n", + " \" Reduces asset value by a percentage, but only for assets located where\\n\",\n", + " \" the peak hazard intensity (e.g., water depth) exceeds the threshold.\\n\",\n", + " ' \"\"\"\\n',\n", + " \" # Retrieve the hazard context passed by Measure.apply\\n\",\n", + " ' base_hazard = kwargs.get(\"base_hazard\")\\n',\n", + " \"\\n\",\n", + " \" # Get the peak intensity for each centroid across all events\\n\",\n", + " \" peak_depths = np.array(base_hazard.intensity.max(axis=0).todense()).flatten()\\n\",\n", + " \"\\n\",\n", + " \" # Ensure exposures are mapped to hazard centroids\\n\",\n", + " ' if \"centr_ID\" not in exposures.gdf.columns:\\n',\n", + " \" exposures.assign_centroids(base_hazard)\\n\",\n", + " \"\\n\",\n", + " \" # Identify which exposure points meet the depth criteria\\n\",\n", + " \" # 'centr_ID' maps each exposure row to a column index in\\n\",\n", + " \" # the hazard intensity matrix\\n\",\n", + " ' centr_indices = exposures.gdf[\"centr_ID\"].values\\n',\n", + " \" point_peak_depths = peak_depths[centr_indices]\\n\",\n", + " \"\\n\",\n", + " \" # We apply the reduction only where Depth > threshold\\n\",\n", + " \" mask = point_peak_depths > threshold\\n\",\n", + " ' exposures.gdf.loc[mask, \"value\"] *= 1 - reduction_factor\\n',\n", + " \"\\n\",\n", + " \" return exposures\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"10a5ad67\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"```python\\n\",\n", + " \"# --- Usage Example ---\\n\",\n", + " \"# Define the measure\\n\",\n", + " \"flood_protection = Measure(\\n\",\n", + " ' name=\"High-Threshold Value Protection\",\\n',\n", + " \" exposures_changes=reduce_exposure_by_depth\\n\",\n", + " \")\\n\",\n", + " \"\\n\",\n", + " \"# When you call apply, the `Hazard` haz is automatically passed to the function\\n\",\n", + " \"# as 'base_hazard'\\n\",\n", + " \"new_exp, _, _ = flood_protection.apply(\\n\",\n", + " \" exp, impf, haz,\\n\",\n", + " \" kwargs_exposures={'reduction_factor': 0.5, 'threshold': 2.0}\\n\",\n", + " \")\\n\",\n", + " \"\\n\",\n", + " '# You can also overide this with another \"base\" hazard in the\\n',\n", + " \"# `kwarg_exposures` if you want.\\n\",\n", + " \"#\\n\",\n", + " \"# This could be for instance if the flood protection is\\n\",\n", + " \"# now defined not for the full probabilistic hazard set\\n\",\n", + " \"# but for a specific event, or a subset.\\n\",\n", + " \"new_exp, _, _ = flood_protection.apply(\\n\",\n", + " \" exp, impf, haz,\\n\",\n", + " \" kwargs_exposures={'base_hazard'= other_hazard,\\n\",\n", + " \" 'reduction_factor': 0.5, 'threshold': 2.0}\\n\",\n", + " \")\\n\",\n", + " \"```\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"8baaf95c\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"```{note}\\n\",\n", + " \"The functions you define yourself are automatically made to accept keyword arguments if they do not already.\\n\",\n", + " \"```\\n\",\n", + " \"\\n\",\n", + " \"```{note}\\n\",\n", + " \"The functions do not necessarily need to use the Exposures|Hazard|ImpactFuncSet object, but they still have to accept it as an argument.\\n\",\n", + " \"```\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"a984d0a7\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"### Deep copies\\n\",\n", + " \"\\n\",\n", + " 'To prevent accidental \"in-place\" modification of your baseline data, `Measure` utilizes `copy.deepcopy()`.\\n',\n", + " \"If `enforce_copy=True` in `apply()` (the default case), the object is cloned before the transformation function runs, ensuring the original objects remains untouched.\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"6d58543f\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"### Measure cash flow (`CostIncome`)\\n\",\n", + " \"\\n\",\n", + " \"The `cost_income` attribute allows the measure to carry its own. It can notably store:\\n\",\n", + " \"\\n\",\n", + " \"* Initial investment costs.\\n\",\n", + " \"* Maintenance costs over time.\\n\",\n", + " \"* Potential indirect income generated by the measure.\\n\",\n", + " \"\\n\",\n", + " \"For more information and advanced usage see the [detailed tutorial on cost income object](cost-income-tutorial).\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"b5c1873f\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"## Defining measures using measure configuration\\n\",\n", + " \"\\n\",\n", + " \"Defining adaptation measures with Python functions can be difficult if you are not familiar with Python. For common changes, a `Measure` can instead be described *declaratively* with a `MeasureConfig` object. A configuration can also be written to, and read from, a YAML file (see the [detailed tutorial](measure-config-tutorial)).\\n\",\n", + " \"\\n\",\n", + " \"A `MeasureConfig` aggregates one configuration object per component of the measure:\\n\",\n", + " \"\\n\",\n", + " \"| Field | Class | Main parameters |\\n\",\n", + " \"|---|---|---|\\n\",\n", + " \"| `hazard_modifier` | `HazardModifierConfig` | `haz_int_mult`, `haz_int_add`, `impact_rp_cutoff`, `new_hazard_path` |\\n\",\n", + " \"| `impfset_modifier` | `ImpfsetModifierConfig` | `impf_ids`, `impf_{mdd,paa,int}_{mult,add}`, `new_impfset_path` |\\n\",\n", + " \"| `exposures_modifier` | `ExposuresModifierConfig` | `reassign_impf_id`, `new_exposures_path` |\\n\",\n", + " \"| `cost_income` | `CostIncomeConfig` | same parameters as `CostIncome` |\\n\",\n", + " \"\\n\",\n", + " 'All parameters default to \"no change\" (multipliers of `1.0`, offsets of `0.0`, `None` otherwise). A `new_*_path` loads a replacement object from file; if modifiers are also given, they are applied after the replacement (and a warning is issued).',\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"4e7a2c91\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"### Creating a measure from a configuration\\n\",\n", + " \"\\n\",\n", + " \"The measure below reduces the mean damage degree of impact function `1` by 25 %, and removes the events that are more frequent than a 1-in-20-year event (ranked by their **impact** in the baseline situation not their intensity).\\n\",\n", + " \"`Measure.from_config` converts the configuration into the corresponding transformation functions.\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"execution_count\": 3,\n", + " \"id\": \"c3d90b57\",\n", + " \"metadata\": {},\n", + " \"outputs\": [],\n", + " \"source\": [\n", + " \"from climada.entity.measures.measure_config import (\\n\",\n", + " \" CostIncomeConfig,\\n\",\n", + " \" ExposuresModifierConfig,\\n\",\n", + " \" HazardModifierConfig,\\n\",\n", + " \" ImpfsetModifierConfig,\\n\",\n", + " \" MeasureConfig,\\n\",\n", + " \")\\n\",\n", + " \"\\n\",\n", + " \"config = MeasureConfig(\\n\",\n", + " ' name=\"Flood proofing and protection standard\",\\n',\n", + " ' haz_type=\"FL\",\\n',\n", + " \" impfset_modifier=ImpfsetModifierConfig(\\n\",\n", + " ' haz_type=\"FL\", impf_ids=1, impf_mdd_mult=0.75\\n',\n", + " \" ),\\n\",\n", + " ' hazard_modifier=HazardModifierConfig(haz_type=\"FL\", impact_rp_cutoff=20),\\n',\n", + " \" exposures_modifier=ExposuresModifierConfig(), # no change, note that you can also just leave it out entirely\\n\",\n", + " \" cost_income=CostIncomeConfig(init_cost=1_000_000, periodic_cost=50_000),\\n\",\n", + " \" color_rgb=(0.1, 0.4, 0.8),\\n\",\n", + " \")\\n\",\n", + " \"\\n\",\n", + " \"measure = Measure.from_config(config)\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"a81f6d02\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"The resulting `Measure` is used like any other. The `base_*` triplet needed by `impact_rp_cutoff` is provided automatically by `Measure.apply`.\\n\",\n", + " \"\\n\",\n", + " \"```python\\n\",\n", + " \"imp_with_measure = measure.calc_impact(exp, impfset, haz)\\n\",\n", + " \"```\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"824e1dd3\",\n", + " \"metadata\": {},\n", + " \"source\": [\"## Advanced examples of measures\"],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"f1a15910-6de8-416f-982f-dd0e53fc13cf\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"Measures are often modelled as a fixed change. In practice, the effect of an adaptation action often depends on **when** it is evaluated (for instance, a relocation programme that is phased in over several years) or on **additional parameters** (regions targeted, share of value removed, protection threshold).\\n\",\n", + " \"\\n\",\n", + " \"The examples below illustrate three patterns:\\n\",\n", + " \"\\n\",\n", + " \"1. **Exposures**: a linear phase-in of asset relocation (`year`, `start_year`, `full_year`, `fraction`, `regions`).\\n\",\n", + " \"2. **Impact functions**: a year-dependent reduction of the mean damage ratio.\\n\",\n", + " \"3. **Hazard**: a step-function protection standard that needs the baseline impacts, and therefore the `base_*` triplet.\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"execution_count\": 4,\n", + " \"id\": \"7e17dfb9-b760-48df-8878-be1d547290a3\",\n", + " \"metadata\": {},\n", + " \"outputs\": [],\n", + " \"source\": [\n", + " \"def phase_in(year, start_year, full_year):\\n\",\n", + " ' \"\"\"Linear effectiveness ramp. (Helper function)\\n',\n", + " \"\\n\",\n", + " \" Parameters\\n\",\n", + " \" ----------\\n\",\n", + " \" year : int\\n\",\n", + " \" Year at which the measure is evaluated.\\n\",\n", + " \" start_year : int\\n\",\n", + " \" Year at which the measure starts having an effect.\\n\",\n", + " \" full_year : int\\n\",\n", + " \" Year at which the measure is fully effective.\\n\",\n", + " \"\\n\",\n", + " \" Returns\\n\",\n", + " \" -------\\n\",\n", + " \" float\\n\",\n", + " \" Effectiveness in [0, 1].\\n\",\n", + " ' \"\"\"\\n',\n", + " \" return float(np.clip((year - start_year) / (full_year - start_year), 0, 1))\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"def relocate_assets(exposures, *, year, start_year, full_year, fraction, regions):\\n\",\n", + " ' \"\"\"Progressively remove asset value from exposed regions.\\n',\n", + " \"\\n\",\n", + " \" Parameters\\n\",\n", + " \" ----------\\n\",\n", + " \" exposures : climada.entity.exposures.base.Exposures\\n\",\n", + " \" Exposures to modify (already a copy if ``enforce_copy=True``).\\n\",\n", + " \" year : int\\n\",\n", + " \" Evaluation year.\\n\",\n", + " \" start_year, full_year : int\\n\",\n", + " \" Start and end of the linear phase-in.\\n\",\n", + " \" fraction : float\\n\",\n", + " \" Share of value removed once the measure is fully implemented.\\n\",\n", + " \" regions : list of int\\n\",\n", + " \" ``region_id`` values targeted by the measure.\\n\",\n", + " \"\\n\",\n", + " \" Returns\\n\",\n", + " \" -------\\n\",\n", + " \" climada.entity.exposures.base.Exposures\\n\",\n", + " \" Exposures with reduced value in the targeted regions.\\n\",\n", + " ' \"\"\"\\n',\n", + " \" ramp = phase_in(year, start_year, full_year)\\n\",\n", + " ' mask = exposures.gdf[\"region_id\"].isin(regions)\\n',\n", + " ' exposures.gdf.loc[mask, \"value\"] *= 1 - fraction * ramp\\n',\n", + " \" return exposures\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"def flood_proofing(\\n\",\n", + " ' impfset, *, year, start_year, full_year, reduction, haz_type=\"FL\", impf_id=1\\n',\n", + " \"):\\n\",\n", + " ' \"\"\"Scale down the mean damage ratio of one impact function.\\n',\n", + " \"\\n\",\n", + " \" Parameters\\n\",\n", + " \" ----------\\n\",\n", + " \" impfset : climada.entity.impact_funcs.impact_func_set.ImpactFuncSet\\n\",\n", + " \" Impact function set to modify.\\n\",\n", + " \" year : int\\n\",\n", + " \" Evaluation year.\\n\",\n", + " \" start_year, full_year : int\\n\",\n", + " \" Start and end of the linear phase-in.\\n\",\n", + " \" reduction : float\\n\",\n", + " \" Relative MDD reduction once fully effective.\\n\",\n", + " \" haz_type : str, optional\\n\",\n", + " \" Hazard type of the targeted function.\\n\",\n", + " \" impf_id : int, optional\\n\",\n", + " \" Id of the targeted function.\\n\",\n", + " \"\\n\",\n", + " \" Returns\\n\",\n", + " \" -------\\n\",\n", + " \" climada.entity.impact_funcs.impact_func_set.ImpactFuncSet\\n\",\n", + " \" Impact function set with the modified function.\\n\",\n", + " ' \"\"\"\\n',\n", + " \" ramp = phase_in(year, start_year, full_year)\\n\",\n", + " \" impf = impfset.get_func(haz_type=haz_type, fun_id=impf_id)\\n\",\n", + " \" impf.mdd = impf.mdd * (1 - reduction * ramp)\\n\",\n", + " \" return impfset\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"def protection_standard(\\n\",\n", + " \" hazard, *, year, start_year, threshold, base_exposures, base_impfset, base_hazard\\n\",\n", + " \"):\\n\",\n", + " ' \"\"\"Remove events whose baseline impact is below a protection threshold.\\n',\n", + " \"\\n\",\n", + " \" Parameters\\n\",\n", + " \" ----------\\n\",\n", + " \" hazard : climada.hazard.base.Hazard\\n\",\n", + " \" Hazard to modify.\\n\",\n", + " \" year : int\\n\",\n", + " \" Evaluation year.\\n\",\n", + " \" start_year : int\\n\",\n", + " \" Year the protection is in place (no effect before).\\n\",\n", + " \" threshold : float\\n\",\n", + " \" Events with baseline total impact below this value are removed.\\n\",\n", + " \" base_exposures, base_impfset, base_hazard\\n\",\n", + " \" Unmodified triplet, provided by ``Measure.apply``.\\n\",\n", + " \"\\n\",\n", + " \" Returns\\n\",\n", + " \" -------\\n\",\n", + " \" climada.hazard.base.Hazard\\n\",\n", + " \" Hazard without the protected events. Frequencies of the remaining\\n\",\n", + " \" events are left unchanged.\\n\",\n", + " ' \"\"\"\\n',\n", + " \" from climada.engine.impact_calc import ImpactCalc\\n\",\n", + " \"\\n\",\n", + " \" if year < start_year:\\n\",\n", + " \" return hazard\\n\",\n", + " \" imp = ImpactCalc(base_exposures, base_impfset, base_hazard).impact(\\n\",\n", + " \" assign_centroids=False\\n\",\n", + " \" )\\n\",\n", + " \" return hazard.select(event_id=imp.event_id[imp.at_event >= threshold])\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"937ded7c-05be-4708-ad08-8d366e287406\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"The parameters can then be provided when the measure is applied, or the impact is computed:\"\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"execution_count\": null,\n", + " \"id\": \"2bcbb4ec-701d-4e38-9321-e86a7dea9538\",\n", + " \"metadata\": {},\n", + " \"outputs\": [],\n", + " \"source\": [\n", + " \"from climada.entity.measures import Measure\\n\",\n", + " \"\\n\",\n", + " \"meas_relocation = Measure(\\n\",\n", + " ' \"relocation\",\\n',\n", + " \" exposures_changes=relocate_assets,\\n\",\n", + " \")\\n\",\n", + " \"\\n\",\n", + " \"meas_relocation_and_standard = Measure(\\n\",\n", + " ' \"relocation_and_standard\",\\n',\n", + " \" exposures_changes=relocate_assets,\\n\",\n", + " \" hazard_changes=protection_standard,\\n\",\n", + " \")\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"4e2ea806-8bca-4681-8543-750b9b303a59\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"```python\\n\",\n", + " \"exp_relocated, _, _ = meas_relocation.apply(\\n\",\n", + " \" exp,\\n\",\n", + " \" impfset,\\n\",\n", + " \" haz,\\n\",\n", + " \" kwargs_exposures=dict(\\n\",\n", + " \" year=2030, start_year=2025, full_year=2045, fraction=0.3, regions=[1, 2]\\n\",\n", + " \" ),\\n\",\n", + " \")\\n\",\n", + " \"\\n\",\n", + " \"imp_with_relocation_and_standards = meas_relocation_and_standard.calc_impact(\\n\",\n", + " \" exp,\\n\",\n", + " \" impfset,\\n\",\n", + " \" haz,\\n\",\n", + " \" kwargs_exposures=dict(\\n\",\n", + " \" year=2030, start_year=2025, full_year=2045, fraction=0.3, regions=[1, 2]\\n\",\n", + " \" ),\\n\",\n", + " \" kwargs_hazard=dict(year=2030, start_year=2025, full_year=2045, threshold=100000),\\n\",\n", + " \")\\n\",\n", + " \"```\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"fabfd29c-0b5e-442a-912f-3cbfac45f78d\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"## Using `MeasureSet` objects\\n\",\n", + " \"\\n\",\n", + " \"`MeasureSet` are used to group a set of measure together, notably to compare them in a cost benefit analysis.\\n\",\n", + " \"\\n\",\n", + " \"`MeasureSet` are build from a list of measures, taking the example from the quickstart:\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"execution_count\": 6,\n", + " \"id\": \"04e747b4-f856-43c3-ba43-a1878c112afe\",\n", + " \"metadata\": {},\n", + " \"outputs\": [],\n", + " \"source\": [\n", + " \"from climada.entity.measures.measure import Measure\\n\",\n", + " \"from climada.entity.measures.measure_set import MeasureSet\\n\",\n", + " \"from climada.entity.measures.cost_income import CostIncome\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"# Define the effect with python functions\\n\",\n", + " \"def targeted_campaign(impact_func_set, impf_id, rescale):\\n\",\n", + " ' \"\"\"Rescales the impact function with id impf_id\"\"\"\\n',\n", + " \" impact_func_set.get_func(fun_id=imp_id).mdd *= rescale\\n\",\n", + " \" return impact_func_set\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"def building_retrofit(exposures, reduction_factor):\\n\",\n", + " ' \"\"\"Reduces the value of all assets by a fixed percentage.\"\"\"\\n',\n", + " ' exposures.gdf[\"value\"] *= 1 - reduction_factor\\n',\n", + " \" return exposures\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"def sea_wall_protection(hazard, reduction):\\n\",\n", + " ' \"\"\"Reduces flood intensity by an absolute amount.\"\"\"\\n',\n", + " \" hazard.intensity.data -= reduction\\n\",\n", + " \" return hazard\\n\",\n", + " \"\\n\",\n", + " \"\\n\",\n", + " \"# Initialize the Measure objects\\n\",\n", + " \"campaign = Measure(\\n\",\n", + " ' name=\"Targeted awareness campaign\",\\n',\n", + " \" impfset_changes=targeted_campaign,\\n\",\n", + " \" cost_income=CostIncome(init_cost=500_000),\\n\",\n", + " \")\\n\",\n", + " \"\\n\",\n", + " \"retrofitting_and_seawall = Measure(\\n\",\n", + " ' name=\"Building Retrofit and seawall construction\",\\n',\n", + " \" exposures_changes=building_retrofit,\\n\",\n", + " \" hazard_changes=sea_wall_protection,\\n\",\n", + " \" cost_income=CostIncome(init_cost=1_000_000),\\n\",\n", + " \")\\n\",\n", + " \"\\n\",\n", + " \"meas_set = MeasureSet([campaign, retrofitting_and_seawall])\",\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"024822ed-53fc-406e-a9f0-012b244113c8\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"You can list the measures names of a set, get its size, check if it contains a measure (by name), `append()` an additional measure, `select()` them by their name.\"\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"execution_count\": 7,\n", + " \"id\": \"2d532d22-6b74-45a4-8b14-3e7b9ede2def\",\n", + " \"metadata\": {},\n", + " \"outputs\": [\n", + " {\n", + " \"name\": \"stdout\",\n", + " \"output_type\": \"stream\",\n", + " \"text\": [\n", + " \"There are 2 measures in the set:\\n\",\n", + " \" ['Targeted awareness campaign', 'Building Retrofit and seawall construction']\\n\",\n", + " \"True\\n\",\n", + " \"False\\n\",\n", + " ],\n", + " }\n", + " ],\n", + " \"source\": [\n", + " 'print(f\"There are {len(meas_set)} measures in the set:\\\\n\", meas_set.names)\\n',\n", + " 'print(\"Targeted awareness campaign\" in meas_set)\\n',\n", + " 'print(\"Mangroves\" in meas_set)',\n", + " ],\n", + " },\n", + " {\n", + " \"cell_type\": \"code\",\n", + " \"execution_count\": 8,\n", + " \"id\": \"cdfc53b8-5da4-4b72-8fa5-7a7f5e18a6ea\",\n", + " \"metadata\": {},\n", + " \"outputs\": [\n", + " {\n", + " \"data\": {\n", + " \"text/plain\": [\n", + " \"{'Targeted awareness campaign': }\"\n", + " ]\n", + " },\n", + " \"execution_count\": 8,\n", + " \"metadata\": {},\n", + " \"output_type\": \"execute_result\",\n", + " }\n", + " ],\n", + " \"source\": ['meas_set.select(\"Targeted awareness campaign\")'],\n", + " },\n", + " {\n", + " \"cell_type\": \"markdown\",\n", + " \"id\": \"8a29d4b4-983f-4fbd-894b-01716c899dfc\",\n", + " \"metadata\": {},\n", + " \"source\": [\n", + " \"You can also combine or compose measures of a `MeasureSet` into one `Measure`.\\n\",\n", + " \"\\n\",\n", + " \"1. *Combining* (`.combine()`) merges the effects of all the measures, taking the best possible effect when multiple measures affect the same dimension.\\n\",\n", + " \"\\n\",\n", + " \"For instance:\\n\",\n", + " \"- When several measures of the set reduce the intensity of the hazard, the combined effect will be the point-wise highest reduction across all measures.\\n\",\n", + " \"- When several measures modify the same impact function, the combined measure uses the point-wise minimum of all the modified impact functions.\\n\",\n", + " \"\\n\",\n", + " \"2. *Composing* (`.compose()`) does the mathematical composition of the measures, i.e., for a list of measures `[m1, m2, m3]`, the resulting transformation is `m1(m2(m3(x)))`. Note that this means measure `m3` is applied first, and the order of list of measures given to the `MeasureSet` matters!\",\n", + " ],\n", + " },\n", + " ],\n", + " \"metadata\": {\n", + " \"kernelspec\": {\n", + " \"display_name\": \"Python [conda env:climada_env_dev]\",\n", + " \"language\": \"python\",\n", + " \"name\": \"conda-env-climada_env_dev-py\",\n", + " },\n", + " \"language_info\": {\n", + " \"codemirror_mode\": {\"name\": \"ipython\", \"version\": 3},\n", + " \"file_extension\": \".py\",\n", + " \"mimetype\": \"text/x-python\",\n", + " \"name\": \"python\",\n", + " \"nbconvert_exporter\": \"python\",\n", + " \"pygments_lexer\": \"ipython3\",\n", + " \"version\": \"3.11.15\",\n", + " },\n", + " },\n", + " \"nbformat\": 4,\n", + " \"nbformat_minor\": 5,\n", + "}" ] } ], "metadata": { - "kernelspec": { - "display_name": "Python [conda env:climada_env_dev]", - "language": "python", - "name": "conda-env-climada_env_dev-py" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.15" + "jupytext": { + "cell_metadata_filter": "-all", + "main_language": "python", + "notebook_metadata_filter": "-all" } }, "nbformat": 4,