"""
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 <https://www.gnu.org/licenses/>.
---
Define Measure class.
"""
from __future__ import annotations
from pandas.tseries.offsets import BaseOffset
__all__ = ["Measure"]
import copy
import inspect
import logging
from functools import wraps
from typing import TYPE_CHECKING, Any, Optional, Tuple, TypeVar
from climada.entity.measures.measure_config import MeasureConfig
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?
# Note for review:
# This function will moved in helper.py in a future PR which will
# add I/O based on MeasureConfig dataclasses
def identity_function(x: T, **_kwargs: Any) -> T:
"""Identity function
Returns the provided parameter. Usefull to design measures without effect
on some of the risk components (Hazard, Exposures, Impact Function)
"""
return x
def allow_kwargs(func):
"""
Decorator that allows a function to accept (and silently ignore) keyword arguments.
If the wrapped function already accepts ``**kwargs``, it is called unchanged.
Otherwise, any keyword arguments not present in the function's signature are
filtered out before the call, preventing ``TypeError`` from unexpected keywords.
The functions used by `Measure` objects to apply changes on ``Exposures``, ``Hazard``,
and ``ImpactFuncSet`` always receive ``base_exposure, base_hazard, base_impfset`` as
keyword arguments. This decorator is applied to users-defined functions and prevent
them from not accepting these kwargs and raising a ``TypeError``.
Parameters
----------
func : callable
The function to wrap.
Returns
-------
callable
A wrapped version of `func` that accepts keyword arguments.
Examples
--------
>>> @allow_kwargs
... def greet(name, greeting="Hello"):
... return f"{greeting}, {name}!"
>>> greet("Alice", greeting="Hi", unused_param="ignored")
'Hi, Alice!'
>>> @allow_kwargs
... def add(a, b):
... return a + b
>>> add(1, 2, extra=99)
3
"""
@wraps(func)
def wrapper(*args, **kwargs):
# Get the names of arguments the original function accepts
params = inspect.signature(func).parameters
# Filter kwargs to only include what the function can handle
# (Unless the function already has **kwargs in its signature)
if any(p.kind == p.VAR_KEYWORD for p in params.values()):
return func(*args, **kwargs)
filtered_kwargs = {k: v for k, v in kwargs.items() if k in params}
return func(*args, **filtered_kwargs)
return wrapper
[docs]
class Measure:
"""
Contains a measure to be applied to a set of exposures, impact functions,
and hazard.
A ``Measure`` represents a single adaptation or risk-reduction action. It
holds three (optional) transformation functions, one each for
:class:`Exposures`, :class:`ImpactFuncSet`, and :class:`Hazard`, that are
can be applied to a triplet of ``(Exposures, ImpactFuncSet, Hazard)``, to
reflect the effect of the measure.
It also holds a `CostIncome` object to define the financial aspects of the
measure (see :class:`CostIncome` and :ref:`cost-income-tutorial`).
Finally it holds an `implementation_duration` attribute, in the form of a
pandas ``DateOffset``, which is used when the time dimension is considered.
Notes
-----
The only requirement for each function is to return an object of the same
class (e.g. :class:`Hazard` for ``hazard_change``). Functions can accept
keyword arguments to enable advanced effect (depending on a year of
application for instance). These arguments can be passed when the
:class:`Measure` is applied (see :py:meth:`~Measure.apply`). Note that for
convenience, each functions receive the by default "base" ``(Exposures,
ImpactFuncSet, Hazard)`` triplet as keyword arguments (`base_exposure`,
`base_impfset`, `base_hazard`).
If the ``Measure`` was defined from a ``MeasureConfig`` object, the
configuration is stored and the measure can be serialized to a file.
(see :ref:`measure-config-tutorial` and :ref:`measure-tutorial`).
Attributes
----------
name : str
Name of the measure.
exposures_change : ExposuresChange
Function to change exposures.
impfset_change : ImpfsetChange
Function to change impact function set.
hazard_change : HazardChange
Function to change hazard.
sub_measures : list of str, optional
List of measure names that this measure is a combination of.
cost_income : climada.entity.measures.cost_income.CostIncome
Cost and income object associated with the measure.
implementation_duration : pd.DateOffset, optional
Duration of implementation before the measure is fully functional.
"""
[docs]
def __init__(
self,
name: str,
*,
exposures_changes: ExposuresChange = identity_function,
impfset_changes: ImpfsetChange = identity_function,
hazard_changes: HazardChange = identity_function,
sub_measures: Optional[list[str]] = None,
cost_income: Optional[CostIncome] = None,
implementation_duration: Optional[BaseOffset] = None,
color_rgb: Optional[Tuple[float, float, float]] = None,
_config: Optional[MeasureConfig] = None,
):
"""
Initialize a new Measure object.
Parameters
----------
name : str
Name of the measure.
exposures_change : callable, optional
Transformation function for Exposures. Defaults to identity.
impfset_change : callable, optional
Transformation function for ImpactFuncSet. Defaults to identity.
hazard_change : callable, optional
Transformation function for Hazard. Defaults to identity.
sub_measures : list of str, optional
Names of component measures.
cost_income : CostIncome, optional
Financial data. If None, an empty CostIncome is initialized.
implementation_duration : pd.DateOffset, optional
Time offset for full implementation.
"""
self.name = name
self.exposures_changes = allow_kwargs(exposures_changes)
self.hazard_changes = allow_kwargs(hazard_changes)
self.impfset_changes = allow_kwargs(impfset_changes)
self.sub_measures = sub_measures
self.cost_income = cost_income if cost_income is not None else CostIncome()
self.implementation_duration = implementation_duration
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
a ``MeasureConfig`` object and can be serialized.
"""
return self._config is not None
[docs]
def apply_exposures_changes(
self, exposures: Exposures, enforce_copy: bool = True, **kwargs
) -> Exposures:
"""Apply the changes from the measure to the given :class:`Exposures` object.
This method applies the `exposures_changes` function of the measure to
the provided :class:`Exposures` object. If ``enforce_copy`` is True (default), a
deep copy of the exposures is created before modification to ensure
immutability of the original object.
Additional keyword arguments to the function can be passed directly.
Parameters
----------
exposures : Exposures
The input exposures object to be transformed.
enforce_copy : bool, optional
If True (default), creates a deep copy of `exposures` before applying
changes, provided the transformation function is not the identity function.
If False, the original object may be modified in-place depending on the
behavior of `exposures_changes`.
**kwargs : dict, optional
Additional keyword arguments passed directly to the `exposures_changes`
function.
Returns
-------
Exposures
The resulting :class:`Exposures` object after the transformation has been applied.
If `enforce_copy` was True, this is a new object.
Notes
-----
The deep copy operation is skipped if `enforce_copy` is False or if
`self.exposures_changes` is the identity function, optimizing performance
when no actual changes are expected or when in-place modification is desired.
"""
changed_exp = (
copy.deepcopy(exposures)
if enforce_copy and self.exposures_changes.__name__ != "identity_function"
else exposures
)
try:
return self.exposures_changes(changed_exp, **kwargs)
except TypeError as exc:
# Check if 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\
that were not provided.\n"
f"Please check the function signature or the helper used and provide the\
required arguments "
"via kwargs_exposures.\n"
f"Original error: {exc}"
) from exc
raise
[docs]
def apply_impfset_changes(
self, impfset: ImpactFuncSet, enforce_copy: bool = True, **kwargs
) -> ImpactFuncSet:
"""
Apply the changes from the measure to the given :class:`ImpactFuncSet` object.
This method applies the `impfset_changes` function of the measure to
the provided :class:`ImpactFuncSet` object. If `enforce_copy` is True
(default), a deep copy of the impfset is created before modification to
ensure immutability of the original object.
Parameters
----------
impfset : ImpactFuncSet
The input impfset object to be transformed.
enforce_copy : bool, optional
If True (default), creates a deep copy of `impfset` before applying
changes, provided the transformation function is not the identity
function. If False, the original object may be modified in-place
depending on the behavior of `impfset_changes`.
**kwargs : dict, optional
Additional keyword arguments passed directly to the `impfset_changes`
function.
Returns
-------
ImpactFuncSet
The resulting :class:`ImpactFuncSet` after the transformation has
been applied. If `enforce_copy` was True, this is a new object.
Notes
-----
The deep copy operation is skipped if `enforce_copy` is False or if
`self.impfset_changes` is the identity function, optimizing performance
when no actual changes are expected or when in-place modification is
desired.
"""
changed_impfset = (
copy.deepcopy(impfset)
if enforce_copy and self.impfset_changes.__name__ != "identity_function"
else impfset
)
try:
return self.impfset_changes(changed_impfset, **kwargs)
except TypeError as exc:
# Check if 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 impact function set requires\
additional arguments that were not provided.\n"
f"Please check the function signature or the helper used\
and provide the required arguments via kwargs_impfset.\n"
f"Original error: {exc}"
) from exc
raise
[docs]
def apply_hazard_changes(
self, hazard: Hazard, enforce_copy: bool = True, **kwargs
) -> Hazard:
"""
Apply the changes from the measure to the given :class:`Hazard` object.
This method applies the `hazard_changes` function of the measure to the
provided :class:`Hazard` object. If `enforce_copy` is True (default), a
deep copy of the hazard is created before modification to ensure
immutability of the original object.
Parameters
----------
hazard : Hazard
The input hazard object to be transformed.
enforce_copy : bool, optional
If True (default), creates a deep copy of `hazard` before applying
changes, provided the transformation function is not the identity
function. If False, the original object may be modified in-place
depending on the behavior of `hazard_changes`.
**kwargs : dict, optional
Additional keyword arguments passed directly to the `hazard_changes`
function.
Returns
-------
Hazard
The resulting hazard object after the transformation has been
applied. If `enforce_copy` was True, this is a new object.
Notes
-----
The deep copy operation is skipped if `enforce_copy` is False or if
`self.hazard_changes` is the identity function, optimizing performance
when no actual changes are expected or when in-place modification is
desired.
"""
changed_hazard = (
copy.deepcopy(hazard)
if enforce_copy and self.hazard_changes.__name__ != "identity_function"
else hazard
)
try:
return self.hazard_changes(changed_hazard, **kwargs)
except TypeError as exc:
# Check if 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 hazard requires\
additional arguments that were not provided.\n"
f"Please check the function signature or the helper used\
and provide the required arguments via kwargs_hazard.\n"
f"Original error: {exc}"
) from exc
raise
[docs]
def apply(
self,
exposures: Exposures,
impfset: ImpactFuncSet,
hazard: Hazard,
enforce_copy: bool = True,
**kwargs,
) -> Tuple[Exposures, ImpactFuncSet, Hazard]:
"""Apply all measure transformations to the provided triplet of
:class:`Exposures`, :class:`ImpactFuncSet`, :class:`Hazard`.
This method applies the measure changes across all three
risk parts: exposures, impact function set, and hazard data.
The method implements a flexible keyword arguments merging strategy
where the original triplet is provided as default context to each
transformation, which can then be overridden by entity-specific kwargs
dictionaries. This enables transformation requiring the information
from other risk components (for instance, removing events based on impact
threshold) or additional information (for instance, effect depending on
year of implementation).
Refer to :ref:`measure-tutorial` for more details.
Parameters
----------
exposures : Exposures
The input exposures object to be transformed.
impfset : ImpactFuncSet
The impact function set to be transformed.
hazard : Hazard
The hazard data to be transformed.
enforce_copy : bool, optional
If True (default), creates deep copies of entities before applying
changes, provided the transformation functions are not identity functions.
If False, entities may be modified in-place depending on the behavior
of the underlying transformation methods.
**kwargs : dict, optional
Additional keyword arguments for configuring transformations. Supports
nested dictionaries for entity-specific customization:
* ``kwargs_exposures``: Dict of kwargs passed to `apply_exposures_changes`
* ``kwargs_impfset``: Dict of kwargs passed to `apply_impfset_changes`
* ``kwargs_hazard``: Dict of kwargs passed to `apply_hazard_changes`
Each nested dict is merged with the default triplet context (exposures,
impfset, hazard), allowing transformations to access related entities
while permitting entity-specific overrides.
Returns
-------
Tuple[Exposures, ImpactFuncSet, Hazard]
A tuple containing the transformed entities in the order:
(changed_exposures, changed_impfset, changed_hazard). If `enforce_copy`
was True, these are new objects; otherwise, they may reference the
original inputs or modified versions thereof.
Notes
-----
The kwargs merging follows this priority order:
1. Default context: The original triplet (exposures, impfset, hazard)
2. Entity-specific overrides: Values from ``kwargs_exposures``,
``kwargs_impfset``, or ``kwargs_hazard`` respectively
This ensures that each transformation receives full context about all entities
while allowing fine-grained control over individual transformations.
The transformation order is: exposures → hazard → impact function set.
Each transformation is independent, so changes to one entity do not affect
the others during processing.
"""
default_kwargs = {
"base_exposures": exposures,
"base_impfset": impfset,
"base_hazard": hazard,
}
# Always provide the triplet by default, and overwrite by custom kwargs.
kwargs_exp = default_kwargs | kwargs.get("kwargs_exposures", {})
kwargs_impfset = default_kwargs | kwargs.get("kwargs_impfset", {})
kwargs_hazard = default_kwargs | kwargs.get("kwargs_hazard", {})
changed_exposures = self.apply_exposures_changes(
exposures, enforce_copy, **kwargs_exp
)
changed_hazard = self.apply_hazard_changes(
hazard, enforce_copy, **kwargs_hazard
)
changed_impfset = self.apply_impfset_changes(
impfset, enforce_copy, **kwargs_impfset
)
return changed_exposures, changed_impfset, changed_hazard
[docs]
def calc_impact(self, exposures, impfset, hazard):
from climada.engine.impact_calc import (
ImpactCalc, # pylint: disable=import-outside-toplevel
)
new_exp, new_impfs, new_haz = self.apply(exposures, impfset, hazard)
if new_haz.centr_exp_col not in new_exp.gdf.columns:
LOGGER.warning(
"No assigned hazard centroids in exposure object after the "
"application of the measure. The centroids will be assigned during impact "
"calculation. This is potentiall costly. To silence this warning, make sure "
"that centroids are assigned to all exposures."
)
new_exp.assign_centroids(new_haz)
imp = ImpactCalc(new_exp, new_impfs, new_haz).impact(
save_mat=False, assign_centroids=False
)
return imp.calc_risk_transfer(0, 0)