From 1a755c371c8047dd953b2074d7a93676ca18ae8b Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 2 Apr 2026 11:44:55 +0200 Subject: [PATCH 01/37] makes soon to be old `measures` module a legacy --- climada/engine/test/test_cost_benefit.py | 4 +- climada/entity/__init__.py | 2 +- .../__init__.py | 0 .../{measures => _legacy_measures}/base.py | 0 climada/entity/_legacy_measures/helper.py | 246 ++++++++++++++ .../entity/_legacy_measures/measure_config.py | 318 ++++++++++++++++++ .../measure_set.py | 0 .../test/__init__.py | 0 .../test/data/.gitignore | 0 .../test/test_base.py | 0 .../test/test_meas_set.py | 0 climada/entity/_legacy_measures/types.py | 10 + climada/entity/entity_def.py | 2 +- climada/entity/test/test_entity.py | 2 +- climada/trajectories/snapshot.py | 2 +- climada/trajectories/test/test_snapshot.py | 2 +- 16 files changed, 581 insertions(+), 7 deletions(-) rename climada/entity/{measures => _legacy_measures}/__init__.py (100%) rename climada/entity/{measures => _legacy_measures}/base.py (100%) create mode 100644 climada/entity/_legacy_measures/helper.py create mode 100644 climada/entity/_legacy_measures/measure_config.py rename climada/entity/{measures => _legacy_measures}/measure_set.py (100%) rename climada/entity/{measures => _legacy_measures}/test/__init__.py (100%) rename climada/entity/{measures => _legacy_measures}/test/data/.gitignore (100%) rename climada/entity/{measures => _legacy_measures}/test/test_base.py (100%) rename climada/entity/{measures => _legacy_measures}/test/test_meas_set.py (100%) create mode 100644 climada/entity/_legacy_measures/types.py diff --git a/climada/engine/test/test_cost_benefit.py b/climada/engine/test/test_cost_benefit.py index e48afec110..8b0276c665 100644 --- a/climada/engine/test/test_cost_benefit.py +++ b/climada/engine/test/test_cost_benefit.py @@ -33,10 +33,10 @@ risk_rp_100, risk_rp_250, ) +from climada.entity._legacy_measures import Measure +from climada.entity._legacy_measures.base import LOGGER as ILOG from climada.entity.disc_rates import DiscRates from climada.entity.entity_def import Entity -from climada.entity.measures import Measure -from climada.entity.measures.base import LOGGER as ILOG from climada.hazard.base import Hazard from climada.test import get_test_file from climada.util.api_client import Client diff --git a/climada/entity/__init__.py b/climada/entity/__init__.py index 7b830c2b70..ceb24ee065 100755 --- a/climada/entity/__init__.py +++ b/climada/entity/__init__.py @@ -19,8 +19,8 @@ init entity """ +from ._legacy_measures import * from .disc_rates import * from .entity_def import * from .exposures import * from .impact_funcs import * -from .measures import * diff --git a/climada/entity/measures/__init__.py b/climada/entity/_legacy_measures/__init__.py similarity index 100% rename from climada/entity/measures/__init__.py rename to climada/entity/_legacy_measures/__init__.py diff --git a/climada/entity/measures/base.py b/climada/entity/_legacy_measures/base.py similarity index 100% rename from climada/entity/measures/base.py rename to climada/entity/_legacy_measures/base.py diff --git a/climada/entity/_legacy_measures/helper.py b/climada/entity/_legacy_measures/helper.py new file mode 100644 index 0000000000..99ba0e8820 --- /dev/null +++ b/climada/entity/_legacy_measures/helper.py @@ -0,0 +1,246 @@ +""" +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 Measure class. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field, fields +from functools import reduce +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, TypeVar, Union, cast + +import numpy as np +import pandas as pd + +from climada.entity.exposures.base import Exposures +from climada.entity.impact_funcs.base import ImpactFunc +from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet +from climada.entity.measures.measure_config import ( + ExposuresModifierConfig, + HazardModifierConfig, + ImpfsetModifierConfig, + MeasureConfig, +) +from climada.hazard.base import Hazard + +if TYPE_CHECKING: + 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 + +LOGGER = logging.getLogger(__name__) + +T = TypeVar("T", Exposures, ImpactFuncSet, Hazard) + + +def identity_function(x: T, **_kwargs: Any) -> T: + return x + + +def composite_fun(*funcs: Callable[..., T]) -> Callable[..., T]: + """ + Composes multiple functions from right to left. + f(g(h(x))) + """ + + def compose(f: Callable[..., T], g: Callable[..., T]) -> Callable[..., T]: + def composed(x: T, **kwargs: Any) -> T: + return f(g(x, **kwargs), **kwargs) + + return composed + + return reduce(compose, funcs, identity_function) + + +def replace_hazard(new_hazard: Hazard) -> HazardChange: + """Returns a function that replaces the hazard with given new one.""" + + def hazard_change(_: Hazard) -> Hazard: + return new_hazard + + return hazard_change + + +def impact_intensity_rp_cutoff_helper( + cut_off_rp: float, +) -> HazardChange: + """Helper to generate a function removing events from a hazard for which + impacts do not exceed the impacts of a given return period. + + This helper returns a function to be applied on a hazard. + The function returned has to run an impact computation to find out which + event to remove from the hazard. + As such it has the following signature: + + ```f(hazard: Hazard, # The hazard to apply on + exposures: Exposures, # The exposure for the impact computation + impfset: ImpactFuncSet, # The impfset for the impact computation + base_hazard: Hazard, # The hazard for the impact computation + exposures_region_id: Optional[list[int]] = None, # Region id to filter to + ) -> Hazard + ``` + + Identifies events exceeding a return period and returns the hazard intensity + matrix with those event intensities zeroed out. + """ + from climada.engine.impact_calc import ImpactCalc + + def hazard_change( + hazard: Hazard, + base_exposures: Exposures, + base_impfset: ImpactFuncSet, + base_hazard: Hazard, + exposures_region_id: Optional[list[int]] = None, + ) -> Hazard: + exp_imp = base_exposures + if exposures_region_id: + # Narrowing the type for the LSP via boolean indexing + in_reg = base_exposures.gdf["region_id"].isin(exposures_region_id) + exp_imp = Exposures(base_exposures.gdf[in_reg], crs=base_exposures.crs) + + imp = ImpactCalc(exp_imp, base_impfset, base_hazard).impact(save_mat=False) + + # Calculate exceedance frequencies + sort_idxs = np.argsort(imp.at_event)[::-1] + exceed_freq = np.cumsum(imp.frequency[sort_idxs]) + events_below_cutoff = sort_idxs[exceed_freq <= cut_off_rp] + + # Modify sparse data structure + intensity_modified = base_hazard.intensity.copy() + for event in events_below_cutoff: + start, end = ( + intensity_modified.indptr[event], + intensity_modified.indptr[event + 1], + ) + intensity_modified.data[start:end] = 0 + + hazard.intensity = intensity_modified + return hazard + + return hazard_change + + +def helper_hazard(hazard_modifier: HazardModifierConfig) -> HazardChange: + """Returns a function that scales and shifts hazard intensity.""" + + def hazard_change(hazard: Hazard, **_kwargs) -> Hazard: + changed_hazard = ( + Hazard.from_hdf5(hazard_modifier.new_hazard_path) + if hazard_modifier.new_hazard_path is not None + else hazard + ) + data = cast(np.ndarray, changed_hazard.intensity.data) + data *= hazard_modifier.haz_int_mult + data += hazard_modifier.haz_int_add + data[data < 0] = 0 + changed_hazard.intensity.eliminate_zeros() + return changed_hazard + + if hazard_modifier.impact_rp_cutoff is not None: + hazard_change = composite_fun( + impact_intensity_rp_cutoff_helper(hazard_modifier.impact_rp_cutoff), + hazard_change, + ) + + return hazard_change + + +def helper_impfset(impfset_modifier: ImpfsetModifierConfig) -> ImpfsetChange: + """Returns a function that modifies impact functions (mdd, paa, intensity) by ID.""" + + def impfset_change(impfset: ImpactFuncSet, **_kwargs) -> ImpactFuncSet: + changed_impfset = ( + impfset.from_excel(impfset_modifier.new_impfset_path) + if impfset_modifier.new_impfset_path is not None + else impfset + ) + if impfset_modifier.impf_ids is None or impfset_modifier.impf_ids == "all": + ids_to_change = impfset.get_ids(haz_type=impfset_modifier.haz_type) + elif isinstance(impfset_modifier.impf_ids, list): + ids_to_change = impfset_modifier.impf_ids + elif isinstance(impfset_modifier.impf_ids, (str, int)): + ids_to_change = [impfset_modifier.impf_ids] + else: + raise ValueError( + f"Impact function ids to changes are invalid: {impfset_modifier.impf_ids}" + ) + + funcs = changed_impfset.get_func(haz_type=impfset_modifier.haz_type) + funcs = [funcs] if isinstance(funcs, ImpactFunc) else funcs + + for impf in funcs: + # Apply Intensity Mod + if impf.id in ids_to_change: + mult, add = ( + impfset_modifier.impf_int_mult, + impfset_modifier.impf_int_add, + ) + impf.intensity = impf.intensity * mult + add + + mult, add = ( + impfset_modifier.impf_mdd_mult, + impfset_modifier.impf_mdd_add, + ) + impf.mdd = impf.mdd * mult + add + + mult, add = ( + impfset_modifier.impf_paa_mult, + impfset_modifier.impf_paa_add, + ) + impf.paa = impf.paa * mult + add + + return changed_impfset + + return impfset_change + + +def change_impfset(new_impfsets: ImpactFuncSet) -> ImpfsetChange: + """Returns a function that swaps the impact function set with the given one.""" + + def impfset_change(_: ImpactFuncSet) -> ImpactFuncSet: + return new_impfsets + + return impfset_change + + +def helper_exposure(exposures_modifier: ExposuresModifierConfig) -> ExposuresChange: + """Returns a function that reassigns impact function IDs and zeros out specific values.""" + + def exposures_change(exposures: Exposures, **_kwargs) -> Exposures: + changed_exposures = ( + exposures + if exposures_modifier.new_exposures_path is None + else Exposures.from_hdf5(exposures_modifier.new_exposures_path) + ) + gdf = cast(pd.DataFrame, changed_exposures.gdf) + if exposures_modifier.reassign_impf_id is not None: + for haz_type, mapping in exposures_modifier.reassign_impf_id.items(): + gdf[f"impf_{haz_type}"] = gdf[f"impf_{haz_type}"].replace(mapping) + + if exposures_modifier.set_to_zero is not None: + gdf.loc[exposures_modifier.set_to_zero, "value"] = 0 + + return changed_exposures + + return exposures_change diff --git a/climada/entity/_legacy_measures/measure_config.py b/climada/entity/_legacy_measures/measure_config.py new file mode 100644 index 0000000000..f742851690 --- /dev/null +++ b/climada/entity/_legacy_measures/measure_config.py @@ -0,0 +1,318 @@ +""" +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 configuration dataclasses for Measure reading and writing. +""" + +from __future__ import annotations + +import dataclasses +import logging +from abc import ABC +from dataclasses import asdict, dataclass, field, fields +from datetime import datetime +from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union + +import pandas as pd + +from climada.util.string_parsers import parse_color, parse_mapping_string, parse_range + +if TYPE_CHECKING: + from climada.entity.measures.base import Measure + from climada.entity.measures.cost_income import CostIncome + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class _ModifierConfig(ABC): + def to_dict(self): + # 1. Get the current values as a dict + current_data = asdict(self) + + # 2. Identify fields where the current value differs from the default + non_default_data = {} + for f in fields(self): + current_value = getattr(self, f.name) + + # Logic to get the default value (handling both default and default_factory) + default_value = f.default + if ( + f.default_factory is not field().default_factory + ): # Check if factory exists + default_value = f.default_factory() + + if current_value != default_value: + non_default_data[f.name] = current_data[f.name] + + non_default_data.pop("haz_type", None) + return non_default_data + + @classmethod + def from_dict(cls, d: dict): + filtered = cls._filter_dict_to_fields(d) + return cls(**filtered) + + @classmethod + def _filter_dict_to_fields(cls, d: dict): + """Filter out values that do not match the dataclass fields.""" + filtered = dict( + filter(lambda k: k[0] in [f.name for f in fields(cls)], d.items()) + ) + return filtered + + def _filter_out_default_fields(self): + non_defaults = {} + defaults = {} + for f in fields(self): + val = getattr(self, f.name) + default = f.default + if f.default_factory is not field().default_factory: + default = f.default_factory() + + if val != default: + non_defaults[f.name] = val + else: + defaults[f.name] = val + return non_defaults, defaults + + def __repr__(self) -> str: + non_defaults, defaults = self._filter_out_default_fields() + ndf_fields_str = ( + "\n\t\t\t".join(f"{k}={v!r}" for k, v in non_defaults.items()) + if non_defaults + else None + ) + fields_str = ( + "\n\t\t\t".join(f"{k}={v!r}" for k, v in defaults.items()) + if defaults + else None + ) + fields = ( + "(" "\n\t\tNon default fields:" f"\n\t\t\t{ndf_fields_str}" + if ndf_fields_str + else "()" + ) + return f"{self.__class__.__name__}{fields}" + + +@dataclass(repr=False) +class ImpfsetModifierConfig(_ModifierConfig): + """Configuration for impact function modifiers.""" + + haz_type: str + impf_ids: Optional[Union[int, str, list[Union[int, str]]]] = None + impf_mdd_mult: float = 1.0 + impf_mdd_add: float = 0.0 + impf_paa_mult: float = 1.0 + impf_paa_add: float = 0.0 + impf_int_mult: float = 1.0 + impf_int_add: float = 0.0 + new_impfset_path: Optional[str] = None + """Excel filepath for new impfset.""" + + def __post_init__(self): + if self.new_impfset_path is not None and any( + [ + self.impf_mdd_add, + self.impf_mdd_mult, + self.impf_paa_add, + self.impf_paa_mult, + self.impf_int_add, + self.impf_int_mult, + ] + ): + LOGGER.warning( + "Both new impfset object and impfset modifiers are provided, " + "modifiers will be applied after changing the impfset." + ) + + +@dataclass(repr=False) +class HazardModifierConfig(_ModifierConfig): + """Configuration for impact function modifiers.""" + + haz_type: str + haz_int_mult: Optional[float] = 1.0 + haz_int_add: Optional[float] = 0.0 + new_hazard_path: Optional[str] = None + """HDF5 filepath for new hazard.""" + impact_rp_cutoff: Optional[float] = None + + def __post_init__(self): + if self.new_hazard_path is not None and any( + [self.haz_int_mult, self.haz_int_add, self.impact_rp_cutoff] + ): + LOGGER.warning( + "Both new hazard object and hazard modifiers are provided, " + "modifiers will be applied after changing the hazard." + ) + + +@dataclass(repr=False) +class ExposuresModifierConfig(_ModifierConfig): + """Configuration for impact function modifiers.""" + + reassign_impf_id: Optional[Dict[str, Dict[int | str, int | str]]] = None + set_to_zero: Optional[list[int]] = None + new_exposures_path: Optional[str] = None + """HDF5 filepath for new exposure""" + + def __post_init__(self): + if self.new_exposures_path is not None and any( + [self.reassign_impf_id, self.set_to_zero] + ): + LOGGER.warning( + "Both new exposures object and exposures modifiers are provided, " + "modifiers will be applied after changing the exposures." + ) + + +@dataclass(repr=False) +class CostIncomeConfig(_ModifierConfig): + """Serializable configuration for CostIncome.""" + + mkt_price_year: Optional[int] = field(default_factory=lambda: datetime.today().year) + init_cost: float = 0.0 + periodic_cost: float = 0.0 + periodic_income: float = 0.0 + cost_yearly_growth_rate: float = 0.0 + income_yearly_growth_rate: float = 0.0 + freq: str = "Y" + custom_cash_flows: Optional[list[dict]] = None + + def to_cost_income(self) -> CostIncome: + df = None + if self.custom_cash_flows is not None: + df = pd.DataFrame(self.custom_cash_flows) + df["date"] = pd.to_datetime(df["date"]) + return CostIncome( + mkt_price_year=self.mkt_price_year, + init_cost=self.init_cost, + periodic_cost=self.periodic_cost, + periodic_income=self.periodic_income, + cost_yearly_growth_rate=self.cost_yearly_growth_rate, + income_yearly_growth_rate=self.income_yearly_growth_rate, + custom_cash_flows=df, + freq=self.freq, + ) + + @classmethod + def from_cost_income(cls, ci: CostIncome) -> "CostIncomeConfig": + """Round-trip from a live CostIncome object.""" + custom = None + if ci.custom_cash_flows is not None: + custom = ( + ci.custom_cash_flows.reset_index() + .rename(columns={"index": "date"}) + .assign(date=lambda df: df["date"].dt.strftime("%Y-%m-%d")) + .to_dict(orient="records") + ) + return cls( + mkt_price_year=ci.mkt_price_year.year, # datetime → int + init_cost=abs(ci.init_cost), # stored negative → positive + periodic_cost=abs(ci.periodic_cost), + periodic_income=ci.periodic_income, + cost_yearly_growth_rate=ci.cost_growth_rate, + income_yearly_growth_rate=ci.income_growth_rate, + freq=ci.freq, + custom_cash_flows=custom, + ) + + +@dataclass(repr=False) +class MeasureConfig(_ModifierConfig): + name: str + haz_type: str + impfset_modifier: ImpfsetModifierConfig + hazard_modifier: HazardModifierConfig + exposures_modifier: ExposuresModifierConfig + cost_income: CostIncomeConfig + implementation_duration: Optional[str] = None + color_rgb: Optional[Tuple[float, float, float]] = None + + def __repr__(self) -> str: + fields_str = "\n\t".join(f"{k}={v!r}" for k, v in self.__dict__.items()) + return f"{self.__class__.__name__}(\n\t{fields_str})" + + def to_dict(self) -> dict: + return { + "name": self.name, + "haz_type": self.haz_type, + **self.impfset_modifier.to_dict(), + **self.hazard_modifier.to_dict(), + **self.exposures_modifier.to_dict(), + **self.cost_income.to_dict(), + "implementation_duration": self.implementation_duration, + "color_rgb": list(self.color_rgb) if self.color_rgb is not None else None, + } + + @classmethod + def from_dict(cls, d: dict) -> "MeasureConfig": + color = d.get("color_rgb") + return cls( + name=d["name"], + haz_type=d["haz_type"], + impfset_modifier=ImpfsetModifierConfig.from_dict(d), + hazard_modifier=HazardModifierConfig.from_dict(d), + exposures_modifier=ExposuresModifierConfig.from_dict(d), + cost_income=CostIncomeConfig.from_dict(d), + implementation_duration=d.get("implementation_duration"), + color_rgb=( + tuple(color) if color is not None and not pd.isna(color) else None + ), + ) + + def to_yaml(self, path: str) -> None: + import yaml + + with open(path, "w") as f: + yaml.dump( + {"measures": [self.to_dict()]}, + f, + default_flow_style=False, + sort_keys=False, + ) + + @classmethod + def from_yaml(cls, path: str) -> "MeasureConfig": + import yaml + + with open(path) as f: + return cls.from_dict(yaml.safe_load(f)["measures"][0]) + + @classmethod + def from_row( + cls, row: pd.Series, haz_type: Optional[str] = None + ) -> "MeasureConfig": + """Build a MeasureConfig from a legacy Excel row.""" + row_dict = row.to_dict() + return cls.from_dict(row_dict) + + +def _serialize_modifier_dict(d: dict) -> dict: + """Stringify keys, convert tuples to lists for JSON.""" + return {str(k): list(v) for k, v in d.items()} + + +def _deserialize_modifier_dict(d: dict) -> dict: + """Restore int keys where possible, values back to tuples.""" + return { + (int(k) if isinstance(k, str) and k.isdigit() else k): tuple(v) + for k, v in d.items() + } diff --git a/climada/entity/measures/measure_set.py b/climada/entity/_legacy_measures/measure_set.py similarity index 100% rename from climada/entity/measures/measure_set.py rename to climada/entity/_legacy_measures/measure_set.py diff --git a/climada/entity/measures/test/__init__.py b/climada/entity/_legacy_measures/test/__init__.py similarity index 100% rename from climada/entity/measures/test/__init__.py rename to climada/entity/_legacy_measures/test/__init__.py diff --git a/climada/entity/measures/test/data/.gitignore b/climada/entity/_legacy_measures/test/data/.gitignore similarity index 100% rename from climada/entity/measures/test/data/.gitignore rename to climada/entity/_legacy_measures/test/data/.gitignore diff --git a/climada/entity/measures/test/test_base.py b/climada/entity/_legacy_measures/test/test_base.py similarity index 100% rename from climada/entity/measures/test/test_base.py rename to climada/entity/_legacy_measures/test/test_base.py diff --git a/climada/entity/measures/test/test_meas_set.py b/climada/entity/_legacy_measures/test/test_meas_set.py similarity index 100% rename from climada/entity/measures/test/test_meas_set.py rename to climada/entity/_legacy_measures/test/test_meas_set.py diff --git a/climada/entity/_legacy_measures/types.py b/climada/entity/_legacy_measures/types.py new file mode 100644 index 0000000000..1ad90986b1 --- /dev/null +++ b/climada/entity/_legacy_measures/types.py @@ -0,0 +1,10 @@ +from collections.abc import Callable +from typing import Concatenate + +from climada.entity.exposures.base import Exposures +from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet +from climada.hazard.base import Hazard + +HazardChange = Callable[Concatenate[Hazard, ...], Hazard] +ImpfsetChange = Callable[Concatenate[ImpactFuncSet, ...], ImpactFuncSet] +ExposuresChange = Callable[Concatenate[Exposures, ...], Exposures] diff --git a/climada/entity/entity_def.py b/climada/entity/entity_def.py index d58af9efed..c1bc3b2550 100755 --- a/climada/entity/entity_def.py +++ b/climada/entity/entity_def.py @@ -26,10 +26,10 @@ import pandas as pd +from climada.entity._legacy_measures.measure_set import Measure, MeasureSet from climada.entity.disc_rates.base import DiscRates from climada.entity.exposures.base import Exposures from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet -from climada.entity.measures.measure_set import MeasureSet LOGGER = logging.getLogger(__name__) diff --git a/climada/entity/test/test_entity.py b/climada/entity/test/test_entity.py index 7805a24e70..4a88fc531a 100644 --- a/climada/entity/test/test_entity.py +++ b/climada/entity/test/test_entity.py @@ -24,11 +24,11 @@ import numpy as np from climada import CONFIG +from climada.entity._legacy_measures.measure_set import MeasureSet from climada.entity.disc_rates.base import DiscRates from climada.entity.entity_def import Entity from climada.entity.exposures.base import Exposures from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet -from climada.entity.measures.measure_set import MeasureSet from climada.util.constants import ENT_TEMPLATE_XLS ENT_TEST_MAT = CONFIG.exposures.test_data.dir().joinpath("demo_today.mat") diff --git a/climada/trajectories/snapshot.py b/climada/trajectories/snapshot.py index 1d5f778135..dbf7f1bbd6 100644 --- a/climada/trajectories/snapshot.py +++ b/climada/trajectories/snapshot.py @@ -31,9 +31,9 @@ import numpy as np import pandas as pd +from climada.entity._legacy_measures.base import Measure from climada.entity.exposures import Exposures from climada.entity.impact_funcs import ImpactFuncSet -from climada.entity.measures.base import Measure from climada.hazard import Hazard LOGGER = logging.getLogger(__name__) diff --git a/climada/trajectories/test/test_snapshot.py b/climada/trajectories/test/test_snapshot.py index 77830d3b54..0acaf148da 100644 --- a/climada/trajectories/test/test_snapshot.py +++ b/climada/trajectories/test/test_snapshot.py @@ -5,9 +5,9 @@ import pandas as pd import pytest +from climada.entity._legacy_measures.base import Measure from climada.entity.exposures import Exposures from climada.entity.impact_funcs import ImpactFunc, ImpactFuncSet -from climada.entity.measures.base import Measure from climada.hazard import Hazard from climada.trajectories.snapshot import Snapshot from climada.util.constants import EXP_DEMO_H5, HAZ_DEMO_H5 From ceaf479c800c76b3f056d106d3660bc4ae26fdc8 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 2 Apr 2026 11:56:15 +0200 Subject: [PATCH 02/37] Oups these shouldn't be here! --- climada/entity/_legacy_measures/helper.py | 246 -------------- .../entity/_legacy_measures/measure_config.py | 318 ------------------ climada/entity/_legacy_measures/types.py | 10 - 3 files changed, 574 deletions(-) delete mode 100644 climada/entity/_legacy_measures/helper.py delete mode 100644 climada/entity/_legacy_measures/measure_config.py delete mode 100644 climada/entity/_legacy_measures/types.py diff --git a/climada/entity/_legacy_measures/helper.py b/climada/entity/_legacy_measures/helper.py deleted file mode 100644 index 99ba0e8820..0000000000 --- a/climada/entity/_legacy_measures/helper.py +++ /dev/null @@ -1,246 +0,0 @@ -""" -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 Measure class. -""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass, field, fields -from functools import reduce -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, TypeVar, Union, cast - -import numpy as np -import pandas as pd - -from climada.entity.exposures.base import Exposures -from climada.entity.impact_funcs.base import ImpactFunc -from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet -from climada.entity.measures.measure_config import ( - ExposuresModifierConfig, - HazardModifierConfig, - ImpfsetModifierConfig, - MeasureConfig, -) -from climada.hazard.base import Hazard - -if TYPE_CHECKING: - 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 - -LOGGER = logging.getLogger(__name__) - -T = TypeVar("T", Exposures, ImpactFuncSet, Hazard) - - -def identity_function(x: T, **_kwargs: Any) -> T: - return x - - -def composite_fun(*funcs: Callable[..., T]) -> Callable[..., T]: - """ - Composes multiple functions from right to left. - f(g(h(x))) - """ - - def compose(f: Callable[..., T], g: Callable[..., T]) -> Callable[..., T]: - def composed(x: T, **kwargs: Any) -> T: - return f(g(x, **kwargs), **kwargs) - - return composed - - return reduce(compose, funcs, identity_function) - - -def replace_hazard(new_hazard: Hazard) -> HazardChange: - """Returns a function that replaces the hazard with given new one.""" - - def hazard_change(_: Hazard) -> Hazard: - return new_hazard - - return hazard_change - - -def impact_intensity_rp_cutoff_helper( - cut_off_rp: float, -) -> HazardChange: - """Helper to generate a function removing events from a hazard for which - impacts do not exceed the impacts of a given return period. - - This helper returns a function to be applied on a hazard. - The function returned has to run an impact computation to find out which - event to remove from the hazard. - As such it has the following signature: - - ```f(hazard: Hazard, # The hazard to apply on - exposures: Exposures, # The exposure for the impact computation - impfset: ImpactFuncSet, # The impfset for the impact computation - base_hazard: Hazard, # The hazard for the impact computation - exposures_region_id: Optional[list[int]] = None, # Region id to filter to - ) -> Hazard - ``` - - Identifies events exceeding a return period and returns the hazard intensity - matrix with those event intensities zeroed out. - """ - from climada.engine.impact_calc import ImpactCalc - - def hazard_change( - hazard: Hazard, - base_exposures: Exposures, - base_impfset: ImpactFuncSet, - base_hazard: Hazard, - exposures_region_id: Optional[list[int]] = None, - ) -> Hazard: - exp_imp = base_exposures - if exposures_region_id: - # Narrowing the type for the LSP via boolean indexing - in_reg = base_exposures.gdf["region_id"].isin(exposures_region_id) - exp_imp = Exposures(base_exposures.gdf[in_reg], crs=base_exposures.crs) - - imp = ImpactCalc(exp_imp, base_impfset, base_hazard).impact(save_mat=False) - - # Calculate exceedance frequencies - sort_idxs = np.argsort(imp.at_event)[::-1] - exceed_freq = np.cumsum(imp.frequency[sort_idxs]) - events_below_cutoff = sort_idxs[exceed_freq <= cut_off_rp] - - # Modify sparse data structure - intensity_modified = base_hazard.intensity.copy() - for event in events_below_cutoff: - start, end = ( - intensity_modified.indptr[event], - intensity_modified.indptr[event + 1], - ) - intensity_modified.data[start:end] = 0 - - hazard.intensity = intensity_modified - return hazard - - return hazard_change - - -def helper_hazard(hazard_modifier: HazardModifierConfig) -> HazardChange: - """Returns a function that scales and shifts hazard intensity.""" - - def hazard_change(hazard: Hazard, **_kwargs) -> Hazard: - changed_hazard = ( - Hazard.from_hdf5(hazard_modifier.new_hazard_path) - if hazard_modifier.new_hazard_path is not None - else hazard - ) - data = cast(np.ndarray, changed_hazard.intensity.data) - data *= hazard_modifier.haz_int_mult - data += hazard_modifier.haz_int_add - data[data < 0] = 0 - changed_hazard.intensity.eliminate_zeros() - return changed_hazard - - if hazard_modifier.impact_rp_cutoff is not None: - hazard_change = composite_fun( - impact_intensity_rp_cutoff_helper(hazard_modifier.impact_rp_cutoff), - hazard_change, - ) - - return hazard_change - - -def helper_impfset(impfset_modifier: ImpfsetModifierConfig) -> ImpfsetChange: - """Returns a function that modifies impact functions (mdd, paa, intensity) by ID.""" - - def impfset_change(impfset: ImpactFuncSet, **_kwargs) -> ImpactFuncSet: - changed_impfset = ( - impfset.from_excel(impfset_modifier.new_impfset_path) - if impfset_modifier.new_impfset_path is not None - else impfset - ) - if impfset_modifier.impf_ids is None or impfset_modifier.impf_ids == "all": - ids_to_change = impfset.get_ids(haz_type=impfset_modifier.haz_type) - elif isinstance(impfset_modifier.impf_ids, list): - ids_to_change = impfset_modifier.impf_ids - elif isinstance(impfset_modifier.impf_ids, (str, int)): - ids_to_change = [impfset_modifier.impf_ids] - else: - raise ValueError( - f"Impact function ids to changes are invalid: {impfset_modifier.impf_ids}" - ) - - funcs = changed_impfset.get_func(haz_type=impfset_modifier.haz_type) - funcs = [funcs] if isinstance(funcs, ImpactFunc) else funcs - - for impf in funcs: - # Apply Intensity Mod - if impf.id in ids_to_change: - mult, add = ( - impfset_modifier.impf_int_mult, - impfset_modifier.impf_int_add, - ) - impf.intensity = impf.intensity * mult + add - - mult, add = ( - impfset_modifier.impf_mdd_mult, - impfset_modifier.impf_mdd_add, - ) - impf.mdd = impf.mdd * mult + add - - mult, add = ( - impfset_modifier.impf_paa_mult, - impfset_modifier.impf_paa_add, - ) - impf.paa = impf.paa * mult + add - - return changed_impfset - - return impfset_change - - -def change_impfset(new_impfsets: ImpactFuncSet) -> ImpfsetChange: - """Returns a function that swaps the impact function set with the given one.""" - - def impfset_change(_: ImpactFuncSet) -> ImpactFuncSet: - return new_impfsets - - return impfset_change - - -def helper_exposure(exposures_modifier: ExposuresModifierConfig) -> ExposuresChange: - """Returns a function that reassigns impact function IDs and zeros out specific values.""" - - def exposures_change(exposures: Exposures, **_kwargs) -> Exposures: - changed_exposures = ( - exposures - if exposures_modifier.new_exposures_path is None - else Exposures.from_hdf5(exposures_modifier.new_exposures_path) - ) - gdf = cast(pd.DataFrame, changed_exposures.gdf) - if exposures_modifier.reassign_impf_id is not None: - for haz_type, mapping in exposures_modifier.reassign_impf_id.items(): - gdf[f"impf_{haz_type}"] = gdf[f"impf_{haz_type}"].replace(mapping) - - if exposures_modifier.set_to_zero is not None: - gdf.loc[exposures_modifier.set_to_zero, "value"] = 0 - - return changed_exposures - - return exposures_change diff --git a/climada/entity/_legacy_measures/measure_config.py b/climada/entity/_legacy_measures/measure_config.py deleted file mode 100644 index f742851690..0000000000 --- a/climada/entity/_legacy_measures/measure_config.py +++ /dev/null @@ -1,318 +0,0 @@ -""" -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 configuration dataclasses for Measure reading and writing. -""" - -from __future__ import annotations - -import dataclasses -import logging -from abc import ABC -from dataclasses import asdict, dataclass, field, fields -from datetime import datetime -from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union - -import pandas as pd - -from climada.util.string_parsers import parse_color, parse_mapping_string, parse_range - -if TYPE_CHECKING: - from climada.entity.measures.base import Measure - from climada.entity.measures.cost_income import CostIncome - -LOGGER = logging.getLogger(__name__) - - -@dataclass -class _ModifierConfig(ABC): - def to_dict(self): - # 1. Get the current values as a dict - current_data = asdict(self) - - # 2. Identify fields where the current value differs from the default - non_default_data = {} - for f in fields(self): - current_value = getattr(self, f.name) - - # Logic to get the default value (handling both default and default_factory) - default_value = f.default - if ( - f.default_factory is not field().default_factory - ): # Check if factory exists - default_value = f.default_factory() - - if current_value != default_value: - non_default_data[f.name] = current_data[f.name] - - non_default_data.pop("haz_type", None) - return non_default_data - - @classmethod - def from_dict(cls, d: dict): - filtered = cls._filter_dict_to_fields(d) - return cls(**filtered) - - @classmethod - def _filter_dict_to_fields(cls, d: dict): - """Filter out values that do not match the dataclass fields.""" - filtered = dict( - filter(lambda k: k[0] in [f.name for f in fields(cls)], d.items()) - ) - return filtered - - def _filter_out_default_fields(self): - non_defaults = {} - defaults = {} - for f in fields(self): - val = getattr(self, f.name) - default = f.default - if f.default_factory is not field().default_factory: - default = f.default_factory() - - if val != default: - non_defaults[f.name] = val - else: - defaults[f.name] = val - return non_defaults, defaults - - def __repr__(self) -> str: - non_defaults, defaults = self._filter_out_default_fields() - ndf_fields_str = ( - "\n\t\t\t".join(f"{k}={v!r}" for k, v in non_defaults.items()) - if non_defaults - else None - ) - fields_str = ( - "\n\t\t\t".join(f"{k}={v!r}" for k, v in defaults.items()) - if defaults - else None - ) - fields = ( - "(" "\n\t\tNon default fields:" f"\n\t\t\t{ndf_fields_str}" - if ndf_fields_str - else "()" - ) - return f"{self.__class__.__name__}{fields}" - - -@dataclass(repr=False) -class ImpfsetModifierConfig(_ModifierConfig): - """Configuration for impact function modifiers.""" - - haz_type: str - impf_ids: Optional[Union[int, str, list[Union[int, str]]]] = None - impf_mdd_mult: float = 1.0 - impf_mdd_add: float = 0.0 - impf_paa_mult: float = 1.0 - impf_paa_add: float = 0.0 - impf_int_mult: float = 1.0 - impf_int_add: float = 0.0 - new_impfset_path: Optional[str] = None - """Excel filepath for new impfset.""" - - def __post_init__(self): - if self.new_impfset_path is not None and any( - [ - self.impf_mdd_add, - self.impf_mdd_mult, - self.impf_paa_add, - self.impf_paa_mult, - self.impf_int_add, - self.impf_int_mult, - ] - ): - LOGGER.warning( - "Both new impfset object and impfset modifiers are provided, " - "modifiers will be applied after changing the impfset." - ) - - -@dataclass(repr=False) -class HazardModifierConfig(_ModifierConfig): - """Configuration for impact function modifiers.""" - - haz_type: str - haz_int_mult: Optional[float] = 1.0 - haz_int_add: Optional[float] = 0.0 - new_hazard_path: Optional[str] = None - """HDF5 filepath for new hazard.""" - impact_rp_cutoff: Optional[float] = None - - def __post_init__(self): - if self.new_hazard_path is not None and any( - [self.haz_int_mult, self.haz_int_add, self.impact_rp_cutoff] - ): - LOGGER.warning( - "Both new hazard object and hazard modifiers are provided, " - "modifiers will be applied after changing the hazard." - ) - - -@dataclass(repr=False) -class ExposuresModifierConfig(_ModifierConfig): - """Configuration for impact function modifiers.""" - - reassign_impf_id: Optional[Dict[str, Dict[int | str, int | str]]] = None - set_to_zero: Optional[list[int]] = None - new_exposures_path: Optional[str] = None - """HDF5 filepath for new exposure""" - - def __post_init__(self): - if self.new_exposures_path is not None and any( - [self.reassign_impf_id, self.set_to_zero] - ): - LOGGER.warning( - "Both new exposures object and exposures modifiers are provided, " - "modifiers will be applied after changing the exposures." - ) - - -@dataclass(repr=False) -class CostIncomeConfig(_ModifierConfig): - """Serializable configuration for CostIncome.""" - - mkt_price_year: Optional[int] = field(default_factory=lambda: datetime.today().year) - init_cost: float = 0.0 - periodic_cost: float = 0.0 - periodic_income: float = 0.0 - cost_yearly_growth_rate: float = 0.0 - income_yearly_growth_rate: float = 0.0 - freq: str = "Y" - custom_cash_flows: Optional[list[dict]] = None - - def to_cost_income(self) -> CostIncome: - df = None - if self.custom_cash_flows is not None: - df = pd.DataFrame(self.custom_cash_flows) - df["date"] = pd.to_datetime(df["date"]) - return CostIncome( - mkt_price_year=self.mkt_price_year, - init_cost=self.init_cost, - periodic_cost=self.periodic_cost, - periodic_income=self.periodic_income, - cost_yearly_growth_rate=self.cost_yearly_growth_rate, - income_yearly_growth_rate=self.income_yearly_growth_rate, - custom_cash_flows=df, - freq=self.freq, - ) - - @classmethod - def from_cost_income(cls, ci: CostIncome) -> "CostIncomeConfig": - """Round-trip from a live CostIncome object.""" - custom = None - if ci.custom_cash_flows is not None: - custom = ( - ci.custom_cash_flows.reset_index() - .rename(columns={"index": "date"}) - .assign(date=lambda df: df["date"].dt.strftime("%Y-%m-%d")) - .to_dict(orient="records") - ) - return cls( - mkt_price_year=ci.mkt_price_year.year, # datetime → int - init_cost=abs(ci.init_cost), # stored negative → positive - periodic_cost=abs(ci.periodic_cost), - periodic_income=ci.periodic_income, - cost_yearly_growth_rate=ci.cost_growth_rate, - income_yearly_growth_rate=ci.income_growth_rate, - freq=ci.freq, - custom_cash_flows=custom, - ) - - -@dataclass(repr=False) -class MeasureConfig(_ModifierConfig): - name: str - haz_type: str - impfset_modifier: ImpfsetModifierConfig - hazard_modifier: HazardModifierConfig - exposures_modifier: ExposuresModifierConfig - cost_income: CostIncomeConfig - implementation_duration: Optional[str] = None - color_rgb: Optional[Tuple[float, float, float]] = None - - def __repr__(self) -> str: - fields_str = "\n\t".join(f"{k}={v!r}" for k, v in self.__dict__.items()) - return f"{self.__class__.__name__}(\n\t{fields_str})" - - def to_dict(self) -> dict: - return { - "name": self.name, - "haz_type": self.haz_type, - **self.impfset_modifier.to_dict(), - **self.hazard_modifier.to_dict(), - **self.exposures_modifier.to_dict(), - **self.cost_income.to_dict(), - "implementation_duration": self.implementation_duration, - "color_rgb": list(self.color_rgb) if self.color_rgb is not None else None, - } - - @classmethod - def from_dict(cls, d: dict) -> "MeasureConfig": - color = d.get("color_rgb") - return cls( - name=d["name"], - haz_type=d["haz_type"], - impfset_modifier=ImpfsetModifierConfig.from_dict(d), - hazard_modifier=HazardModifierConfig.from_dict(d), - exposures_modifier=ExposuresModifierConfig.from_dict(d), - cost_income=CostIncomeConfig.from_dict(d), - implementation_duration=d.get("implementation_duration"), - color_rgb=( - tuple(color) if color is not None and not pd.isna(color) else None - ), - ) - - def to_yaml(self, path: str) -> None: - import yaml - - with open(path, "w") as f: - yaml.dump( - {"measures": [self.to_dict()]}, - f, - default_flow_style=False, - sort_keys=False, - ) - - @classmethod - def from_yaml(cls, path: str) -> "MeasureConfig": - import yaml - - with open(path) as f: - return cls.from_dict(yaml.safe_load(f)["measures"][0]) - - @classmethod - def from_row( - cls, row: pd.Series, haz_type: Optional[str] = None - ) -> "MeasureConfig": - """Build a MeasureConfig from a legacy Excel row.""" - row_dict = row.to_dict() - return cls.from_dict(row_dict) - - -def _serialize_modifier_dict(d: dict) -> dict: - """Stringify keys, convert tuples to lists for JSON.""" - return {str(k): list(v) for k, v in d.items()} - - -def _deserialize_modifier_dict(d: dict) -> dict: - """Restore int keys where possible, values back to tuples.""" - return { - (int(k) if isinstance(k, str) and k.isdigit() else k): tuple(v) - for k, v in d.items() - } diff --git a/climada/entity/_legacy_measures/types.py b/climada/entity/_legacy_measures/types.py deleted file mode 100644 index 1ad90986b1..0000000000 --- a/climada/entity/_legacy_measures/types.py +++ /dev/null @@ -1,10 +0,0 @@ -from collections.abc import Callable -from typing import Concatenate - -from climada.entity.exposures.base import Exposures -from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet -from climada.hazard.base import Hazard - -HazardChange = Callable[Concatenate[Hazard, ...], Hazard] -ImpfsetChange = Callable[Concatenate[ImpactFuncSet, ...], ImpactFuncSet] -ExposuresChange = Callable[Concatenate[Exposures, ...], Exposures] From c176e80090c62bb2bb17ad408e9305c2c3abc7e1 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 2 Apr 2026 12:04:42 +0200 Subject: [PATCH 03/37] Forgotten places --- climada/engine/unsequa/input_var.py | 4 ++-- climada/entity/_legacy_measures/measure_set.py | 3 ++- climada/entity/_legacy_measures/test/test_base.py | 4 ++-- climada/entity/_legacy_measures/test/test_meas_set.py | 10 ++++++---- climada/trajectories/calc_risk_metrics.py | 2 +- climada/trajectories/test/test_calc_risk_metrics.py | 2 +- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/climada/engine/unsequa/input_var.py b/climada/engine/unsequa/input_var.py index 76e63d766e..9abdd8d3fa 100644 --- a/climada/engine/unsequa/input_var.py +++ b/climada/engine/unsequa/input_var.py @@ -518,7 +518,7 @@ def ent( exp_list : [climada.entity.exposures.base.Exposure] The list of base exposure. Can be one or many to uniformly sample from. - meas_set : climada.entity.measures.measure_set.MeasureSet + meas_set : climada.entity._legacy_measures.measure_set.MeasureSet The base measures. haz_id_dict : dict Dictionary of the impact functions affected by uncertainty. @@ -660,7 +660,7 @@ def entfut( exp_list : [climada.entity.exposures.base.Exposure] The list of base exposure. Can be one or many to uniformly sample from. - meas_set : climada.entity.measures.measure_set.MeasureSet + meas_set : climada.entity._legacy_measures.measure_set.MeasureSet The base measures. haz_id_dict : dict Dictionary of the impact functions affected by uncertainty. diff --git a/climada/entity/_legacy_measures/measure_set.py b/climada/entity/_legacy_measures/measure_set.py index 90a2bb43c2..228788ba15 100755 --- a/climada/entity/_legacy_measures/measure_set.py +++ b/climada/entity/_legacy_measures/measure_set.py @@ -32,7 +32,8 @@ from matplotlib import colormaps as cm import climada.util.hdf5_handler as u_hdf5 -from climada.entity.measures.base import Measure + +from .base import Measure LOGGER = logging.getLogger(__name__) diff --git a/climada/entity/_legacy_measures/test/test_base.py b/climada/entity/_legacy_measures/test/test_base.py index 6f76eb7373..430ab7d44b 100644 --- a/climada/entity/_legacy_measures/test/test_base.py +++ b/climada/entity/_legacy_measures/test/test_base.py @@ -28,12 +28,12 @@ import climada.entity.exposures.test as exposures_test import climada.util.coordinates as u_coord from climada import CONFIG +from climada.entity._legacy_measures.base import IMPF_ID_FACT, Measure +from climada.entity._legacy_measures.measure_set import MeasureSet from climada.entity.entity_def import Entity from climada.entity.exposures.base import Exposures from climada.entity.impact_funcs.base import ImpactFunc from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet -from climada.entity.measures.base import IMPF_ID_FACT, Measure -from climada.entity.measures.measure_set import MeasureSet from climada.hazard.base import Hazard from climada.test import get_test_file from climada.util.constants import HAZ_DEMO_H5 diff --git a/climada/entity/_legacy_measures/test/test_meas_set.py b/climada/entity/_legacy_measures/test/test_meas_set.py index a2cbdc3f16..868510fbe8 100644 --- a/climada/entity/_legacy_measures/test/test_meas_set.py +++ b/climada/entity/_legacy_measures/test/test_meas_set.py @@ -24,8 +24,8 @@ import numpy as np from climada import CONFIG -from climada.entity.measures.base import Measure -from climada.entity.measures.measure_set import MeasureSet +from climada.entity._legacy_measures.base import Measure +from climada.entity._legacy_measures.measure_set import MeasureSet from climada.util.constants import ENT_DEMO_TODAY, ENT_TEMPLATE_XLS DATA_DIR = CONFIG.measures.test_data.dir() @@ -58,7 +58,7 @@ def test_add_wrong_error(self): """Test error is raised when wrong ImpactFunc provided.""" meas = MeasureSet() with self.assertLogs( - "climada.entity.measures.measure_set", level="WARNING" + "climada.entity._legacy_measures.measure_set", level="WARNING" ) as cm: meas.append(Measure()) self.assertIn("Input Measure's hazard type not set.", cm.output[0]) @@ -76,7 +76,9 @@ def test_remove_measure_pass(self): def test_remove_wrong_error(self): """Test error is raised when invalid inputs.""" meas = MeasureSet(measure_list=[Measure(name="Mangrove", haz_type="FL")]) - with self.assertLogs("climada.entity.measures.measure_set", level="INFO") as cm: + with self.assertLogs( + "climada.entity._legacy_measures.measure_set", level="INFO" + ) as cm: meas.remove_measure(name="Seawall") self.assertIn("No Measure with name Seawall.", cm.output[0]) diff --git a/climada/trajectories/calc_risk_metrics.py b/climada/trajectories/calc_risk_metrics.py index 902fffc2ba..8e5ae8b150 100644 --- a/climada/trajectories/calc_risk_metrics.py +++ b/climada/trajectories/calc_risk_metrics.py @@ -32,7 +32,7 @@ import pandas as pd from climada.engine.impact import Impact -from climada.entity.measures.base import Measure +from climada.entity._legacy_measures.base import Measure from climada.trajectories.constants import ( AAI_METRIC_NAME, COORD_ID_COL_NAME, diff --git a/climada/trajectories/test/test_calc_risk_metrics.py b/climada/trajectories/test/test_calc_risk_metrics.py index 9c75f78fb4..6fc530d065 100644 --- a/climada/trajectories/test/test_calc_risk_metrics.py +++ b/climada/trajectories/test/test_calc_risk_metrics.py @@ -26,10 +26,10 @@ import pandas as pd import pytest +from climada.entity._legacy_measures.base import Measure from climada.entity.exposures import Exposures from climada.entity.impact_funcs import ImpactFuncSet from climada.entity.impact_funcs.trop_cyclone import ImpfTropCyclone -from climada.entity.measures.base import Measure from climada.hazard import Hazard from climada.trajectories.calc_risk_metrics import CalcRiskMetricsPoints from climada.trajectories.constants import ( From 4010812fd494c00ec5818c70b27839eee1bd703a Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 7 Apr 2026 09:34:52 +0200 Subject: [PATCH 04/37] Adds cost income --- climada/entity/measures/cost_income.py | 415 +++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 climada/entity/measures/cost_income.py diff --git a/climada/entity/measures/cost_income.py b/climada/entity/measures/cost_income.py new file mode 100644 index 0000000000..564bbd251f --- /dev/null +++ b/climada/entity/measures/cost_income.py @@ -0,0 +1,415 @@ +""" +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 the Cash Flows class. +""" + +# Define default discount rates +# DISC_RATES = DiscRates(years=np.arange(1900, 2100), rates=np.ones(np.arange(1900, 2100).size)) + +from datetime import datetime +from typing import Any, List, Optional, Tuple + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from climada.entity.measures.measure_config import CostIncomeConfig + + +class CostIncome: + """ + Manages costs and incomes related to a measure over time. + + Income are stored a positive numbers and costs as negative + ones. + + Attributes + ---------- + freq : str + Frequency of the cash flows (e.g., 'Y', 'M', 'D'). + mkt_price_year : datetime + The reference year for market prices. + cost_growth_rate : float + Yearly growth rate of costs. + init_cost : float + Initial implementation cost (stored as negative). + periodic_cost : float + Recurring cost per period (stored as negative). + periodic_income : float + Recurring income per period. + income_growth_rate : float + Yearly growth rate of income. + custom_cash_flows : pd.DataFrame, optional + User-defined cash flows indexed by date. + """ + + def __init__( + self, + *, + mkt_price_year: Optional[int] = None, + init_cost: float = 0.0, + periodic_cost: float = 0.0, + periodic_income: float = 0.0, + cost_yearly_growth_rate: float = 0.0, + income_yearly_growth_rate: float = 0.0, + custom_cash_flows: Optional[pd.DataFrame] = None, + freq: str = "Y", + ): + """Initialize CostIncome with parameters.""" + self.freq = freq + self.mkt_price_year = datetime(mkt_price_year or datetime.today().year, 1, 1) + self.cost_growth_rate = cost_yearly_growth_rate + + self.init_cost = -abs(init_cost) + self.periodic_cost = -abs(periodic_cost) + self.periodic_income = abs(periodic_income) + + self.income_growth_rate = income_yearly_growth_rate + + if custom_cash_flows is not None: + self.custom_cash_flows = self._prepare_custom_flows(custom_cash_flows) + else: + self.custom_cash_flows = None + + def _prepare_custom_flows(self, df: pd.DataFrame) -> pd.DataFrame: + """Process and resample custom cash flow dataframe.""" + df = df.copy() + if "cost" in df.columns: + df["cost"] = -df["cost"].abs() + if "date" in df.columns: + df["date"] = pd.to_datetime(df["date"]) + df = df.set_index("date") + return df.resample(self.freq).sum() + + @classmethod + def from_config(cls, config: CostIncomeConfig) -> "CostIncome": + df = None + if config.custom_cash_flows is not None: + df = pd.DataFrame(config.custom_cash_flows) + df["date"] = pd.to_datetime(df["date"]) + return cls( + mkt_price_year=config.mkt_price_year, + init_cost=config.init_cost, + periodic_cost=config.periodic_cost, + periodic_income=config.periodic_income, + cost_yearly_growth_rate=config.cost_yearly_growth_rate, + income_yearly_growth_rate=config.income_yearly_growth_rate, + custom_cash_flows=df, + freq=config.freq, + ) + + @classmethod + def from_dict(cls, d: dict) -> "CostIncome": + return cls.from_config( + CostIncomeConfig( + mkt_price_year=d.get("mkt_price_year"), + init_cost=d.get("init_cost", 0.0), + periodic_cost=d.get("periodic_cost", 0.0), + periodic_income=d.get("periodic_income", 0.0), + cost_yearly_growth_rate=d.get("cost_yearly_growth_rate", 0.0), + income_yearly_growth_rate=d.get("income_yearly_growth_rate", 0.0), + freq=d.get("freq", "Y"), + custom_cash_flows=d.get("custom_cash_flows"), + ) + ) + + @classmethod + def from_yaml(cls, path: str) -> "CostIncome": + import yaml + + with open(path) as f: + return cls.from_dict(yaml.safe_load(f)["cost_income"]) + + @staticmethod + def _freq_to_days(freq: str) -> str: + """ + Convert a frequency string to the equivalent number of days. + + Parameters: + ----------- + freq : str + A frequency string (e.g., 'D' for daily, 'M' for monthly, 'Y' for yearly). + + Returns: + -------- + float + The equivalent number of days for the given frequency string. + """ + try: + # Convert the frequency string to a DateOffset object + offset = pd.tseries.frequencies.to_offset(freq) + + # Calculate the number of days by applying the offset to a base date + base_date = pd.Timestamp("2000-01-01") + end_date = base_date + offset + + # Return the difference in days + return f"{(end_date - base_date).days}d" + except ValueError: + raise ValueError(f"Invalid frequency string: {freq}") + + def _get_width_days(self) -> float: + """Return the number of days in the current frequency.""" + ref = pd.Timestamp("2000-01-01") + offset = pd.tseries.frequencies.to_offset(self.freq) + return float(((ref + offset) - ref).days) + + def _get_custom_val(self, date, column: str) -> float: + if self.custom_cash_flows is not None: + return self.custom_cash_flows.loc[date, column] + + raise AttributeError("No custom cash flow is defined.") + + def _calc_at_date( + self, impl_date: pd.Timestamp, curr_date: pd.Timestamp + ) -> Tuple[float, float, float]: + """Calculate cash flows for a single timestamp.""" + # Calculate growth factor based on years from market price reference + years_passed = (curr_date - self.mkt_price_year).days / 365.0 + + cost_factor = (1 + self.cost_growth_rate) ** years_passed + inc_factor = (1 + self.income_growth_rate) ** years_passed + + if curr_date < impl_date: + cost, income = 0.0, 0.0 + elif curr_date == impl_date: + cost = self.init_cost * cost_factor + income = self.periodic_income * inc_factor + else: + cost = self.periodic_cost * cost_factor + income = self.periodic_income * inc_factor + + c_cost = self._get_custom_val(curr_date, "cost") + c_inc = self._get_custom_val(curr_date, "income") + + total_cost = cost + c_cost + total_inc = income + c_inc + return (total_inc + total_cost), total_cost, total_inc + + def calc_cash_flows( + self, impl_date: Any, start_date: Any, end_date: Any, disc: Optional[Any] = None + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Calculate net cash flows, costs, and incomes over a period. + + Parameters + ---------- + impl_date : Any + Date the measure is implemented. + start_date : Any + Start of the calculation period. + end_date : Any + End of the calculation period. + disc : DiscRates, optional + Discount rates object to calculate Net Present Value. + + Returns + ------- + Tuple[np.ndarray, np.ndarray, np.ndarray] + (net, costs, incomes) + """ + impl_ts = pd.Timestamp(impl_date) + periods = pd.period_range(start=start_date, end=end_date, freq=self.freq) + + results = [self._calc_at_date(impl_ts, p.start_time) for p in periods] + net, costs, incs = map(np.array, zip(*results)) + + if disc is not None: + # Vectorized discounting + years = np.array([p.year for p in periods]) + # Note: Assumes disc.rates is indexed/aligned with disc.years + rate_map = dict(zip(disc.years, disc.rates)) + factors = np.array( + [ + 1 + / (1 + rate_map.get(yr, 0.0)) + ** (yr - pd.Timestamp(start_date).year) + for yr in years + ] + ) + # Handle potential length mismatch if years aren't in disc + valid = np.array([yr in rate_map for yr in years]) + return ( + net[valid] * factors[valid], + costs[valid] * factors[valid], + incs[valid] * factors[valid], + ) + + return net, costs, incs + + def calc_total( + self, impl_date: Any, start_date: Any, end_date: Any, disc: Optional[Any] = None + ) -> Tuple[float, float, float]: + """ + Calculate the total or net present value of the cash flows over a given period + + Parameters: + ----------- + impl_year: int + the year the measure is implemented + + start_year: int + the start year of the period + + end_year: int + the end year of the period + + disc: DiscRates object + the discount rates (required if discounted is True) + + Returns: + -------- + Tuple[float, float, float] + the total net, cost, and income present values over the given period + """ + + net_cash_flows, costs, incomes = self.calc_cash_flows( + impl_date, start_date, end_date, disc=disc + ) + return np.sum(net_cash_flows), np.sum(costs), np.sum(incomes) + + def plot_cash_flows( + self, + impl_date: Any, + start_date: Any, + end_date: Any, + disc: Optional[Any] = None, + to_plot: List[str] = ["net", "cost", "income"], + ): + """ + Plot the cash flows over a given period. + + Parameters: + ----------- + impl_date: datetime + The date the measure is implemented. + start_date: datetime + The start date of the period. + end_date: datetime + The end date of the period. + disc: DiscRates object + The discount rates (optional). + to_plot: list + List of strings indicating which cash flows to plot. Options are 'net', 'cost', 'income'. + """ + # Calculate the cash flows over the given period + net_cash_flows, costs, incomes = self.calc_cash_flows( + impl_date, start_date, end_date, disc=disc + ) + + # Plot the cash flows with colors + date_range = pd.date_range( + start=start_date, end=end_date, freq=self._freq_to_days(self.freq) + ) + width = self._get_width_days() * 0.8 # 80% width for visibility + fig, ax = plt.subplots() + + if "cost" in to_plot: + ax.bar(date_range, costs, color="red", label="Cost", width=width) + if "income" in to_plot: + ax.bar( + date_range, + incomes, + color="blue", + label="Income", + alpha=0.7, + width=width, + ) + if "net" in to_plot: + ax.bar( + date_range, + net_cash_flows, + color="blue", + edgecolor="red", + hatch="//", + label="Net", + alpha=0.5, + width=width, + ) + + ax.xaxis_date() # <---- treat x-ticks as datetime + fig.autofmt_xdate() + plt.xlabel("Date") + plt.ylabel("Cash Flow") + plt.title("Discounted Cash Flows" if disc else "Cash Flows") + plt.legend() + plt.show() + return ax + + def to_dataframe( + self, impl_date: Any, start_date: Any, end_date: Any, disc: Optional[Any] = None + ) -> pd.DataFrame: + """Return cash flows as a formatted DataFrame.""" + net, costs, incs = self.calc_cash_flows( + impl_date, start_date, end_date, disc=disc + ) + periods = pd.period_range(start=start_date, end=end_date, freq=self.freq) + + return pd.DataFrame( + {"date": periods, "net": net, "cost": costs, "income": incs} + ) + + @staticmethod + def comb_cost_income(cost_incomes: list["CostIncome"]) -> "CostIncome": + """Sum costs and incomes from all measures.""" + first_ci = cost_incomes[0] + + if not all( + [ + first_ci.mkt_price_year.year == c.mkt_price_year.year + for c in cost_incomes + ] + ): + raise ValueError( + "Measure cost incomes have different market price years, combination is not possible." + ) + + if not all( + [first_ci.cost_growth_rate == c.cost_growth_rate for c in cost_incomes] + ): + raise ValueError( + "Measure cost incomes have different cost_growth_rate, combination is not possible." + ) + + if not all( + [first_ci.income_growth_rate == c.income_growth_rate for c in cost_incomes] + ): + raise ValueError( + "Measure cost incomes have different income_growth_rate, combination is not possible." + ) + + return CostIncome( + mkt_price_year=first_ci.mkt_price_year.year, + cost_yearly_growth_rate=first_ci.cost_growth_rate, + init_cost=sum(c.init_cost for c in cost_incomes), + periodic_cost=sum(c.periodic_cost for c in cost_incomes), + periodic_income=sum(c.periodic_income for c in cost_incomes), + income_yearly_growth_rate=first_ci.income_growth_rate, + ) + + @staticmethod + def from_kwargs(kwargs) -> "CostIncome": + """Extracts financial keys from the excel row data.""" + return CostIncome( + init_cost=kwargs.get("init_cost", 0.0), + periodic_cost=kwargs.get("periodic_cost", 0.0), + periodic_income=kwargs.get("periodic_income", 0.0), + income_yearly_growth_rate=kwargs.get("income_yearly_growth_rate", 0.0), + cost_yearly_growth_rate=kwargs.get("cost_yearly_growth_rate", 0.0), + ) From 2c0dffada20a3ff14b7cb71bae9febaefca41cc4 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 7 Apr 2026 09:39:37 +0200 Subject: [PATCH 05/37] Introduces measure config dataclasses --- climada/entity/measures/measure_config.py | 318 ++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 climada/entity/measures/measure_config.py diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py new file mode 100644 index 0000000000..f742851690 --- /dev/null +++ b/climada/entity/measures/measure_config.py @@ -0,0 +1,318 @@ +""" +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 configuration dataclasses for Measure reading and writing. +""" + +from __future__ import annotations + +import dataclasses +import logging +from abc import ABC +from dataclasses import asdict, dataclass, field, fields +from datetime import datetime +from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union + +import pandas as pd + +from climada.util.string_parsers import parse_color, parse_mapping_string, parse_range + +if TYPE_CHECKING: + from climada.entity.measures.base import Measure + from climada.entity.measures.cost_income import CostIncome + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class _ModifierConfig(ABC): + def to_dict(self): + # 1. Get the current values as a dict + current_data = asdict(self) + + # 2. Identify fields where the current value differs from the default + non_default_data = {} + for f in fields(self): + current_value = getattr(self, f.name) + + # Logic to get the default value (handling both default and default_factory) + default_value = f.default + if ( + f.default_factory is not field().default_factory + ): # Check if factory exists + default_value = f.default_factory() + + if current_value != default_value: + non_default_data[f.name] = current_data[f.name] + + non_default_data.pop("haz_type", None) + return non_default_data + + @classmethod + def from_dict(cls, d: dict): + filtered = cls._filter_dict_to_fields(d) + return cls(**filtered) + + @classmethod + def _filter_dict_to_fields(cls, d: dict): + """Filter out values that do not match the dataclass fields.""" + filtered = dict( + filter(lambda k: k[0] in [f.name for f in fields(cls)], d.items()) + ) + return filtered + + def _filter_out_default_fields(self): + non_defaults = {} + defaults = {} + for f in fields(self): + val = getattr(self, f.name) + default = f.default + if f.default_factory is not field().default_factory: + default = f.default_factory() + + if val != default: + non_defaults[f.name] = val + else: + defaults[f.name] = val + return non_defaults, defaults + + def __repr__(self) -> str: + non_defaults, defaults = self._filter_out_default_fields() + ndf_fields_str = ( + "\n\t\t\t".join(f"{k}={v!r}" for k, v in non_defaults.items()) + if non_defaults + else None + ) + fields_str = ( + "\n\t\t\t".join(f"{k}={v!r}" for k, v in defaults.items()) + if defaults + else None + ) + fields = ( + "(" "\n\t\tNon default fields:" f"\n\t\t\t{ndf_fields_str}" + if ndf_fields_str + else "()" + ) + return f"{self.__class__.__name__}{fields}" + + +@dataclass(repr=False) +class ImpfsetModifierConfig(_ModifierConfig): + """Configuration for impact function modifiers.""" + + haz_type: str + impf_ids: Optional[Union[int, str, list[Union[int, str]]]] = None + impf_mdd_mult: float = 1.0 + impf_mdd_add: float = 0.0 + impf_paa_mult: float = 1.0 + impf_paa_add: float = 0.0 + impf_int_mult: float = 1.0 + impf_int_add: float = 0.0 + new_impfset_path: Optional[str] = None + """Excel filepath for new impfset.""" + + def __post_init__(self): + if self.new_impfset_path is not None and any( + [ + self.impf_mdd_add, + self.impf_mdd_mult, + self.impf_paa_add, + self.impf_paa_mult, + self.impf_int_add, + self.impf_int_mult, + ] + ): + LOGGER.warning( + "Both new impfset object and impfset modifiers are provided, " + "modifiers will be applied after changing the impfset." + ) + + +@dataclass(repr=False) +class HazardModifierConfig(_ModifierConfig): + """Configuration for impact function modifiers.""" + + haz_type: str + haz_int_mult: Optional[float] = 1.0 + haz_int_add: Optional[float] = 0.0 + new_hazard_path: Optional[str] = None + """HDF5 filepath for new hazard.""" + impact_rp_cutoff: Optional[float] = None + + def __post_init__(self): + if self.new_hazard_path is not None and any( + [self.haz_int_mult, self.haz_int_add, self.impact_rp_cutoff] + ): + LOGGER.warning( + "Both new hazard object and hazard modifiers are provided, " + "modifiers will be applied after changing the hazard." + ) + + +@dataclass(repr=False) +class ExposuresModifierConfig(_ModifierConfig): + """Configuration for impact function modifiers.""" + + reassign_impf_id: Optional[Dict[str, Dict[int | str, int | str]]] = None + set_to_zero: Optional[list[int]] = None + new_exposures_path: Optional[str] = None + """HDF5 filepath for new exposure""" + + def __post_init__(self): + if self.new_exposures_path is not None and any( + [self.reassign_impf_id, self.set_to_zero] + ): + LOGGER.warning( + "Both new exposures object and exposures modifiers are provided, " + "modifiers will be applied after changing the exposures." + ) + + +@dataclass(repr=False) +class CostIncomeConfig(_ModifierConfig): + """Serializable configuration for CostIncome.""" + + mkt_price_year: Optional[int] = field(default_factory=lambda: datetime.today().year) + init_cost: float = 0.0 + periodic_cost: float = 0.0 + periodic_income: float = 0.0 + cost_yearly_growth_rate: float = 0.0 + income_yearly_growth_rate: float = 0.0 + freq: str = "Y" + custom_cash_flows: Optional[list[dict]] = None + + def to_cost_income(self) -> CostIncome: + df = None + if self.custom_cash_flows is not None: + df = pd.DataFrame(self.custom_cash_flows) + df["date"] = pd.to_datetime(df["date"]) + return CostIncome( + mkt_price_year=self.mkt_price_year, + init_cost=self.init_cost, + periodic_cost=self.periodic_cost, + periodic_income=self.periodic_income, + cost_yearly_growth_rate=self.cost_yearly_growth_rate, + income_yearly_growth_rate=self.income_yearly_growth_rate, + custom_cash_flows=df, + freq=self.freq, + ) + + @classmethod + def from_cost_income(cls, ci: CostIncome) -> "CostIncomeConfig": + """Round-trip from a live CostIncome object.""" + custom = None + if ci.custom_cash_flows is not None: + custom = ( + ci.custom_cash_flows.reset_index() + .rename(columns={"index": "date"}) + .assign(date=lambda df: df["date"].dt.strftime("%Y-%m-%d")) + .to_dict(orient="records") + ) + return cls( + mkt_price_year=ci.mkt_price_year.year, # datetime → int + init_cost=abs(ci.init_cost), # stored negative → positive + periodic_cost=abs(ci.periodic_cost), + periodic_income=ci.periodic_income, + cost_yearly_growth_rate=ci.cost_growth_rate, + income_yearly_growth_rate=ci.income_growth_rate, + freq=ci.freq, + custom_cash_flows=custom, + ) + + +@dataclass(repr=False) +class MeasureConfig(_ModifierConfig): + name: str + haz_type: str + impfset_modifier: ImpfsetModifierConfig + hazard_modifier: HazardModifierConfig + exposures_modifier: ExposuresModifierConfig + cost_income: CostIncomeConfig + implementation_duration: Optional[str] = None + color_rgb: Optional[Tuple[float, float, float]] = None + + def __repr__(self) -> str: + fields_str = "\n\t".join(f"{k}={v!r}" for k, v in self.__dict__.items()) + return f"{self.__class__.__name__}(\n\t{fields_str})" + + def to_dict(self) -> dict: + return { + "name": self.name, + "haz_type": self.haz_type, + **self.impfset_modifier.to_dict(), + **self.hazard_modifier.to_dict(), + **self.exposures_modifier.to_dict(), + **self.cost_income.to_dict(), + "implementation_duration": self.implementation_duration, + "color_rgb": list(self.color_rgb) if self.color_rgb is not None else None, + } + + @classmethod + def from_dict(cls, d: dict) -> "MeasureConfig": + color = d.get("color_rgb") + return cls( + name=d["name"], + haz_type=d["haz_type"], + impfset_modifier=ImpfsetModifierConfig.from_dict(d), + hazard_modifier=HazardModifierConfig.from_dict(d), + exposures_modifier=ExposuresModifierConfig.from_dict(d), + cost_income=CostIncomeConfig.from_dict(d), + implementation_duration=d.get("implementation_duration"), + color_rgb=( + tuple(color) if color is not None and not pd.isna(color) else None + ), + ) + + def to_yaml(self, path: str) -> None: + import yaml + + with open(path, "w") as f: + yaml.dump( + {"measures": [self.to_dict()]}, + f, + default_flow_style=False, + sort_keys=False, + ) + + @classmethod + def from_yaml(cls, path: str) -> "MeasureConfig": + import yaml + + with open(path) as f: + return cls.from_dict(yaml.safe_load(f)["measures"][0]) + + @classmethod + def from_row( + cls, row: pd.Series, haz_type: Optional[str] = None + ) -> "MeasureConfig": + """Build a MeasureConfig from a legacy Excel row.""" + row_dict = row.to_dict() + return cls.from_dict(row_dict) + + +def _serialize_modifier_dict(d: dict) -> dict: + """Stringify keys, convert tuples to lists for JSON.""" + return {str(k): list(v) for k, v in d.items()} + + +def _deserialize_modifier_dict(d: dict) -> dict: + """Restore int keys where possible, values back to tuples.""" + return { + (int(k) if isinstance(k, str) and k.isdigit() else k): tuple(v) + for k, v in d.items() + } From e9f48318f2bde1eeb9c9ac7ffe570f23f94d4a8e Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 7 Apr 2026 10:22:54 +0200 Subject: [PATCH 06/37] Cleans-up, Docstringfies --- climada/entity/measures/measure_config.py | 489 ++++++++++++++++++---- 1 file changed, 397 insertions(+), 92 deletions(-) diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py index f742851690..3730dc2d5d 100644 --- a/climada/entity/measures/measure_config.py +++ b/climada/entity/measures/measure_config.py @@ -21,99 +21,209 @@ from __future__ import annotations -import dataclasses import logging from abc import ABC from dataclasses import asdict, dataclass, field, fields from datetime import datetime -from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union +from typing import Dict, Optional, Tuple, Union import pandas as pd -from climada.util.string_parsers import parse_color, parse_mapping_string, parse_range - -if TYPE_CHECKING: - from climada.entity.measures.base import Measure - from climada.entity.measures.cost_income import CostIncome - LOGGER = logging.getLogger(__name__) @dataclass class _ModifierConfig(ABC): + """ + Abstract base class for all modifier configuration dataclasses. + + Provides shared serialization, deserialization, and representation + logic for all concrete modifier config subclasses. Not intended to + be instantiated directly. + """ + def to_dict(self): + """ + Serialize the config to a flat dictionary, omitting default values. + + The ``haz_type`` field is always excluded from the output, as it + is managed at the ``MeasureConfig`` level. + + Returns + ------- + dict + Dictionary containing only fields whose values differ from + their dataclass defaults. + """ + # 1. Get the current values as a dict current_data = asdict(self) # 2. Identify fields where the current value differs from the default non_default_data = {} - for f in fields(self): - current_value = getattr(self, f.name) + for defined_field in fields(self): + current_value = getattr(self, defined_field.name) # Logic to get the default value (handling both default and default_factory) - default_value = f.default + default_value = defined_field.default if ( - f.default_factory is not field().default_factory + defined_field.default_factory is not defined_field().default_factory ): # Check if factory exists - default_value = f.default_factory() + default_value = defined_field.default_factory() if current_value != default_value: - non_default_data[f.name] = current_data[f.name] + non_default_data[defined_field.name] = current_data[defined_field.name] non_default_data.pop("haz_type", None) return non_default_data @classmethod - def from_dict(cls, d: dict): - filtered = cls._filter_dict_to_fields(d) + def from_dict(cls, kwargs_dict: dict): + """ + Instantiate a config from a dictionary, ignoring unknown keys. + + Parameters + ---------- + kwargs_dict : dict + Input dictionary. Keys not matching any dataclass field are + silently discarded. + + Returns + ------- + _ModifierConfig + A new instance of the calling subclass. + """ + + filtered = cls._filter_dict_to_fields(kwargs_dict) return cls(**filtered) @classmethod - def _filter_dict_to_fields(cls, d: dict): - """Filter out values that do not match the dataclass fields.""" + def _filter_dict_to_fields(cls, to_filter: dict): + """ + Filter a dictionary to only the keys matching the dataclass fields. + + Parameters + ---------- + to_filter : dict + Input dictionary, potentially containing extra keys. + + Returns + ------- + dict + A copy of ``to_filter`` restricted to keys that correspond to declared + dataclass fields on this class. + """ + filtered = dict( - filter(lambda k: k[0] in [f.name for f in fields(cls)], d.items()) + filter(lambda k: k[0] in [f.name for f in fields(cls)], to_filter.items()) ) return filtered def _filter_out_default_fields(self): + """ + Partition the instance's fields into non-default and default groups. + + Returns + ------- + non_defaults : dict + Fields whose current value differs from the dataclass default. + defaults : dict + Fields whose current value equals the dataclass default. + """ + non_defaults = {} defaults = {} - for f in fields(self): - val = getattr(self, f.name) - default = f.default - if f.default_factory is not field().default_factory: - default = f.default_factory() + for defined_field in fields(self): + val = getattr(self, defined_field.name) + default = defined_field.default + if defined_field.default_factory is not field().default_factory: + default = defined_field.default_factory() if val != default: - non_defaults[f.name] = val + non_defaults[defined_field.name] = val else: - defaults[f.name] = val + defaults[defined_field.name] = val return non_defaults, defaults def __repr__(self) -> str: + """ + Return a human-readable representation highlighting non-default fields. + + Non-default fields are shown prominently; default fields are shown + below them. This makes it easy to see at a glance what has been + configured on an instance. + + Returns + ------- + str + A formatted string representation of the instance. + """ + non_defaults, defaults = self._filter_out_default_fields() ndf_fields_str = ( "\n\t\t\t".join(f"{k}={v!r}" for k, v in non_defaults.items()) if non_defaults else None ) - fields_str = ( + _ = ( "\n\t\t\t".join(f"{k}={v!r}" for k, v in defaults.items()) if defaults else None ) - fields = ( + ndf_fields = ( "(" "\n\t\tNon default fields:" f"\n\t\t\t{ndf_fields_str}" if ndf_fields_str else "()" ) - return f"{self.__class__.__name__}{fields}" + return f"{self.__class__.__name__}{ndf_fields}" @dataclass(repr=False) class ImpfsetModifierConfig(_ModifierConfig): - """Configuration for impact function modifiers.""" + """ + Configuration for modifications to an impact function set. + + Supports scaling or shifting MDD, PAA, and intensity curves, as well + as replacement of the impact function set, loaded from a file path. If + both a new file path and modifier values are provided, modifiers are + applied after the replacement (and a warning is issued). + + Parameters + ---------- + haz_type : str + Hazard type identifier (e.g. ``"TC"``) that this modifier targets. + impf_ids : int or str or list of int or str, optional + Impact function ID(s) to which modifications are applied. + If ``None``, all impact functions are affected. + impf_mdd_mult : float, optional + Multiplicative factor applied to the mean damage degree (MDD) curve. + Default is ``1.0`` (no change). + impf_mdd_add : float, optional + Additive offset applied to the MDD curve after multiplication. + Default is ``0.0``. + impf_paa_mult : float, optional + Multiplicative factor applied to the percentage of affected assets + (PAA) curve. Default is ``1.0``. + impf_paa_add : float, optional + 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. + Default is ``1.0``. + impf_int_add : float, optional + Additive offset applied to the intensity axis after multiplication. + Default is ``0.0``. + new_impfset_path : str, optional + Path to an Excel file containing a replacement impact function set. + If provided alongside modifier values, a warning is issued and + modifiers are applied after loading the new set. + + Warns + ----- + UserWarning + If ``new_impfset_path`` is set alongside any non-default modifier + values. + """ haz_type: str impf_ids: Optional[Union[int, str, list[Union[int, str]]]] = None @@ -124,7 +234,6 @@ class ImpfsetModifierConfig(_ModifierConfig): impf_int_mult: float = 1.0 impf_int_add: float = 0.0 new_impfset_path: Optional[str] = None - """Excel filepath for new impfset.""" def __post_init__(self): if self.new_impfset_path is not None and any( @@ -145,13 +254,43 @@ def __post_init__(self): @dataclass(repr=False) class HazardModifierConfig(_ModifierConfig): - """Configuration for impact function modifiers.""" + """ + Configuration for modifications to a hazard. + + Supports scaling or shifting hazard intensity, applying a return-period + frequency cutoff, and replacement of the hazard, loaded from a file path. + If both a new file path and modifier values are provided, modifiers are + applied after the replacement. + + Parameters + ---------- + haz_type : str + Hazard type identifier (e.g. ``"TC"``) that this modifier targets. + haz_int_mult : float, optional + Multiplicative factor applied to hazard intensity. + Default is ``1.0`` (no change). + haz_int_add : float, optional + Additive offset applied to hazard intensity after multiplication. + Default is ``0.0``. + new_hazard_path : str, optional + Path to an HDF5 file containing a replacement hazard. + If provided alongside modifier values, a warning is issued and + modifiers are applied after loading the new hazard. + impact_rp_cutoff : float, optional + Return period (in years) below which hazard events are discarded. + If ``None``, no cutoff is applied. + + Warns + ----- + UserWarning + If ``new_hazard_path`` is set alongside any non-default modifier + values or a non-``None`` ``impact_rp_cutoff``. + """ haz_type: str haz_int_mult: Optional[float] = 1.0 haz_int_add: Optional[float] = 0.0 new_hazard_path: Optional[str] = None - """HDF5 filepath for new hazard.""" impact_rp_cutoff: Optional[float] = None def __post_init__(self): @@ -166,7 +305,34 @@ def __post_init__(self): @dataclass(repr=False) class ExposuresModifierConfig(_ModifierConfig): - """Configuration for impact function modifiers.""" + """ + Configuration for modifications to an exposures object. + + Supports remapping impact function IDs, zeroing out selected regions, + and replacement of the exposures from a new file. If both a new + file path and modifier values are provided, modifiers are applied after + the replacement. + + Parameters + ---------- + reassign_impf_id : dict of {str: dict of {int or str: int or str}}, optional + Nested mapping ``{haz_type: {old_id: new_id}}`` used to reassign + impact function IDs in the exposures. If ``None``, no remapping + is performed. + set_to_zero : list of int, optional + Region IDs for which exposure values are set to zero. + If ``None``, no zeroing is applied. + new_exposures_path : str, optional + Path to an HDF5 file containing replacement exposures. + If provided alongside modifier values, a warning is issued and + modifiers are applied after loading the new exposures. + + Warns + ----- + UserWarning + If ``new_exposures_path`` is set alongside any non-``None`` + modifier values. + """ reassign_impf_id: Optional[Dict[str, Dict[int | str, int | str]]] = None set_to_zero: Optional[list[int]] = None @@ -185,7 +351,34 @@ def __post_init__(self): @dataclass(repr=False) class CostIncomeConfig(_ModifierConfig): - """Serializable configuration for CostIncome.""" + """ + Serializable configuration for a ``CostIncome`` object. + + Encodes all parameters required to construct a ``CostIncome`` instance, + including optional custom cash flow schedules. + + Parameters + ---------- + mkt_price_year : int, optional + Reference year for market prices. Defaults to the current year. + init_cost : float, optional + One-time initial investment cost (positive value). Default is ``0.0``. + periodic_cost : float, optional + Recurring cost per period (positive value). Default is ``0.0``. + periodic_income : float, optional + Recurring income per period. Default is ``0.0``. + cost_yearly_growth_rate : float, optional + Annual growth rate applied to periodic costs. Default is ``0.0``. + income_yearly_growth_rate : float, optional + Annual growth rate applied to periodic income. Default is ``0.0``. + freq : str, optional + Pandas offset alias defining the period length (e.g. ``"Y"`` for + yearly, ``"M"`` for monthly). Default is ``"Y"``. + custom_cash_flows : list of dict, optional + 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. + """ mkt_price_year: Optional[int] = field(default_factory=lambda: datetime.today().year) init_cost: float = 0.0 @@ -196,47 +389,79 @@ class CostIncomeConfig(_ModifierConfig): freq: str = "Y" custom_cash_flows: Optional[list[dict]] = None - def to_cost_income(self) -> CostIncome: - df = None - if self.custom_cash_flows is not None: - df = pd.DataFrame(self.custom_cash_flows) - df["date"] = pd.to_datetime(df["date"]) - return CostIncome( - mkt_price_year=self.mkt_price_year, - init_cost=self.init_cost, - periodic_cost=self.periodic_cost, - periodic_income=self.periodic_income, - cost_yearly_growth_rate=self.cost_yearly_growth_rate, - income_yearly_growth_rate=self.income_yearly_growth_rate, - custom_cash_flows=df, - freq=self.freq, - ) - @classmethod - def from_cost_income(cls, ci: CostIncome) -> "CostIncomeConfig": - """Round-trip from a live CostIncome object.""" + def from_cost_income(cls, cost_income: "CostIncome") -> "CostIncomeConfig": + """ + Construct a :class:`CostIncomeConfig` from a live + :class:`CostIncome` object. + + Parameters + ---------- + cost_income : CostIncome + The live ``CostIncome`` instance to serialise. + + Returns + ------- + CostIncomeConfig + The config instance equivalent to the ``CostIncome``. + """ + custom = None - if ci.custom_cash_flows is not None: + if cost_income.custom_cash_flows is not None: custom = ( - ci.custom_cash_flows.reset_index() + cost_income.custom_cash_flows.reset_index() .rename(columns={"index": "date"}) .assign(date=lambda df: df["date"].dt.strftime("%Y-%m-%d")) .to_dict(orient="records") ) return cls( - mkt_price_year=ci.mkt_price_year.year, # datetime → int - init_cost=abs(ci.init_cost), # stored negative → positive - periodic_cost=abs(ci.periodic_cost), - periodic_income=ci.periodic_income, - cost_yearly_growth_rate=ci.cost_growth_rate, - income_yearly_growth_rate=ci.income_growth_rate, - freq=ci.freq, + mkt_price_year=cost_income.mkt_price_year.year, # datetime → int + init_cost=abs(cost_income.init_cost), # stored negative → positive + periodic_cost=abs(cost_income.periodic_cost), + periodic_income=cost_income.periodic_income, + cost_yearly_growth_rate=cost_income.cost_growth_rate, + income_yearly_growth_rate=cost_income.income_growth_rate, + freq=cost_income.freq, custom_cash_flows=custom, ) @dataclass(repr=False) class MeasureConfig(_ModifierConfig): + """ + Top-level serializable configuration for a single adaptation measure. + + Aggregates all modifier sub-configs (hazard, impact functions, exposures, + cost/income) into a single object that can be round-tripped through dict, + YAML, or a legacy Excel row. + + This class is the primary entry point for defining measures in a + declarative, file-based workflow and serves as the serialization + counterpart to :class:`~climada.entity.measures.base.Measure`. + + Parameters + ---------- + name : str + Unique name identifying this measure. + haz_type : str + Hazard type identifier (e.g. ``"TC"``) this measure is designed for. + impfset_modifier : ImpfsetModifierConfig + Configuration describing modifications to the impact function set. + hazard_modifier : HazardModifierConfig + Configuration describing modifications to the hazard. + exposures_modifier : ExposuresModifierConfig + Configuration describing modifications to the exposures. + cost_income : CostIncomeConfig + Financial parameters associated with implementing this measure. + implementation_duration : str, optional + Pandas offset alias (e.g. ``"2Y"``) representing the time before + the measure is fully operational. If ``None``, the measure takes + effect immediately. + color_rgb : tuple of float, optional + RGB colour triple in the range ``[0, 1]`` used for visualisation. + If ``None``, defaults to black ``(0, 0, 0)``. + """ + name: str haz_type: str impfset_modifier: ImpfsetModifierConfig @@ -247,10 +472,36 @@ class MeasureConfig(_ModifierConfig): color_rgb: Optional[Tuple[float, float, float]] = None def __repr__(self) -> str: + """ + Return a detailed string representation of the measure configuration. + + All fields are shown, including sub-configs, with each on its own + indented line. + + Returns + ------- + str + A formatted multi-line string representation. + """ + fields_str = "\n\t".join(f"{k}={v!r}" for k, v in self.__dict__.items()) return f"{self.__class__.__name__}(\n\t{fields_str})" def to_dict(self) -> dict: + """ + Serialize the measure configuration to a flat dictionary. + + Sub-config dictionaries are merged into the top-level dict (i.e. + their keys are inlined, not nested). ``haz_type`` is always included + at the top level. Fields with ``None`` values are preserved. + + Returns + ------- + dict + Flat dictionary representation suitable for YAML or Excel + serialization. + """ + return { "name": self.name, "haz_type": self.haz_type, @@ -263,56 +514,110 @@ def to_dict(self) -> dict: } @classmethod - def from_dict(cls, d: dict) -> "MeasureConfig": - color = d.get("color_rgb") + def from_dict(cls, kwargs_dict: dict) -> "MeasureConfig": + """ + Instantiate a :class:`MeasureConfig` from a flat dictionary. + + Delegates sub-config construction to the respective + ``from_dict`` classmethods. Unknown keys are silently discarded + by each sub-config parser. + + Parameters + ---------- + kwargs_dict : dict + Flat dictionary, as produced by :meth:`to_dict` or read from + a legacy Excel row. Must contain at minimum ``"name"`` and + ``"haz_type"``. + + Returns + ------- + MeasureConfig + A fully populated configuration instance. + """ + + color = kwargs_dict.get("color_rgb") return cls( - name=d["name"], - haz_type=d["haz_type"], - impfset_modifier=ImpfsetModifierConfig.from_dict(d), - hazard_modifier=HazardModifierConfig.from_dict(d), - exposures_modifier=ExposuresModifierConfig.from_dict(d), - cost_income=CostIncomeConfig.from_dict(d), - implementation_duration=d.get("implementation_duration"), + name=kwargs_dict["name"], + haz_type=kwargs_dict["haz_type"], + impfset_modifier=ImpfsetModifierConfig.from_dict(kwargs_dict), + hazard_modifier=HazardModifierConfig.from_dict(kwargs_dict), + exposures_modifier=ExposuresModifierConfig.from_dict(kwargs_dict), + cost_income=CostIncomeConfig.from_dict(kwargs_dict), + implementation_duration=kwargs_dict.get("implementation_duration"), color_rgb=( tuple(color) if color is not None and not pd.isna(color) else None ), ) def to_yaml(self, path: str) -> None: + """ + Write this configuration to a YAML file. + + The file is structured as ``{"measures": []}``, + matching the expected format for :meth:`from_yaml`. + + Parameters + ---------- + path : str + Destination file path. Will be created or overwritten. + """ + import yaml - with open(path, "w") as f: + with open(path, "w") as opened_file: yaml.dump( {"measures": [self.to_dict()]}, - f, + opened_file, default_flow_style=False, sort_keys=False, ) @classmethod def from_yaml(cls, path: str) -> "MeasureConfig": - import yaml + """ + Load a :class:`MeasureConfig` from a YAML file. - with open(path) as f: - return cls.from_dict(yaml.safe_load(f)["measures"][0]) + Expects the file to contain a top-level ``"measures"`` list; reads + only the first entry. - @classmethod - def from_row( - cls, row: pd.Series, haz_type: Optional[str] = None - ) -> "MeasureConfig": - """Build a MeasureConfig from a legacy Excel row.""" - row_dict = row.to_dict() - return cls.from_dict(row_dict) + Parameters + ---------- + path : str + Path to the YAML file to read. + + Returns + ------- + MeasureConfig + The configuration parsed from the first entry in + ``measures``. + """ + import yaml -def _serialize_modifier_dict(d: dict) -> dict: - """Stringify keys, convert tuples to lists for JSON.""" - return {str(k): list(v) for k, v in d.items()} + with open(path) as opened_file: + return cls.from_dict(yaml.safe_load(opened_file)["measures"][0]) + @classmethod + def from_row(cls, row: pd.Series) -> "MeasureConfig": + """ + Construct a :class:`MeasureConfig` from a legacy Excel row. + + Converts the row to a dictionary and delegates to :meth:`from_dict`. + This is the primary migration path for measures currently stored in + the legacy Excel-based ``MeasureSet`` format. + + Parameters + ---------- + row : pd.Series + A single row from a legacy measures Excel sheet, with column + names matching the flat dictionary keys expected by + :meth:`from_dict`. + + Returns + ------- + MeasureConfig + A configuration instance populated from the row data. + """ -def _deserialize_modifier_dict(d: dict) -> dict: - """Restore int keys where possible, values back to tuples.""" - return { - (int(k) if isinstance(k, str) and k.isdigit() else k): tuple(v) - for k, v in d.items() - } + row_dict = row.to_dict() + return cls.from_dict(row_dict) From bcf6811a5393c3c87931b3285f694e567bb49a1b Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 7 Apr 2026 15:12:50 +0200 Subject: [PATCH 07/37] Better to_dict, color parser, and post_inits --- climada/entity/measures/measure_config.py | 143 ++++++++++++---------- 1 file changed, 77 insertions(+), 66 deletions(-) diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py index 3730dc2d5d..2bbd41c6c3 100644 --- a/climada/entity/measures/measure_config.py +++ b/climada/entity/measures/measure_config.py @@ -22,11 +22,13 @@ from __future__ import annotations import logging +import warnings from abc import ABC from dataclasses import asdict, dataclass, field, fields from datetime import datetime from typing import Dict, Optional, Tuple, Union +import numpy as np import pandas as pd LOGGER = logging.getLogger(__name__) @@ -42,40 +44,53 @@ class _ModifierConfig(ABC): be instantiated directly. """ - def to_dict(self): + def _filter_out_default_fields(self): """ - Serialize the config to a flat dictionary, omitting default values. + Partition the instance's fields into non-default and default groups. The ``haz_type`` field is always excluded from the output, as it is managed at the ``MeasureConfig`` level. Returns ------- - dict - Dictionary containing only fields whose values differ from - their dataclass defaults. + non_defaults : dict + Fields whose current value differs from the dataclass default. + defaults : dict + Fields whose current value equals the dataclass default. """ - # 1. Get the current values as a dict - current_data = asdict(self) - - # 2. Identify fields where the current value differs from the default - non_default_data = {} + non_defaults = {} + defaults = {} for defined_field in fields(self): - current_value = getattr(self, defined_field.name) + val = getattr(self, defined_field.name) + default = defined_field.default + if defined_field.default_factory is not field().default_factory: + default = defined_field.default_factory() - # Logic to get the default value (handling both default and default_factory) - default_value = defined_field.default - if ( - defined_field.default_factory is not defined_field().default_factory - ): # Check if factory exists - default_value = defined_field.default_factory() + if val != default: + non_defaults[defined_field.name] = val + else: + defaults[defined_field.name] = val - if current_value != default_value: - non_default_data[defined_field.name] = current_data[defined_field.name] + if "haz_type" in non_defaults: + non_defaults.pop("haz_type") + return non_defaults, defaults - non_default_data.pop("haz_type", None) - return non_default_data + def to_dict(self): + """ + Serialize the config to a flat dictionary, omitting default values. + + The ``haz_type`` field is always excluded from the output, as it + is managed at the ``MeasureConfig`` level. + + Returns + ------- + dict + Dictionary containing only fields whose values differ from + their dataclass defaults. + """ + non_default, _ = self._filter_out_default_fields() + return non_default @classmethod def from_dict(cls, kwargs_dict: dict): @@ -119,32 +134,6 @@ def _filter_dict_to_fields(cls, to_filter: dict): ) return filtered - def _filter_out_default_fields(self): - """ - Partition the instance's fields into non-default and default groups. - - Returns - ------- - non_defaults : dict - Fields whose current value differs from the dataclass default. - defaults : dict - Fields whose current value equals the dataclass default. - """ - - non_defaults = {} - defaults = {} - for defined_field in fields(self): - val = getattr(self, defined_field.name) - default = defined_field.default - if defined_field.default_factory is not field().default_factory: - default = defined_field.default_factory() - - if val != default: - non_defaults[defined_field.name] = val - else: - defaults[defined_field.name] = val - return non_defaults, defaults - def __repr__(self) -> str: """ Return a human-readable representation highlighting non-default fields. @@ -236,17 +225,19 @@ class ImpfsetModifierConfig(_ModifierConfig): new_impfset_path: Optional[str] = None def __post_init__(self): - if self.new_impfset_path is not None and any( - [ - self.impf_mdd_add, - self.impf_mdd_mult, - self.impf_paa_add, - self.impf_paa_mult, - self.impf_int_add, - self.impf_int_mult, + config = self.to_dict() + if "new_impfset_path" in config and any( + key in config + for key in [ + "impf_mdd_add", + "impf_mdd_mult", + "impf_paa_add", + "impf_paa_mult", + "impf_int_add", + "impf_int_mult", ] ): - LOGGER.warning( + warnings.warn( "Both new impfset object and impfset modifiers are provided, " "modifiers will be applied after changing the impfset." ) @@ -294,10 +285,11 @@ class HazardModifierConfig(_ModifierConfig): impact_rp_cutoff: Optional[float] = None def __post_init__(self): - if self.new_hazard_path is not None and any( - [self.haz_int_mult, self.haz_int_add, self.impact_rp_cutoff] + config = self.to_dict() + if "new_hazard_path" in config and any( + key in config for key in ["haz_int_mult", "haz_int_add", "impact_rp_cutoff"] ): - LOGGER.warning( + warnings.warn( "Both new hazard object and hazard modifiers are provided, " "modifiers will be applied after changing the hazard." ) @@ -340,10 +332,11 @@ class ExposuresModifierConfig(_ModifierConfig): """HDF5 filepath for new exposure""" def __post_init__(self): - if self.new_exposures_path is not None and any( - [self.reassign_impf_id, self.set_to_zero] + config = self.to_dict() + if "new_exposures_path" in config and any( + key in config for key in ["reassign_impf_id", "set_to_zero"] ): - LOGGER.warning( + warnings.warn( "Both new exposures object and exposures modifiers are provided, " "modifiers will be applied after changing the exposures." ) @@ -535,7 +528,6 @@ def from_dict(cls, kwargs_dict: dict) -> "MeasureConfig": A fully populated configuration instance. """ - color = kwargs_dict.get("color_rgb") return cls( name=kwargs_dict["name"], haz_type=kwargs_dict["haz_type"], @@ -544,11 +536,30 @@ def from_dict(cls, kwargs_dict: dict) -> "MeasureConfig": exposures_modifier=ExposuresModifierConfig.from_dict(kwargs_dict), cost_income=CostIncomeConfig.from_dict(kwargs_dict), implementation_duration=kwargs_dict.get("implementation_duration"), - color_rgb=( - tuple(color) if color is not None and not pd.isna(color) else None - ), + color_rgb=cls._normalize_color(kwargs_dict.get("color_rgb")), ) + @staticmethod + def _normalize_color(color_rgb): + # 1. Handle None and NaN (np.nan, pd.NA, float('nan')) + if color_rgb is None or pd.isna(color_rgb) is True: + return None + + # 2. Convert sequence types (list, np.array, tuple) to a standard tuple + try: + # Flatten in case it's a nested numpy array, then convert to tuple + result = tuple(np.array(color_rgb).flatten().tolist()) + + # 3. Enforce the length of three + if len(result) != 3: + raise ValueError(f"Expected 3 digits, got {len(result)}") + + return result + + except (TypeError, ValueError) as err: + # Handle cases where input isn't iterable or wrong length + raise ValueError(f"Invalid color format: {color_rgb}.") from err + def to_yaml(self, path: str) -> None: """ Write this configuration to a YAML file. From 2718c84dcef81678c924aa81ab72424598bba4bf Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 7 Apr 2026 15:13:50 +0200 Subject: [PATCH 08/37] Unit tests --- .../measures/test/test_measure_config.py | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 climada/entity/measures/test/test_measure_config.py diff --git a/climada/entity/measures/test/test_measure_config.py b/climada/entity/measures/test/test_measure_config.py new file mode 100644 index 0000000000..5f0d54badb --- /dev/null +++ b/climada/entity/measures/test/test_measure_config.py @@ -0,0 +1,515 @@ +""" +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 . + +--- + +Tests for MeasureConfig and related dataclasses. +""" + +# tests/entity/measures/test_measure_config.py + +import logging +import warnings +from datetime import datetime + +import pandas as pd +import pytest + +from climada.entity.measures.measure_config import ( + CostIncomeConfig, + ExposuresModifierConfig, + HazardModifierConfig, + ImpfsetModifierConfig, + MeasureConfig, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def minimal_measure_dict(): + return {"name": "seawall", "haz_type": "TC"} + + +@pytest.fixture +def full_measure_dict(): + return { + "name": "seawall", + "haz_type": "TC", + "haz_int_mult": 0.8, + "haz_int_add": -0.1, + "impf_mdd_mult": 0.9, + "impf_paa_mult": 0.95, + "impf_ids": [1, 2], + "reassign_impf_id": {"TC": {1: 3}}, + "set_to_zero": [10, 20], + "init_cost": 1000.0, + "periodic_cost": 50.0, + "color_rgb": [0.1, 0.5, 0.9], + "implementation_duration": "2Y", + } + + +@pytest.fixture +def basic_impfset_config(): + return ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.9) + + +@pytest.fixture +def basic_hazard_config(): + return HazardModifierConfig(haz_type="TC", haz_int_mult=0.8) + + +@pytest.fixture +def basic_exposures_config(): + return ExposuresModifierConfig(reassign_impf_id={"TC": {1: 2}}) + + +@pytest.fixture +def basic_cost_income_config(): + return CostIncomeConfig(init_cost=1000.0, periodic_cost=50.0) + + +@pytest.fixture +def full_measure_config(full_measure_dict): + return MeasureConfig.from_dict(full_measure_dict) + + +# --------------------------------------------------------------------------- +# _ModifierConfig (via concrete subclasses) +# --------------------------------------------------------------------------- + + +def test_modifier_config_to_dict_omits_defaults(): + config = ImpfsetModifierConfig(haz_type="TC") + result = config.to_dict() + assert result == {} + + +def test_modifier_config_to_dict_includes_non_defaults(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5, impf_paa_add=0.1) + result = config.to_dict() + assert result["impf_mdd_mult"] == 0.5 + assert result["impf_paa_add"] == 0.1 + + +def test_modifier_config_from_dict_ignores_unknown_keys(): + d = {"haz_type": "TC", "unknown_field": 99, "another_unknown": "foo"} + config = ImpfsetModifierConfig.from_dict(d) + assert config.haz_type == "TC" + assert not hasattr(config, "unknown_field") + + +def test_modifier_config_from_dict_roundtrip(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5, impf_paa_add=0.1) + d = {**config.to_dict(), "haz_type": "TC"} + recovered = ImpfsetModifierConfig.from_dict(d) + assert recovered.impf_mdd_mult == config.impf_mdd_mult + assert recovered.impf_paa_add == config.impf_paa_add + + +def test_modifier_config_filter_dict_to_fields_filters_extra_keys(): + d = {"haz_type": "TC", "impf_mdd_mult": 0.5, "not_a_field": 123} + filtered = ImpfsetModifierConfig._filter_dict_to_fields(d) + assert "not_a_field" not in filtered + assert "haz_type" in filtered + assert "impf_mdd_mult" in filtered + assert filtered["haz_type"] == "TC" + assert filtered["impf_mdd_mult"] == 0.5 + + +def test_modifier_config_filter_out_default_fields_partitions_correctly(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5) + non_defaults, defaults = config._filter_out_default_fields() + assert "impf_mdd_mult" in non_defaults + assert "impf_mdd_mult" not in defaults + assert "impf_paa_mult" in defaults + assert "impf_paa_mult" not in non_defaults + from dataclasses import fields + + all_field_names = {f.name for f in fields(config) if f.name != "haz_type"} + assert set(non_defaults) | set(defaults) == all_field_names + + +def test_modifier_config_repr_shows_non_defaults_prominently(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5) + r = repr(config) + assert "Non default fields" in r + assert "impf_mdd_mult" in r + + +def test_modifier_config_repr_empty_when_all_defaults(): + config = ImpfsetModifierConfig(haz_type="TC") + r = repr(config) + assert "Non default fields" not in r + + +# --------------------------------------------------------------------------- +# ImpfsetModifierConfig +# --------------------------------------------------------------------------- + + +def test_impfset_modifier_config_defaults(): + config = ImpfsetModifierConfig(haz_type="TC") + assert config.impf_ids is None + assert config.impf_mdd_mult == 1.0 + assert config.impf_mdd_add == 0.0 + assert config.impf_paa_mult == 1.0 + assert config.impf_paa_add == 0.0 + assert config.impf_int_mult == 1.0 + assert config.impf_int_add == 0.0 + assert config.new_impfset_path is None + + +def test_impfset_modifier_config_from_dict_roundtrip(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.8, impf_ids=[1, 2]) + d = {**config.to_dict(), "haz_type": "TC"} + recovered = ImpfsetModifierConfig.from_dict(d) + assert recovered.impf_mdd_mult == config.impf_mdd_mult + assert recovered.impf_ids == config.impf_ids + + +def test_impfset_modifier_config_to_dict_roundtrip(): + d = {"haz_type": "TC", "impf_mdd_mult": 0.8, "impf_paa_add": 0.05} + config = ImpfsetModifierConfig.from_dict(d) + result = {**config.to_dict(), "haz_type": "TC"} + assert result["impf_mdd_mult"] == d["impf_mdd_mult"] + assert result["impf_paa_add"] == d["impf_paa_add"] + + +def test_impfset_modifier_config_warns_when_path_and_modifiers_combined(): + with pytest.warns(UserWarning): + ImpfsetModifierConfig( + haz_type="TC", + new_impfset_path="path/to/file.xlsx", + impf_mdd_mult=0.5, + ) + + +def test_impfset_modifier_config_no_warning_when_only_path(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + ImpfsetModifierConfig(haz_type="TC", new_impfset_path="path/to/file.xlsx") + + +def test_impfset_modifier_config_no_warning_when_only_modifiers(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5) + + +def test_impfset_modifier_config_impf_ids_accepts_int(): + config = ImpfsetModifierConfig(haz_type="TC", impf_ids=1) + assert config.impf_ids == 1 + + +def test_impfset_modifier_config_impf_ids_accepts_str(): + config = ImpfsetModifierConfig(haz_type="TC", impf_ids="1") + assert config.impf_ids == "1" + + +def test_impfset_modifier_config_impf_ids_accepts_list(): + config = ImpfsetModifierConfig(haz_type="TC", impf_ids=[1, 2, "3"]) + assert config.impf_ids == [1, 2, "3"] + + +def test_impfset_modifier_config_impf_ids_accepts_none(): + config = ImpfsetModifierConfig(haz_type="TC", impf_ids=None) + assert config.impf_ids is None + + +# --------------------------------------------------------------------------- +# HazardModifierConfig +# --------------------------------------------------------------------------- + + +def test_hazard_modifier_config_defaults(): + config = HazardModifierConfig(haz_type="TC") + assert config.haz_int_mult == 1.0 + assert config.haz_int_add == 0.0 + assert config.new_hazard_path is None + assert config.impact_rp_cutoff is None + + +def test_hazard_modifier_config_from_dict_roundtrip(): + config = HazardModifierConfig(haz_type="TC", haz_int_mult=0.8, haz_int_add=-0.1) + d = {**config.to_dict(), "haz_type": "TC"} + recovered = HazardModifierConfig.from_dict(d) + assert recovered.haz_int_mult == config.haz_int_mult + assert recovered.haz_int_add == config.haz_int_add + + +def test_hazard_modifier_config_to_dict_roundtrip(): + d = {"haz_type": "TC", "haz_int_mult": 0.7, "haz_int_add": -0.2} + config = HazardModifierConfig.from_dict(d) + result = {**config.to_dict(), "haz_type": "TC"} + assert result["haz_int_mult"] == d["haz_int_mult"] + assert result["haz_int_add"] == d["haz_int_add"] + + +def test_hazard_modifier_config_warns_when_path_and_modifiers_combined(): + with pytest.warns(UserWarning): + HazardModifierConfig( + haz_type="TC", + new_hazard_path="path/to/hazard.h5", + haz_int_mult=0.5, + ) + + +def test_hazard_modifier_config_warns_when_path_and_rp_cutoff_combined(): + with pytest.warns(UserWarning): + HazardModifierConfig( + haz_type="TC", + new_hazard_path="path/to/hazard.h5", + impact_rp_cutoff=100.0, + ) + + +def test_hazard_modifier_config_no_warning_when_only_path(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + HazardModifierConfig(haz_type="TC", new_hazard_path="path/to/hazard.h5") + + +def test_hazard_modifier_config_no_warning_when_only_modifiers(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + HazardModifierConfig(haz_type="TC", haz_int_mult=0.5) + + +# --------------------------------------------------------------------------- +# ExposuresModifierConfig +# --------------------------------------------------------------------------- + + +def test_exposures_modifier_config_defaults(): + config = ExposuresModifierConfig() + assert config.reassign_impf_id is None + assert config.set_to_zero is None + assert config.new_exposures_path is None + + +def test_exposures_modifier_config_from_dict_roundtrip(): + config = ExposuresModifierConfig( + reassign_impf_id={"TC": {1: 2}}, + set_to_zero=[10, 20], + ) + d = config.to_dict() + recovered = ExposuresModifierConfig.from_dict(d) + assert recovered.reassign_impf_id == config.reassign_impf_id + assert recovered.set_to_zero == config.set_to_zero + + +def test_exposures_modifier_config_to_dict_roundtrip(): + d = {"reassign_impf_id": {"TC": {1: 2}}, "set_to_zero": [5, 6]} + config = ExposuresModifierConfig.from_dict(d) + result = config.to_dict() + assert result["reassign_impf_id"] == d["reassign_impf_id"] + assert result["set_to_zero"] == d["set_to_zero"] + + +def test_exposures_modifier_config_warns_when_path_and_modifiers_combined(): + with pytest.warns(UserWarning): + ExposuresModifierConfig( + new_exposures_path="path/to/exp.h5", + reassign_impf_id={"TC": {1: 2}}, + ) + + +def test_exposures_modifier_config_no_warning_when_only_path(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + ExposuresModifierConfig(new_exposures_path="path/to/exp.h5") + + +def test_exposures_modifier_config_no_warning_when_only_modifiers(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + ExposuresModifierConfig(reassign_impf_id={"TC": {1: 2}}) + + +def test_exposures_modifier_config_reassign_impf_id_accepts_int_keys(): + config = ExposuresModifierConfig(reassign_impf_id={"TC": {1: 2}}) + assert config.reassign_impf_id == {"TC": {1: 2}} + + +def test_exposures_modifier_config_reassign_impf_id_accepts_str_keys(): + config = ExposuresModifierConfig(reassign_impf_id={"TC": {"1": "2"}}) + assert config.reassign_impf_id == {"TC": {"1": "2"}} + + +def test_exposures_modifier_config_set_to_zero_accepts_none(): + config = ExposuresModifierConfig(set_to_zero=None) + assert config.set_to_zero is None + + +def test_exposures_modifier_config_set_to_zero_accepts_list(): + config = ExposuresModifierConfig(set_to_zero=[1, 2, 3]) + assert config.set_to_zero == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# CostIncomeConfig +# --------------------------------------------------------------------------- + + +def test_cost_income_config_defaults(): + config = CostIncomeConfig() + assert config.init_cost == 0.0 + assert config.periodic_cost == 0.0 + assert config.periodic_income == 0.0 + assert config.cost_yearly_growth_rate == 0.0 + assert config.income_yearly_growth_rate == 0.0 + assert config.freq == "Y" + assert config.custom_cash_flows is None + + +def test_cost_income_config_default_mkt_price_year_is_current_year(): + config = CostIncomeConfig() + assert config.mkt_price_year == datetime.today().year + + +def test_cost_income_config_from_dict_roundtrip(): + config = CostIncomeConfig(init_cost=1000.0, periodic_cost=50.0, freq="M") + d = config.to_dict() + recovered = CostIncomeConfig.from_dict(d) + assert recovered.init_cost == config.init_cost + assert recovered.periodic_cost == config.periodic_cost + assert recovered.freq == config.freq + + +def test_cost_income_config_to_dict_roundtrip(): + d = {"init_cost": 500.0, "periodic_income": 20.0, "freq": "M"} + config = CostIncomeConfig.from_dict(d) + result = config.to_dict() + assert result["init_cost"] == d["init_cost"] + assert result["periodic_income"] == d["periodic_income"] + assert result["freq"] == d["freq"] + + +# --------------------------------------------------------------------------- +# MeasureConfig +# --------------------------------------------------------------------------- + + +def test_measure_config_from_dict_minimal(minimal_measure_dict): + config = MeasureConfig.from_dict(minimal_measure_dict) + assert config.name == "seawall" + assert config.haz_type == "TC" + assert config.impfset_modifier == ImpfsetModifierConfig(haz_type="TC") + assert config.hazard_modifier == HazardModifierConfig(haz_type="TC") + assert config.exposures_modifier == ExposuresModifierConfig() + assert config.cost_income == CostIncomeConfig() + + +def test_measure_config_from_dict_full(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + assert config.hazard_modifier.haz_int_mult == full_measure_dict["haz_int_mult"] + assert config.impfset_modifier.impf_mdd_mult == full_measure_dict["impf_mdd_mult"] + assert config.exposures_modifier.set_to_zero == full_measure_dict["set_to_zero"] + assert config.cost_income.init_cost == full_measure_dict["init_cost"] + assert config.color_rgb == tuple(full_measure_dict["color_rgb"]) + assert ( + config.implementation_duration == full_measure_dict["implementation_duration"] + ) + + +def test_measure_config_from_dict_ignores_unknown_keys(minimal_measure_dict): + d = {**minimal_measure_dict, "completely_unknown": 42} + config = MeasureConfig.from_dict(d) + assert config.name == "seawall" + assert not hasattr(config, "completely_unknown") + + +def test_measure_config_to_dict_roundtrip(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + recovered = MeasureConfig.from_dict(config.to_dict()) + assert recovered.name == config.name + assert recovered.haz_type == config.haz_type + assert recovered.hazard_modifier == config.hazard_modifier + assert recovered.impfset_modifier == config.impfset_modifier + assert recovered.exposures_modifier == config.exposures_modifier + assert recovered.color_rgb == config.color_rgb + assert recovered.implementation_duration == config.implementation_duration + + +def test_measure_config_to_dict_color_rgb_none(minimal_measure_dict): + config = MeasureConfig.from_dict(minimal_measure_dict) + result = config.to_dict() + assert result["color_rgb"] is None + + +def test_measure_config_to_dict_color_rgb_set(minimal_measure_dict): + config = MeasureConfig.from_dict( + {**minimal_measure_dict, "color_rgb": [0.1, 0.5, 0.9]} + ) + result = config.to_dict() + assert result["color_rgb"] == [0.1, 0.5, 0.9] + + +def test_measure_config_to_yaml_roundtrip(tmp_path, full_measure_dict): + path = str(tmp_path / "measure.yaml") + config = MeasureConfig.from_dict(full_measure_dict) + config.to_yaml(path) + recovered = MeasureConfig.from_yaml(path) + assert recovered.name == config.name + assert recovered.haz_type == config.haz_type + assert recovered.hazard_modifier == config.hazard_modifier + assert recovered.impfset_modifier == config.impfset_modifier + assert recovered.color_rgb == config.color_rgb + + +def test_measure_config_from_yaml_reads_first_entry(tmp_path, full_measure_dict): + import yaml + + second = {**full_measure_dict, "name": "second_measure"} + path = str(tmp_path / "measures.yaml") + with open(path, "w") as f: + yaml.dump({"measures": [full_measure_dict, second]}, f) + config = MeasureConfig.from_yaml(path) + assert config.name == full_measure_dict["name"] + + +def test_measure_config_from_row_roundtrip(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + row = pd.Series(config.to_dict()) + recovered = MeasureConfig.from_row(row) + assert recovered.name == config.name + assert recovered.hazard_modifier == config.hazard_modifier + assert recovered.impfset_modifier == config.impfset_modifier + + +def test_measure_config_from_row_ignores_extra_columns(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + d = {**config.to_dict(), "extra_column": "garbage"} + row = pd.Series(d) + recovered = MeasureConfig.from_row(row) + assert recovered.name == config.name + + +def test_measure_config_sub_configs_correctly_dispatched(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + assert config.hazard_modifier.haz_int_mult == full_measure_dict["haz_int_mult"] + assert config.impfset_modifier.impf_mdd_mult == full_measure_dict["impf_mdd_mult"] + assert ( + config.exposures_modifier.reassign_impf_id + == full_measure_dict["reassign_impf_id"] + ) + assert config.cost_income.init_cost == full_measure_dict["init_cost"] + assert not hasattr(config.hazard_modifier, "impf_mdd_mult") + assert not hasattr(config.impfset_modifier, "haz_int_mult") From c5345fba258c0d570bfe177bd44c285d2cb38466 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 7 Apr 2026 15:19:50 +0200 Subject: [PATCH 09/37] Removes duplicate docstring --- climada/entity/measures/measure_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py index 2bbd41c6c3..073e6a1455 100644 --- a/climada/entity/measures/measure_config.py +++ b/climada/entity/measures/measure_config.py @@ -329,7 +329,6 @@ class ExposuresModifierConfig(_ModifierConfig): reassign_impf_id: Optional[Dict[str, Dict[int | str, int | str]]] = None set_to_zero: Optional[list[int]] = None new_exposures_path: Optional[str] = None - """HDF5 filepath for new exposure""" def __post_init__(self): config = self.to_dict() From 4551e3c98e17a58a73be35f5dd05f358452fd297 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 2 Apr 2026 12:04:42 +0200 Subject: [PATCH 10/37] Forgotten places Cleans-up, Docstringfies Better to_dict, color parser, and post_inits Removes duplicate docstring --- climada/engine/unsequa/input_var.py | 4 +- .../entity/_legacy_measures/measure_set.py | 3 +- .../entity/_legacy_measures/test/test_base.py | 4 +- .../_legacy_measures/test/test_meas_set.py | 10 +- climada/entity/measures/measure_config.py | 633 ++++++++++++++++++ climada/trajectories/calc_risk_metrics.py | 2 +- .../test/test_calc_risk_metrics.py | 2 +- 7 files changed, 647 insertions(+), 11 deletions(-) create mode 100644 climada/entity/measures/measure_config.py diff --git a/climada/engine/unsequa/input_var.py b/climada/engine/unsequa/input_var.py index 76e63d766e..9abdd8d3fa 100644 --- a/climada/engine/unsequa/input_var.py +++ b/climada/engine/unsequa/input_var.py @@ -518,7 +518,7 @@ def ent( exp_list : [climada.entity.exposures.base.Exposure] The list of base exposure. Can be one or many to uniformly sample from. - meas_set : climada.entity.measures.measure_set.MeasureSet + meas_set : climada.entity._legacy_measures.measure_set.MeasureSet The base measures. haz_id_dict : dict Dictionary of the impact functions affected by uncertainty. @@ -660,7 +660,7 @@ def entfut( exp_list : [climada.entity.exposures.base.Exposure] The list of base exposure. Can be one or many to uniformly sample from. - meas_set : climada.entity.measures.measure_set.MeasureSet + meas_set : climada.entity._legacy_measures.measure_set.MeasureSet The base measures. haz_id_dict : dict Dictionary of the impact functions affected by uncertainty. diff --git a/climada/entity/_legacy_measures/measure_set.py b/climada/entity/_legacy_measures/measure_set.py index 90a2bb43c2..228788ba15 100755 --- a/climada/entity/_legacy_measures/measure_set.py +++ b/climada/entity/_legacy_measures/measure_set.py @@ -32,7 +32,8 @@ from matplotlib import colormaps as cm import climada.util.hdf5_handler as u_hdf5 -from climada.entity.measures.base import Measure + +from .base import Measure LOGGER = logging.getLogger(__name__) diff --git a/climada/entity/_legacy_measures/test/test_base.py b/climada/entity/_legacy_measures/test/test_base.py index 6f76eb7373..430ab7d44b 100644 --- a/climada/entity/_legacy_measures/test/test_base.py +++ b/climada/entity/_legacy_measures/test/test_base.py @@ -28,12 +28,12 @@ import climada.entity.exposures.test as exposures_test import climada.util.coordinates as u_coord from climada import CONFIG +from climada.entity._legacy_measures.base import IMPF_ID_FACT, Measure +from climada.entity._legacy_measures.measure_set import MeasureSet from climada.entity.entity_def import Entity from climada.entity.exposures.base import Exposures from climada.entity.impact_funcs.base import ImpactFunc from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet -from climada.entity.measures.base import IMPF_ID_FACT, Measure -from climada.entity.measures.measure_set import MeasureSet from climada.hazard.base import Hazard from climada.test import get_test_file from climada.util.constants import HAZ_DEMO_H5 diff --git a/climada/entity/_legacy_measures/test/test_meas_set.py b/climada/entity/_legacy_measures/test/test_meas_set.py index a2cbdc3f16..868510fbe8 100644 --- a/climada/entity/_legacy_measures/test/test_meas_set.py +++ b/climada/entity/_legacy_measures/test/test_meas_set.py @@ -24,8 +24,8 @@ import numpy as np from climada import CONFIG -from climada.entity.measures.base import Measure -from climada.entity.measures.measure_set import MeasureSet +from climada.entity._legacy_measures.base import Measure +from climada.entity._legacy_measures.measure_set import MeasureSet from climada.util.constants import ENT_DEMO_TODAY, ENT_TEMPLATE_XLS DATA_DIR = CONFIG.measures.test_data.dir() @@ -58,7 +58,7 @@ def test_add_wrong_error(self): """Test error is raised when wrong ImpactFunc provided.""" meas = MeasureSet() with self.assertLogs( - "climada.entity.measures.measure_set", level="WARNING" + "climada.entity._legacy_measures.measure_set", level="WARNING" ) as cm: meas.append(Measure()) self.assertIn("Input Measure's hazard type not set.", cm.output[0]) @@ -76,7 +76,9 @@ def test_remove_measure_pass(self): def test_remove_wrong_error(self): """Test error is raised when invalid inputs.""" meas = MeasureSet(measure_list=[Measure(name="Mangrove", haz_type="FL")]) - with self.assertLogs("climada.entity.measures.measure_set", level="INFO") as cm: + with self.assertLogs( + "climada.entity._legacy_measures.measure_set", level="INFO" + ) as cm: meas.remove_measure(name="Seawall") self.assertIn("No Measure with name Seawall.", cm.output[0]) diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py new file mode 100644 index 0000000000..073e6a1455 --- /dev/null +++ b/climada/entity/measures/measure_config.py @@ -0,0 +1,633 @@ +""" +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 configuration dataclasses for Measure reading and writing. +""" + +from __future__ import annotations + +import logging +import warnings +from abc import ABC +from dataclasses import asdict, dataclass, field, fields +from datetime import datetime +from typing import Dict, Optional, Tuple, Union + +import numpy as np +import pandas as pd + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class _ModifierConfig(ABC): + """ + Abstract base class for all modifier configuration dataclasses. + + Provides shared serialization, deserialization, and representation + logic for all concrete modifier config subclasses. Not intended to + be instantiated directly. + """ + + def _filter_out_default_fields(self): + """ + Partition the instance's fields into non-default and default groups. + + The ``haz_type`` field is always excluded from the output, as it + is managed at the ``MeasureConfig`` level. + + Returns + ------- + non_defaults : dict + Fields whose current value differs from the dataclass default. + defaults : dict + Fields whose current value equals the dataclass default. + """ + + non_defaults = {} + defaults = {} + for defined_field in fields(self): + val = getattr(self, defined_field.name) + default = defined_field.default + if defined_field.default_factory is not field().default_factory: + default = defined_field.default_factory() + + if val != default: + non_defaults[defined_field.name] = val + else: + defaults[defined_field.name] = val + + if "haz_type" in non_defaults: + non_defaults.pop("haz_type") + return non_defaults, defaults + + def to_dict(self): + """ + Serialize the config to a flat dictionary, omitting default values. + + The ``haz_type`` field is always excluded from the output, as it + is managed at the ``MeasureConfig`` level. + + Returns + ------- + dict + Dictionary containing only fields whose values differ from + their dataclass defaults. + """ + non_default, _ = self._filter_out_default_fields() + return non_default + + @classmethod + def from_dict(cls, kwargs_dict: dict): + """ + Instantiate a config from a dictionary, ignoring unknown keys. + + Parameters + ---------- + kwargs_dict : dict + Input dictionary. Keys not matching any dataclass field are + silently discarded. + + Returns + ------- + _ModifierConfig + A new instance of the calling subclass. + """ + + filtered = cls._filter_dict_to_fields(kwargs_dict) + return cls(**filtered) + + @classmethod + def _filter_dict_to_fields(cls, to_filter: dict): + """ + Filter a dictionary to only the keys matching the dataclass fields. + + Parameters + ---------- + to_filter : dict + Input dictionary, potentially containing extra keys. + + Returns + ------- + dict + A copy of ``to_filter`` restricted to keys that correspond to declared + dataclass fields on this class. + """ + + filtered = dict( + filter(lambda k: k[0] in [f.name for f in fields(cls)], to_filter.items()) + ) + return filtered + + def __repr__(self) -> str: + """ + Return a human-readable representation highlighting non-default fields. + + Non-default fields are shown prominently; default fields are shown + below them. This makes it easy to see at a glance what has been + configured on an instance. + + Returns + ------- + str + A formatted string representation of the instance. + """ + + non_defaults, defaults = self._filter_out_default_fields() + ndf_fields_str = ( + "\n\t\t\t".join(f"{k}={v!r}" for k, v in non_defaults.items()) + if non_defaults + else None + ) + _ = ( + "\n\t\t\t".join(f"{k}={v!r}" for k, v in defaults.items()) + if defaults + else None + ) + ndf_fields = ( + "(" "\n\t\tNon default fields:" f"\n\t\t\t{ndf_fields_str}" + if ndf_fields_str + else "()" + ) + return f"{self.__class__.__name__}{ndf_fields}" + + +@dataclass(repr=False) +class ImpfsetModifierConfig(_ModifierConfig): + """ + Configuration for modifications to an impact function set. + + Supports scaling or shifting MDD, PAA, and intensity curves, as well + as replacement of the impact function set, loaded from a file path. If + both a new file path and modifier values are provided, modifiers are + applied after the replacement (and a warning is issued). + + Parameters + ---------- + haz_type : str + Hazard type identifier (e.g. ``"TC"``) that this modifier targets. + impf_ids : int or str or list of int or str, optional + Impact function ID(s) to which modifications are applied. + If ``None``, all impact functions are affected. + impf_mdd_mult : float, optional + Multiplicative factor applied to the mean damage degree (MDD) curve. + Default is ``1.0`` (no change). + impf_mdd_add : float, optional + Additive offset applied to the MDD curve after multiplication. + Default is ``0.0``. + impf_paa_mult : float, optional + Multiplicative factor applied to the percentage of affected assets + (PAA) curve. Default is ``1.0``. + impf_paa_add : float, optional + 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. + Default is ``1.0``. + impf_int_add : float, optional + Additive offset applied to the intensity axis after multiplication. + Default is ``0.0``. + new_impfset_path : str, optional + Path to an Excel file containing a replacement impact function set. + If provided alongside modifier values, a warning is issued and + modifiers are applied after loading the new set. + + Warns + ----- + UserWarning + If ``new_impfset_path`` is set alongside any non-default modifier + values. + """ + + haz_type: str + impf_ids: Optional[Union[int, str, list[Union[int, str]]]] = None + impf_mdd_mult: float = 1.0 + impf_mdd_add: float = 0.0 + impf_paa_mult: float = 1.0 + impf_paa_add: float = 0.0 + impf_int_mult: float = 1.0 + impf_int_add: float = 0.0 + new_impfset_path: Optional[str] = None + + def __post_init__(self): + config = self.to_dict() + if "new_impfset_path" in config and any( + key in config + for key in [ + "impf_mdd_add", + "impf_mdd_mult", + "impf_paa_add", + "impf_paa_mult", + "impf_int_add", + "impf_int_mult", + ] + ): + warnings.warn( + "Both new impfset object and impfset modifiers are provided, " + "modifiers will be applied after changing the impfset." + ) + + +@dataclass(repr=False) +class HazardModifierConfig(_ModifierConfig): + """ + Configuration for modifications to a hazard. + + Supports scaling or shifting hazard intensity, applying a return-period + frequency cutoff, and replacement of the hazard, loaded from a file path. + If both a new file path and modifier values are provided, modifiers are + applied after the replacement. + + Parameters + ---------- + haz_type : str + Hazard type identifier (e.g. ``"TC"``) that this modifier targets. + haz_int_mult : float, optional + Multiplicative factor applied to hazard intensity. + Default is ``1.0`` (no change). + haz_int_add : float, optional + Additive offset applied to hazard intensity after multiplication. + Default is ``0.0``. + new_hazard_path : str, optional + Path to an HDF5 file containing a replacement hazard. + If provided alongside modifier values, a warning is issued and + modifiers are applied after loading the new hazard. + impact_rp_cutoff : float, optional + Return period (in years) below which hazard events are discarded. + If ``None``, no cutoff is applied. + + Warns + ----- + UserWarning + If ``new_hazard_path`` is set alongside any non-default modifier + values or a non-``None`` ``impact_rp_cutoff``. + """ + + haz_type: str + haz_int_mult: Optional[float] = 1.0 + haz_int_add: Optional[float] = 0.0 + new_hazard_path: Optional[str] = None + impact_rp_cutoff: Optional[float] = None + + def __post_init__(self): + config = self.to_dict() + if "new_hazard_path" in config and any( + key in config for key in ["haz_int_mult", "haz_int_add", "impact_rp_cutoff"] + ): + warnings.warn( + "Both new hazard object and hazard modifiers are provided, " + "modifiers will be applied after changing the hazard." + ) + + +@dataclass(repr=False) +class ExposuresModifierConfig(_ModifierConfig): + """ + Configuration for modifications to an exposures object. + + Supports remapping impact function IDs, zeroing out selected regions, + and replacement of the exposures from a new file. If both a new + file path and modifier values are provided, modifiers are applied after + the replacement. + + Parameters + ---------- + reassign_impf_id : dict of {str: dict of {int or str: int or str}}, optional + Nested mapping ``{haz_type: {old_id: new_id}}`` used to reassign + impact function IDs in the exposures. If ``None``, no remapping + is performed. + set_to_zero : list of int, optional + Region IDs for which exposure values are set to zero. + If ``None``, no zeroing is applied. + new_exposures_path : str, optional + Path to an HDF5 file containing replacement exposures. + If provided alongside modifier values, a warning is issued and + modifiers are applied after loading the new exposures. + + Warns + ----- + UserWarning + If ``new_exposures_path`` is set alongside any non-``None`` + modifier values. + """ + + reassign_impf_id: Optional[Dict[str, Dict[int | str, int | str]]] = None + set_to_zero: Optional[list[int]] = None + new_exposures_path: Optional[str] = None + + def __post_init__(self): + config = self.to_dict() + if "new_exposures_path" in config and any( + key in config for key in ["reassign_impf_id", "set_to_zero"] + ): + warnings.warn( + "Both new exposures object and exposures modifiers are provided, " + "modifiers will be applied after changing the exposures." + ) + + +@dataclass(repr=False) +class CostIncomeConfig(_ModifierConfig): + """ + Serializable configuration for a ``CostIncome`` object. + + Encodes all parameters required to construct a ``CostIncome`` instance, + including optional custom cash flow schedules. + + Parameters + ---------- + mkt_price_year : int, optional + Reference year for market prices. Defaults to the current year. + init_cost : float, optional + One-time initial investment cost (positive value). Default is ``0.0``. + periodic_cost : float, optional + Recurring cost per period (positive value). Default is ``0.0``. + periodic_income : float, optional + Recurring income per period. Default is ``0.0``. + cost_yearly_growth_rate : float, optional + Annual growth rate applied to periodic costs. Default is ``0.0``. + income_yearly_growth_rate : float, optional + Annual growth rate applied to periodic income. Default is ``0.0``. + freq : str, optional + Pandas offset alias defining the period length (e.g. ``"Y"`` for + yearly, ``"M"`` for monthly). Default is ``"Y"``. + custom_cash_flows : list of dict, optional + 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. + """ + + mkt_price_year: Optional[int] = field(default_factory=lambda: datetime.today().year) + init_cost: float = 0.0 + periodic_cost: float = 0.0 + periodic_income: float = 0.0 + cost_yearly_growth_rate: float = 0.0 + income_yearly_growth_rate: float = 0.0 + freq: str = "Y" + custom_cash_flows: Optional[list[dict]] = None + + @classmethod + def from_cost_income(cls, cost_income: "CostIncome") -> "CostIncomeConfig": + """ + Construct a :class:`CostIncomeConfig` from a live + :class:`CostIncome` object. + + Parameters + ---------- + cost_income : CostIncome + The live ``CostIncome`` instance to serialise. + + Returns + ------- + CostIncomeConfig + The config instance equivalent to the ``CostIncome``. + """ + + custom = None + if cost_income.custom_cash_flows is not None: + custom = ( + cost_income.custom_cash_flows.reset_index() + .rename(columns={"index": "date"}) + .assign(date=lambda df: df["date"].dt.strftime("%Y-%m-%d")) + .to_dict(orient="records") + ) + return cls( + mkt_price_year=cost_income.mkt_price_year.year, # datetime → int + init_cost=abs(cost_income.init_cost), # stored negative → positive + periodic_cost=abs(cost_income.periodic_cost), + periodic_income=cost_income.periodic_income, + cost_yearly_growth_rate=cost_income.cost_growth_rate, + income_yearly_growth_rate=cost_income.income_growth_rate, + freq=cost_income.freq, + custom_cash_flows=custom, + ) + + +@dataclass(repr=False) +class MeasureConfig(_ModifierConfig): + """ + Top-level serializable configuration for a single adaptation measure. + + Aggregates all modifier sub-configs (hazard, impact functions, exposures, + cost/income) into a single object that can be round-tripped through dict, + YAML, or a legacy Excel row. + + This class is the primary entry point for defining measures in a + declarative, file-based workflow and serves as the serialization + counterpart to :class:`~climada.entity.measures.base.Measure`. + + Parameters + ---------- + name : str + Unique name identifying this measure. + haz_type : str + Hazard type identifier (e.g. ``"TC"``) this measure is designed for. + impfset_modifier : ImpfsetModifierConfig + Configuration describing modifications to the impact function set. + hazard_modifier : HazardModifierConfig + Configuration describing modifications to the hazard. + exposures_modifier : ExposuresModifierConfig + Configuration describing modifications to the exposures. + cost_income : CostIncomeConfig + Financial parameters associated with implementing this measure. + implementation_duration : str, optional + Pandas offset alias (e.g. ``"2Y"``) representing the time before + the measure is fully operational. If ``None``, the measure takes + effect immediately. + color_rgb : tuple of float, optional + RGB colour triple in the range ``[0, 1]`` used for visualisation. + If ``None``, defaults to black ``(0, 0, 0)``. + """ + + name: str + haz_type: str + impfset_modifier: ImpfsetModifierConfig + hazard_modifier: HazardModifierConfig + exposures_modifier: ExposuresModifierConfig + cost_income: CostIncomeConfig + implementation_duration: Optional[str] = None + color_rgb: Optional[Tuple[float, float, float]] = None + + def __repr__(self) -> str: + """ + Return a detailed string representation of the measure configuration. + + All fields are shown, including sub-configs, with each on its own + indented line. + + Returns + ------- + str + A formatted multi-line string representation. + """ + + fields_str = "\n\t".join(f"{k}={v!r}" for k, v in self.__dict__.items()) + return f"{self.__class__.__name__}(\n\t{fields_str})" + + def to_dict(self) -> dict: + """ + Serialize the measure configuration to a flat dictionary. + + Sub-config dictionaries are merged into the top-level dict (i.e. + their keys are inlined, not nested). ``haz_type`` is always included + at the top level. Fields with ``None`` values are preserved. + + Returns + ------- + dict + Flat dictionary representation suitable for YAML or Excel + serialization. + """ + + return { + "name": self.name, + "haz_type": self.haz_type, + **self.impfset_modifier.to_dict(), + **self.hazard_modifier.to_dict(), + **self.exposures_modifier.to_dict(), + **self.cost_income.to_dict(), + "implementation_duration": self.implementation_duration, + "color_rgb": list(self.color_rgb) if self.color_rgb is not None else None, + } + + @classmethod + def from_dict(cls, kwargs_dict: dict) -> "MeasureConfig": + """ + Instantiate a :class:`MeasureConfig` from a flat dictionary. + + Delegates sub-config construction to the respective + ``from_dict`` classmethods. Unknown keys are silently discarded + by each sub-config parser. + + Parameters + ---------- + kwargs_dict : dict + Flat dictionary, as produced by :meth:`to_dict` or read from + a legacy Excel row. Must contain at minimum ``"name"`` and + ``"haz_type"``. + + Returns + ------- + MeasureConfig + A fully populated configuration instance. + """ + + return cls( + name=kwargs_dict["name"], + haz_type=kwargs_dict["haz_type"], + impfset_modifier=ImpfsetModifierConfig.from_dict(kwargs_dict), + hazard_modifier=HazardModifierConfig.from_dict(kwargs_dict), + exposures_modifier=ExposuresModifierConfig.from_dict(kwargs_dict), + cost_income=CostIncomeConfig.from_dict(kwargs_dict), + implementation_duration=kwargs_dict.get("implementation_duration"), + color_rgb=cls._normalize_color(kwargs_dict.get("color_rgb")), + ) + + @staticmethod + def _normalize_color(color_rgb): + # 1. Handle None and NaN (np.nan, pd.NA, float('nan')) + if color_rgb is None or pd.isna(color_rgb) is True: + return None + + # 2. Convert sequence types (list, np.array, tuple) to a standard tuple + try: + # Flatten in case it's a nested numpy array, then convert to tuple + result = tuple(np.array(color_rgb).flatten().tolist()) + + # 3. Enforce the length of three + if len(result) != 3: + raise ValueError(f"Expected 3 digits, got {len(result)}") + + return result + + except (TypeError, ValueError) as err: + # Handle cases where input isn't iterable or wrong length + raise ValueError(f"Invalid color format: {color_rgb}.") from err + + def to_yaml(self, path: str) -> None: + """ + Write this configuration to a YAML file. + + The file is structured as ``{"measures": []}``, + matching the expected format for :meth:`from_yaml`. + + Parameters + ---------- + path : str + Destination file path. Will be created or overwritten. + """ + + import yaml + + with open(path, "w") as opened_file: + yaml.dump( + {"measures": [self.to_dict()]}, + opened_file, + default_flow_style=False, + sort_keys=False, + ) + + @classmethod + def from_yaml(cls, path: str) -> "MeasureConfig": + """ + Load a :class:`MeasureConfig` from a YAML file. + + Expects the file to contain a top-level ``"measures"`` list; reads + only the first entry. + + Parameters + ---------- + path : str + Path to the YAML file to read. + + Returns + ------- + MeasureConfig + The configuration parsed from the first entry in + ``measures``. + """ + + import yaml + + with open(path) as opened_file: + return cls.from_dict(yaml.safe_load(opened_file)["measures"][0]) + + @classmethod + def from_row(cls, row: pd.Series) -> "MeasureConfig": + """ + Construct a :class:`MeasureConfig` from a legacy Excel row. + + Converts the row to a dictionary and delegates to :meth:`from_dict`. + This is the primary migration path for measures currently stored in + the legacy Excel-based ``MeasureSet`` format. + + Parameters + ---------- + row : pd.Series + A single row from a legacy measures Excel sheet, with column + names matching the flat dictionary keys expected by + :meth:`from_dict`. + + Returns + ------- + MeasureConfig + A configuration instance populated from the row data. + """ + + row_dict = row.to_dict() + return cls.from_dict(row_dict) diff --git a/climada/trajectories/calc_risk_metrics.py b/climada/trajectories/calc_risk_metrics.py index 902fffc2ba..8e5ae8b150 100644 --- a/climada/trajectories/calc_risk_metrics.py +++ b/climada/trajectories/calc_risk_metrics.py @@ -32,7 +32,7 @@ import pandas as pd from climada.engine.impact import Impact -from climada.entity.measures.base import Measure +from climada.entity._legacy_measures.base import Measure from climada.trajectories.constants import ( AAI_METRIC_NAME, COORD_ID_COL_NAME, diff --git a/climada/trajectories/test/test_calc_risk_metrics.py b/climada/trajectories/test/test_calc_risk_metrics.py index 9c75f78fb4..6fc530d065 100644 --- a/climada/trajectories/test/test_calc_risk_metrics.py +++ b/climada/trajectories/test/test_calc_risk_metrics.py @@ -26,10 +26,10 @@ import pandas as pd import pytest +from climada.entity._legacy_measures.base import Measure from climada.entity.exposures import Exposures from climada.entity.impact_funcs import ImpactFuncSet from climada.entity.impact_funcs.trop_cyclone import ImpfTropCyclone -from climada.entity.measures.base import Measure from climada.hazard import Hazard from climada.trajectories.calc_risk_metrics import CalcRiskMetricsPoints from climada.trajectories.constants import ( From 78c49689b20fd4cde70f1a69a655fd2dcccf9807 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 7 Apr 2026 16:26:34 +0200 Subject: [PATCH 11/37] tests again --- .../measures/test/test_measure_config.py | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 climada/entity/measures/test/test_measure_config.py diff --git a/climada/entity/measures/test/test_measure_config.py b/climada/entity/measures/test/test_measure_config.py new file mode 100644 index 0000000000..5f0d54badb --- /dev/null +++ b/climada/entity/measures/test/test_measure_config.py @@ -0,0 +1,515 @@ +""" +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 . + +--- + +Tests for MeasureConfig and related dataclasses. +""" + +# tests/entity/measures/test_measure_config.py + +import logging +import warnings +from datetime import datetime + +import pandas as pd +import pytest + +from climada.entity.measures.measure_config import ( + CostIncomeConfig, + ExposuresModifierConfig, + HazardModifierConfig, + ImpfsetModifierConfig, + MeasureConfig, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def minimal_measure_dict(): + return {"name": "seawall", "haz_type": "TC"} + + +@pytest.fixture +def full_measure_dict(): + return { + "name": "seawall", + "haz_type": "TC", + "haz_int_mult": 0.8, + "haz_int_add": -0.1, + "impf_mdd_mult": 0.9, + "impf_paa_mult": 0.95, + "impf_ids": [1, 2], + "reassign_impf_id": {"TC": {1: 3}}, + "set_to_zero": [10, 20], + "init_cost": 1000.0, + "periodic_cost": 50.0, + "color_rgb": [0.1, 0.5, 0.9], + "implementation_duration": "2Y", + } + + +@pytest.fixture +def basic_impfset_config(): + return ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.9) + + +@pytest.fixture +def basic_hazard_config(): + return HazardModifierConfig(haz_type="TC", haz_int_mult=0.8) + + +@pytest.fixture +def basic_exposures_config(): + return ExposuresModifierConfig(reassign_impf_id={"TC": {1: 2}}) + + +@pytest.fixture +def basic_cost_income_config(): + return CostIncomeConfig(init_cost=1000.0, periodic_cost=50.0) + + +@pytest.fixture +def full_measure_config(full_measure_dict): + return MeasureConfig.from_dict(full_measure_dict) + + +# --------------------------------------------------------------------------- +# _ModifierConfig (via concrete subclasses) +# --------------------------------------------------------------------------- + + +def test_modifier_config_to_dict_omits_defaults(): + config = ImpfsetModifierConfig(haz_type="TC") + result = config.to_dict() + assert result == {} + + +def test_modifier_config_to_dict_includes_non_defaults(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5, impf_paa_add=0.1) + result = config.to_dict() + assert result["impf_mdd_mult"] == 0.5 + assert result["impf_paa_add"] == 0.1 + + +def test_modifier_config_from_dict_ignores_unknown_keys(): + d = {"haz_type": "TC", "unknown_field": 99, "another_unknown": "foo"} + config = ImpfsetModifierConfig.from_dict(d) + assert config.haz_type == "TC" + assert not hasattr(config, "unknown_field") + + +def test_modifier_config_from_dict_roundtrip(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5, impf_paa_add=0.1) + d = {**config.to_dict(), "haz_type": "TC"} + recovered = ImpfsetModifierConfig.from_dict(d) + assert recovered.impf_mdd_mult == config.impf_mdd_mult + assert recovered.impf_paa_add == config.impf_paa_add + + +def test_modifier_config_filter_dict_to_fields_filters_extra_keys(): + d = {"haz_type": "TC", "impf_mdd_mult": 0.5, "not_a_field": 123} + filtered = ImpfsetModifierConfig._filter_dict_to_fields(d) + assert "not_a_field" not in filtered + assert "haz_type" in filtered + assert "impf_mdd_mult" in filtered + assert filtered["haz_type"] == "TC" + assert filtered["impf_mdd_mult"] == 0.5 + + +def test_modifier_config_filter_out_default_fields_partitions_correctly(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5) + non_defaults, defaults = config._filter_out_default_fields() + assert "impf_mdd_mult" in non_defaults + assert "impf_mdd_mult" not in defaults + assert "impf_paa_mult" in defaults + assert "impf_paa_mult" not in non_defaults + from dataclasses import fields + + all_field_names = {f.name for f in fields(config) if f.name != "haz_type"} + assert set(non_defaults) | set(defaults) == all_field_names + + +def test_modifier_config_repr_shows_non_defaults_prominently(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5) + r = repr(config) + assert "Non default fields" in r + assert "impf_mdd_mult" in r + + +def test_modifier_config_repr_empty_when_all_defaults(): + config = ImpfsetModifierConfig(haz_type="TC") + r = repr(config) + assert "Non default fields" not in r + + +# --------------------------------------------------------------------------- +# ImpfsetModifierConfig +# --------------------------------------------------------------------------- + + +def test_impfset_modifier_config_defaults(): + config = ImpfsetModifierConfig(haz_type="TC") + assert config.impf_ids is None + assert config.impf_mdd_mult == 1.0 + assert config.impf_mdd_add == 0.0 + assert config.impf_paa_mult == 1.0 + assert config.impf_paa_add == 0.0 + assert config.impf_int_mult == 1.0 + assert config.impf_int_add == 0.0 + assert config.new_impfset_path is None + + +def test_impfset_modifier_config_from_dict_roundtrip(): + config = ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.8, impf_ids=[1, 2]) + d = {**config.to_dict(), "haz_type": "TC"} + recovered = ImpfsetModifierConfig.from_dict(d) + assert recovered.impf_mdd_mult == config.impf_mdd_mult + assert recovered.impf_ids == config.impf_ids + + +def test_impfset_modifier_config_to_dict_roundtrip(): + d = {"haz_type": "TC", "impf_mdd_mult": 0.8, "impf_paa_add": 0.05} + config = ImpfsetModifierConfig.from_dict(d) + result = {**config.to_dict(), "haz_type": "TC"} + assert result["impf_mdd_mult"] == d["impf_mdd_mult"] + assert result["impf_paa_add"] == d["impf_paa_add"] + + +def test_impfset_modifier_config_warns_when_path_and_modifiers_combined(): + with pytest.warns(UserWarning): + ImpfsetModifierConfig( + haz_type="TC", + new_impfset_path="path/to/file.xlsx", + impf_mdd_mult=0.5, + ) + + +def test_impfset_modifier_config_no_warning_when_only_path(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + ImpfsetModifierConfig(haz_type="TC", new_impfset_path="path/to/file.xlsx") + + +def test_impfset_modifier_config_no_warning_when_only_modifiers(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + ImpfsetModifierConfig(haz_type="TC", impf_mdd_mult=0.5) + + +def test_impfset_modifier_config_impf_ids_accepts_int(): + config = ImpfsetModifierConfig(haz_type="TC", impf_ids=1) + assert config.impf_ids == 1 + + +def test_impfset_modifier_config_impf_ids_accepts_str(): + config = ImpfsetModifierConfig(haz_type="TC", impf_ids="1") + assert config.impf_ids == "1" + + +def test_impfset_modifier_config_impf_ids_accepts_list(): + config = ImpfsetModifierConfig(haz_type="TC", impf_ids=[1, 2, "3"]) + assert config.impf_ids == [1, 2, "3"] + + +def test_impfset_modifier_config_impf_ids_accepts_none(): + config = ImpfsetModifierConfig(haz_type="TC", impf_ids=None) + assert config.impf_ids is None + + +# --------------------------------------------------------------------------- +# HazardModifierConfig +# --------------------------------------------------------------------------- + + +def test_hazard_modifier_config_defaults(): + config = HazardModifierConfig(haz_type="TC") + assert config.haz_int_mult == 1.0 + assert config.haz_int_add == 0.0 + assert config.new_hazard_path is None + assert config.impact_rp_cutoff is None + + +def test_hazard_modifier_config_from_dict_roundtrip(): + config = HazardModifierConfig(haz_type="TC", haz_int_mult=0.8, haz_int_add=-0.1) + d = {**config.to_dict(), "haz_type": "TC"} + recovered = HazardModifierConfig.from_dict(d) + assert recovered.haz_int_mult == config.haz_int_mult + assert recovered.haz_int_add == config.haz_int_add + + +def test_hazard_modifier_config_to_dict_roundtrip(): + d = {"haz_type": "TC", "haz_int_mult": 0.7, "haz_int_add": -0.2} + config = HazardModifierConfig.from_dict(d) + result = {**config.to_dict(), "haz_type": "TC"} + assert result["haz_int_mult"] == d["haz_int_mult"] + assert result["haz_int_add"] == d["haz_int_add"] + + +def test_hazard_modifier_config_warns_when_path_and_modifiers_combined(): + with pytest.warns(UserWarning): + HazardModifierConfig( + haz_type="TC", + new_hazard_path="path/to/hazard.h5", + haz_int_mult=0.5, + ) + + +def test_hazard_modifier_config_warns_when_path_and_rp_cutoff_combined(): + with pytest.warns(UserWarning): + HazardModifierConfig( + haz_type="TC", + new_hazard_path="path/to/hazard.h5", + impact_rp_cutoff=100.0, + ) + + +def test_hazard_modifier_config_no_warning_when_only_path(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + HazardModifierConfig(haz_type="TC", new_hazard_path="path/to/hazard.h5") + + +def test_hazard_modifier_config_no_warning_when_only_modifiers(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + HazardModifierConfig(haz_type="TC", haz_int_mult=0.5) + + +# --------------------------------------------------------------------------- +# ExposuresModifierConfig +# --------------------------------------------------------------------------- + + +def test_exposures_modifier_config_defaults(): + config = ExposuresModifierConfig() + assert config.reassign_impf_id is None + assert config.set_to_zero is None + assert config.new_exposures_path is None + + +def test_exposures_modifier_config_from_dict_roundtrip(): + config = ExposuresModifierConfig( + reassign_impf_id={"TC": {1: 2}}, + set_to_zero=[10, 20], + ) + d = config.to_dict() + recovered = ExposuresModifierConfig.from_dict(d) + assert recovered.reassign_impf_id == config.reassign_impf_id + assert recovered.set_to_zero == config.set_to_zero + + +def test_exposures_modifier_config_to_dict_roundtrip(): + d = {"reassign_impf_id": {"TC": {1: 2}}, "set_to_zero": [5, 6]} + config = ExposuresModifierConfig.from_dict(d) + result = config.to_dict() + assert result["reassign_impf_id"] == d["reassign_impf_id"] + assert result["set_to_zero"] == d["set_to_zero"] + + +def test_exposures_modifier_config_warns_when_path_and_modifiers_combined(): + with pytest.warns(UserWarning): + ExposuresModifierConfig( + new_exposures_path="path/to/exp.h5", + reassign_impf_id={"TC": {1: 2}}, + ) + + +def test_exposures_modifier_config_no_warning_when_only_path(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + ExposuresModifierConfig(new_exposures_path="path/to/exp.h5") + + +def test_exposures_modifier_config_no_warning_when_only_modifiers(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + ExposuresModifierConfig(reassign_impf_id={"TC": {1: 2}}) + + +def test_exposures_modifier_config_reassign_impf_id_accepts_int_keys(): + config = ExposuresModifierConfig(reassign_impf_id={"TC": {1: 2}}) + assert config.reassign_impf_id == {"TC": {1: 2}} + + +def test_exposures_modifier_config_reassign_impf_id_accepts_str_keys(): + config = ExposuresModifierConfig(reassign_impf_id={"TC": {"1": "2"}}) + assert config.reassign_impf_id == {"TC": {"1": "2"}} + + +def test_exposures_modifier_config_set_to_zero_accepts_none(): + config = ExposuresModifierConfig(set_to_zero=None) + assert config.set_to_zero is None + + +def test_exposures_modifier_config_set_to_zero_accepts_list(): + config = ExposuresModifierConfig(set_to_zero=[1, 2, 3]) + assert config.set_to_zero == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# CostIncomeConfig +# --------------------------------------------------------------------------- + + +def test_cost_income_config_defaults(): + config = CostIncomeConfig() + assert config.init_cost == 0.0 + assert config.periodic_cost == 0.0 + assert config.periodic_income == 0.0 + assert config.cost_yearly_growth_rate == 0.0 + assert config.income_yearly_growth_rate == 0.0 + assert config.freq == "Y" + assert config.custom_cash_flows is None + + +def test_cost_income_config_default_mkt_price_year_is_current_year(): + config = CostIncomeConfig() + assert config.mkt_price_year == datetime.today().year + + +def test_cost_income_config_from_dict_roundtrip(): + config = CostIncomeConfig(init_cost=1000.0, periodic_cost=50.0, freq="M") + d = config.to_dict() + recovered = CostIncomeConfig.from_dict(d) + assert recovered.init_cost == config.init_cost + assert recovered.periodic_cost == config.periodic_cost + assert recovered.freq == config.freq + + +def test_cost_income_config_to_dict_roundtrip(): + d = {"init_cost": 500.0, "periodic_income": 20.0, "freq": "M"} + config = CostIncomeConfig.from_dict(d) + result = config.to_dict() + assert result["init_cost"] == d["init_cost"] + assert result["periodic_income"] == d["periodic_income"] + assert result["freq"] == d["freq"] + + +# --------------------------------------------------------------------------- +# MeasureConfig +# --------------------------------------------------------------------------- + + +def test_measure_config_from_dict_minimal(minimal_measure_dict): + config = MeasureConfig.from_dict(minimal_measure_dict) + assert config.name == "seawall" + assert config.haz_type == "TC" + assert config.impfset_modifier == ImpfsetModifierConfig(haz_type="TC") + assert config.hazard_modifier == HazardModifierConfig(haz_type="TC") + assert config.exposures_modifier == ExposuresModifierConfig() + assert config.cost_income == CostIncomeConfig() + + +def test_measure_config_from_dict_full(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + assert config.hazard_modifier.haz_int_mult == full_measure_dict["haz_int_mult"] + assert config.impfset_modifier.impf_mdd_mult == full_measure_dict["impf_mdd_mult"] + assert config.exposures_modifier.set_to_zero == full_measure_dict["set_to_zero"] + assert config.cost_income.init_cost == full_measure_dict["init_cost"] + assert config.color_rgb == tuple(full_measure_dict["color_rgb"]) + assert ( + config.implementation_duration == full_measure_dict["implementation_duration"] + ) + + +def test_measure_config_from_dict_ignores_unknown_keys(minimal_measure_dict): + d = {**minimal_measure_dict, "completely_unknown": 42} + config = MeasureConfig.from_dict(d) + assert config.name == "seawall" + assert not hasattr(config, "completely_unknown") + + +def test_measure_config_to_dict_roundtrip(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + recovered = MeasureConfig.from_dict(config.to_dict()) + assert recovered.name == config.name + assert recovered.haz_type == config.haz_type + assert recovered.hazard_modifier == config.hazard_modifier + assert recovered.impfset_modifier == config.impfset_modifier + assert recovered.exposures_modifier == config.exposures_modifier + assert recovered.color_rgb == config.color_rgb + assert recovered.implementation_duration == config.implementation_duration + + +def test_measure_config_to_dict_color_rgb_none(minimal_measure_dict): + config = MeasureConfig.from_dict(minimal_measure_dict) + result = config.to_dict() + assert result["color_rgb"] is None + + +def test_measure_config_to_dict_color_rgb_set(minimal_measure_dict): + config = MeasureConfig.from_dict( + {**minimal_measure_dict, "color_rgb": [0.1, 0.5, 0.9]} + ) + result = config.to_dict() + assert result["color_rgb"] == [0.1, 0.5, 0.9] + + +def test_measure_config_to_yaml_roundtrip(tmp_path, full_measure_dict): + path = str(tmp_path / "measure.yaml") + config = MeasureConfig.from_dict(full_measure_dict) + config.to_yaml(path) + recovered = MeasureConfig.from_yaml(path) + assert recovered.name == config.name + assert recovered.haz_type == config.haz_type + assert recovered.hazard_modifier == config.hazard_modifier + assert recovered.impfset_modifier == config.impfset_modifier + assert recovered.color_rgb == config.color_rgb + + +def test_measure_config_from_yaml_reads_first_entry(tmp_path, full_measure_dict): + import yaml + + second = {**full_measure_dict, "name": "second_measure"} + path = str(tmp_path / "measures.yaml") + with open(path, "w") as f: + yaml.dump({"measures": [full_measure_dict, second]}, f) + config = MeasureConfig.from_yaml(path) + assert config.name == full_measure_dict["name"] + + +def test_measure_config_from_row_roundtrip(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + row = pd.Series(config.to_dict()) + recovered = MeasureConfig.from_row(row) + assert recovered.name == config.name + assert recovered.hazard_modifier == config.hazard_modifier + assert recovered.impfset_modifier == config.impfset_modifier + + +def test_measure_config_from_row_ignores_extra_columns(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + d = {**config.to_dict(), "extra_column": "garbage"} + row = pd.Series(d) + recovered = MeasureConfig.from_row(row) + assert recovered.name == config.name + + +def test_measure_config_sub_configs_correctly_dispatched(full_measure_dict): + config = MeasureConfig.from_dict(full_measure_dict) + assert config.hazard_modifier.haz_int_mult == full_measure_dict["haz_int_mult"] + assert config.impfset_modifier.impf_mdd_mult == full_measure_dict["impf_mdd_mult"] + assert ( + config.exposures_modifier.reassign_impf_id + == full_measure_dict["reassign_impf_id"] + ) + assert config.cost_income.init_cost == full_measure_dict["init_cost"] + assert not hasattr(config.hazard_modifier, "impf_mdd_mult") + assert not hasattr(config.impfset_modifier, "haz_int_mult") From 108bcb2f88cb0850db38737a1d6787fc56fda2b1 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Wed, 8 Apr 2026 10:06:46 +0200 Subject: [PATCH 12/37] Updates changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e301836bd1..58775e3ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Code freeze date: YYYY-MM-DD ### Changed - Updated Impact Calculation Tutorial (`doc.climada_engine_Impact.ipynb`) [#1095](https://github.com/CLIMADA-project/climada_python/pull/1095). +- Makes current `measure` module a legacy module, moving it to `_legacy_measure`, to retain compatibility with `CostBenefit` class and various tests. [#1274](https://github.com/CLIMADA-project/climada_python/pull/1274) ### Fixed From c96a0617705fdeb27637e90a3aa901d5fd7038cf Mon Sep 17 00:00:00 2001 From: spjuhel Date: Wed, 8 Apr 2026 10:19:57 +0200 Subject: [PATCH 13/37] Adds freq modifiers --- climada/entity/measures/measure_config.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py index 073e6a1455..0ffb1f4cd3 100644 --- a/climada/entity/measures/measure_config.py +++ b/climada/entity/measures/measure_config.py @@ -281,13 +281,22 @@ class HazardModifierConfig(_ModifierConfig): haz_type: str haz_int_mult: Optional[float] = 1.0 haz_int_add: Optional[float] = 0.0 + haz_freq_mult: Optional[float] = 1.0 + haz_freq_add: Optional[float] = 0.0 new_hazard_path: Optional[str] = None impact_rp_cutoff: Optional[float] = None def __post_init__(self): config = self.to_dict() if "new_hazard_path" in config and any( - key in config for key in ["haz_int_mult", "haz_int_add", "impact_rp_cutoff"] + key in config + for key in [ + "haz_int_mult", + "haz_int_add", + "haz_freq_mult", + "haz_freq_add", + "impact_rp_cutoff", + ] ): warnings.warn( "Both new hazard object and hazard modifiers are provided, " From 4e416bc6127a34650a1b59e2dd77a75a8e807f7e Mon Sep 17 00:00:00 2001 From: spjuhel Date: Wed, 8 Apr 2026 16:02:38 +0200 Subject: [PATCH 14/37] Improves __repr__ --- climada/entity/measures/measure_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py index 0ffb1f4cd3..59f9278bba 100644 --- a/climada/entity/measures/measure_config.py +++ b/climada/entity/measures/measure_config.py @@ -160,7 +160,7 @@ def __repr__(self) -> str: else None ) ndf_fields = ( - "(" "\n\t\tNon default fields:" f"\n\t\t\t{ndf_fields_str}" + "(" "\n\t\tNon default fields:" f"\n\t\t\t{ndf_fields_str}" "\n)" if ndf_fields_str else "()" ) From 30bdce66bb4dc8c65ad9e5e3dd36d916647a8b61 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Wed, 8 Apr 2026 16:03:31 +0200 Subject: [PATCH 15/37] Tutorial first draft --- doc/user-guide/climada_entity_Exposures.ipynb | 1 + .../climada_entity_ImpactFuncSet.ipynb | 1 + doc/user-guide/climada_hazard_Hazard.ipynb | 1 + doc/user-guide/climada_measure_config.ipynb | 570 ++++++++++++++++++ 4 files changed, 573 insertions(+) create mode 100644 doc/user-guide/climada_measure_config.ipynb diff --git a/doc/user-guide/climada_entity_Exposures.ipynb b/doc/user-guide/climada_entity_Exposures.ipynb index aa1b39fd38..90a0c81ebe 100644 --- a/doc/user-guide/climada_entity_Exposures.ipynb +++ b/doc/user-guide/climada_entity_Exposures.ipynb @@ -4,6 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "(exposure-tutorial)=\n", "# Exposures class" ] }, diff --git a/doc/user-guide/climada_entity_ImpactFuncSet.ipynb b/doc/user-guide/climada_entity_ImpactFuncSet.ipynb index fd349487cd..ad1841a750 100644 --- a/doc/user-guide/climada_entity_ImpactFuncSet.ipynb +++ b/doc/user-guide/climada_entity_ImpactFuncSet.ipynb @@ -4,6 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "(impact-functions-tutorial)=\n", "# Impact Functions" ] }, diff --git a/doc/user-guide/climada_hazard_Hazard.ipynb b/doc/user-guide/climada_hazard_Hazard.ipynb index 412346d041..0b6bd40373 100644 --- a/doc/user-guide/climada_hazard_Hazard.ipynb +++ b/doc/user-guide/climada_hazard_Hazard.ipynb @@ -4,6 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "(hazard-tutorial)=\n", "# Hazard class\n", "\n", "## What is a hazard?\n", diff --git a/doc/user-guide/climada_measure_config.ipynb b/doc/user-guide/climada_measure_config.ipynb new file mode 100644 index 0000000000..8fa4739bac --- /dev/null +++ b/doc/user-guide/climada_measure_config.ipynb @@ -0,0 +1,570 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "659605a5-d601-47d3-89f7-b606e3e39c93", + "metadata": {}, + "source": [ + "(measure-config-tutorial)=\n", + "\n", + "# Defining Adaptation Measures with configurations" + ] + }, + { + "cell_type": "markdown", + "id": "c68c6cf1-d0a1-40ae-ae45-838741988ac6", + "metadata": {}, + "source": [ + "## Introduction\n", + "\n", + "CLIMADA uses `Measure` objects to model the effects of adaptation measures. `Measure` objects were formerly defined declaratively (via for instance, a shifting or scaling of the hazard intensity or a change of impact function), and are now defined as python functions to enable more flexibility on the possible changes (see the [tutorial on measure objects](measure-tutorial)'). \n", + "\n", + "The caveat of defining measure effects as python functions is that it cannot be serialized (written to a file), and also makes reading from a file a challenge.\n", + "\n", + "In order to retain close that gap, the `measure` module now ships `MeasureConfig` objects, which handle the reading, writing and \"declarative\" defining of `Measure` objects.\n", + "\n", + "`Measure` objects can be instantiated from `MeasureConfig` objects using `Measure.from_config()`.\n", + "\n", + "### Summary of `Measure` vs `MeasureConfig`\n", + "\n", + "| `Measure` | `MeasureConfig` |\n", + "|-----------|--------------------|\n", + "| Is used for the actual computation | Is transformed into a `Measure` for actual computation |\n", + "| Uses python function to define what change to apply to the `Exposures`, `ImpactFuncSet`, `Hazard` objects | Define the changes (functions) to apply via the former way (scaling/shifting effect, alternate file loading, etc.) |\n", + "| Accepts any possible effect as long as it can be defined as a python function | Is restricted to a set of defined effects |\n", + "| Cannot be written to a file (unless it was created by a `MeasureConfig`) | Can easily be read from/written to a file (`.xlsx` or `.yaml`) |" + ] + }, + { + "cell_type": "markdown", + "id": "6d786faa-5b8c-4ee6-83cd-5fdafc1b2c29", + "metadata": {}, + "source": [ + "### Configuration classes\n", + "\n", + "The definition of measures via `MeasureConfig` is organized into a hierarchy of specialized classes:\n", + "\n", + "- `MeasureConfig`: The top-level container for a single measure.\n", + "- `HazardModifierConfig`: Defines how the hazard is changed (e.g., shifting intensity).\n", + "- `ImpfsetModifierConfig`: Adjusts impact functions (e.g., scaling vulnerability curves).\n", + "- `ExposuresModifierConfig`: Modifies exposure data (e.g., reassigning IDs or zeroing regions).\n", + "- `CostIncomeConfig`: Handles the financial aspects, including initial costs and recurring income.\n", + "\n", + "Note that everything can be defined and accessed directly from the `MeasureConfig` container, the underlying ones are there to keep things organized.\n", + "\n", + "In the following we present each of these subclasses and the possibilities they offer." + ] + }, + { + "cell_type": "markdown", + "id": "4887d2a6-8295-4fda-8442-cbcbd3b16fea", + "metadata": {}, + "source": [ + "## Quickstart" + ] + }, + { + "cell_type": "markdown", + "id": "5c40640d-50a4-4102-8e45-0dc8b9a770f2", + "metadata": {}, + "source": [ + "You can directly define a `MeasureConfig` object with a dictionary, using `MeasureConfig.from_dict()`.\n", + "\n", + "Below are the possible parameters:\n", + "\n", + "| Scope | Parameter | Type | Description |\n", + "| :--- | :--- | :--- | :--- |\n", + "| **Top-Level** | `name` (required) | `str` | Unique identifier for the measure. |\n", + "| | `haz_type` (required) | `str` | The hazard type this measure targets (e.g., \"TC\", \"FL\"). |\n", + "| | `implementation_duration` | `str` | Pandas offset alias (e.g., \"2Y\") for implementation time. |\n", + "| | `color_rgb` | `tuple` | RGB triple (0-1 range) for plotting and visualization. |\n", + "| **Hazard** | `haz_int_mult` | `float` | Multiplier for hazard intensity (default: 1.0). |\n", + "| | `haz_int_add` | `float` | Additive offset for hazard intensity (default: 0.0). |\n", + "| | `new_hazard_path` | | Path to an HDF5 file to replace the current hazard. |\n", + "| | `impact_rp_cutoff` | `float` | Return period (years) threshold; events below this are ignored. |\n", + "| **Impact Function**| `impf_ids` | `list` | Specific impact function IDs to modify (None = all). |\n", + "| | `impf_mdd_mult` / `_add` | `float` | Scale or shift the Mean Damage Degree curve. |\n", + "| | `impf_paa_mult` / `_add` | `float` | Scale or shift the Percentage of Assets Affected curve. |\n", + "| | `impf_int_mult` / `_add` | `float` | Scale or shift the intensity axis of the function. |\n", + "| | `new_impfset_path` | | Path to an Excel file to replace the impact function set. |\n", + "| **Exposures** | `reassign_impf_id` | `dict` | Mapping `{haz_type: {old_id: new_id}}` for reclassification. |\n", + "| | `set_to_zero` | `list` | List of Region IDs where exposure value is set to 0. |\n", + "| | `new_exposures_path` | | Path to an HDF5 file to replace the current exposures. |\n", + "| **Cost & Income** | `init_cost` | `float` | One-time investment cost (absolute value). |\n", + "| | `periodic_cost` | `float` | Recurring maintenance/operational costs. |\n", + "| | `periodic_income` | `float` | Recurring income generated by the measure. |\n", + "| | `mkt_price_year` | `int` | Reference year for pricing (default: current year). |\n", + "| | `freq` | `str` | Frequency of cash flows (e.g., \"Y\" for yearly). |\n", + "| | `custom_cash_flows` | `list[dict]`| Explicit list of dates and values for complex cash flows. (See the [cost income tutorial](cost-income-tutorial)) |" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "62cf6502-7765-452c-be32-eb49a363b4a8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "MeasureConfig(\n", + "\tname='Tutorial measure'\n", + "\thaz_type='TC'\n", + "\timpfset_modifier=ImpfsetModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\timpf_ids=[1, 2]\n", + "\t\t\timpf_mdd_mult=0.8\n", + ")\n", + "\thazard_modifier=HazardModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\tnew_hazard_path='path/to/new_hazard.h5'\n", + ")\n", + "\texposures_modifier=ExposuresModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\treassign_impf_id={'TC': {1: 2}}\n", + ")\n", + "\tcost_income=CostIncomeConfig(\n", + "\t\tNon default fields:\n", + "\t\t\tinit_cost=10000\n", + "\t\t\tperiodic_cost=500\n", + ")\n", + "\timplementation_duration=None\n", + "\tcolor_rgb=(0.1, 0.5, 0.3))\n" + ] + } + ], + "source": [ + "from climada.entity.measures.measure_config import MeasureConfig\n", + "\n", + "measure_dict = {\n", + " \"name\": \"Tutorial measure\",\n", + " \"haz_type\": \"TC\",\n", + " \"impf_ids\": [1, 2],\n", + " \"impf_mdd_mult\": 0.8,\n", + " \"new_hazard_path\": \"path/to/new_hazard.h5\",\n", + " \"reassign_impf_id\": {\"TC\": {1: 2}},\n", + " \"color_rgb\": [0.1, 0.5, 0.3],\n", + " \"init_cost\": 10000,\n", + " \"periodic_cost\": 500,\n", + "}\n", + "\n", + "meas_config = MeasureConfig.from_dict(measure_dict)\n", + "\n", + "print(meas_config)" + ] + }, + { + "cell_type": "markdown", + "id": "ac98393f-575f-4580-ac4a-dae578638916", + "metadata": {}, + "source": [ + "## Modifying Impact Functions: `ImpfsetModifierConfig`\n", + "\n", + "The `ImpfsetModifierConfig` is used to define how an adaptation measure changes the vulnerability (refer to the [impact functions tutorial](impact-functions-tutorial)).\n", + "\n", + "When \"translated\" to a `Measure` object the `ImpfsetModifierConfig` populates the `impfset_change` attribute with a function that takes an `ImpactFuncSet` and returns a modified one, according to the specifications.\n", + "\n", + "```{note}\n", + "Modifications are always applied to a specific hazard type (`haz_type` parameter).\n", + "```\n", + "\n", + "`ImpfsetModifierConfig` allows you to modify the main components of an impact function set, as well as to replace it entirely:\n", + "\n", + "- The MDD (Mean Damage Degree) array: via `impf_mdd_mult` to scale it and `impf_mdd_add` to shift it.\n", + "- The PAA (Percentage of Assets Affected) array: via `impf_paa_mult` to scale it and `impf_paa_add` to shift it.\n", + "- The intensity array: via `impf_int_mult` to scale it and `impf_int_add` to shift it.\n", + "- Replacing the set: via providing the `new_impfset_path` parameter. It needs to be a valid `.xlsx` file readable by `ImpactFuncSet.from_excel()`\n", + "\n", + "See below for code examples.\n", + "\n", + "```{warning}\n", + "If you provide a new_impfset_path and other modifiers, CLIMADA will load the new file first and then apply the modifiers to it. (A warning will be issued to ensure this sequence is intended).\n", + "```\n", + "\n", + "```{note}\n", + "By default the changes are applied to all the impact functions in the set, but you can provide the `impf_ids` parameter to apply the changes to a selection of impact function ids.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5ffb447b-1b8f-4e40-9d1c-7db33a11255e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "--- Scaling Config ---\n", + "ImpfsetModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\timpf_ids=[1, 2]\n", + "\t\t\timpf_mdd_mult=0.8\n", + "\t\t\timpf_int_add=5.0\n", + ")\n", + "\n", + "--- Replacement Config ---\n", + "ImpfsetModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\tnew_impfset_path='path/to/new_impact_functions.xlsx'\n", + ")\n" + ] + } + ], + "source": [ + "from climada.entity.measures.measure_config import ImpfsetModifierConfig\n", + "\n", + "# 1. Scaling existing Impact Functions\n", + "# Let's say we want to simulate a 20% reduction in MDD\n", + "# and a slight shift in the intensity threshold for Hazard 'TC'.\n", + "impf_mod_scaling = ImpfsetModifierConfig(\n", + " haz_type=\"TC\",\n", + " impf_ids=[1, 2], # Apply only to specific function IDs\n", + " impf_mdd_mult=0.8, # Reduce Mean Damage Degree by 20%\n", + " impf_int_add=5.0, # Shift intensity axis by 5 units (e.g., higher resistance)\n", + ")\n", + "\n", + "print(\"--- Scaling Config ---\")\n", + "print(impf_mod_scaling)\n", + "\n", + "# 2. Replacing the Impact Function Set from a file\n", + "# Useful for measures that implement completely new building standards.\n", + "impf_mod_replace = ImpfsetModifierConfig(\n", + " haz_type=\"TC\", new_impfset_path=\"path/to/new_impact_functions.xlsx\"\n", + ")\n", + "\n", + "print(\"\\n--- Replacement Config ---\")\n", + "print(impf_mod_replace)" + ] + }, + { + "cell_type": "markdown", + "id": "234ebc89-83b0-42b1-8b97-734016306b84", + "metadata": {}, + "source": [ + "## Modifying Hazards: `HazardModifierConfig`\n", + "\n", + "The `HazardModifierConfig` is used to define how an adaptation measure changes the hazard (refer to the [hazard tutorial](hazard-tutorial)).\n", + "\n", + "When \"translated\" to a `Measure` object the `HazardModifierConfig` populates the `hazard_change` attribute with a function that takes a `Hazard` (possibly additional arguments, see below) and returns a modified one, according to the specifications.\n", + "\n", + "```{note}\n", + "Modifications are always applied to a specific hazard type (`haz_type` parameter).\n", + "```\n", + "\n", + "`HazardModifierConfig` allows you to modify the intensity and frequency of the hazard, to apply a cutoff on the return period of impacts, as well as to replace it entirely:\n", + "\n", + "- The intensity matrix: via `haz_int_mult` to scale it and `haz_int_add` to shift it.\n", + "- The frequency array: via `haz_freq_mult` to scale it and `haz_freq_add` to shift it.\n", + "- Replacing the hazard: via providing the `new_hazard_path` parameter. It needs to be a valid hazard HDF5 file readable by `Hazard.from_hdf5()`\n", + "- Applying a cutoff on frequency based on impacts: via `impact_rp_cutoff` (see the note).\n", + "\n", + "```{note}\n", + "Providing a value for `impact_rp_cutoff` \"removes\" (it sets their intensity to 0.) events from the hazard, for which the exceedance frequency (inverse of return period) of impacts is below the given threshold.\n", + "\n", + "For instance providing 1/20, would remove all events whose impacts have a return period below 20 years.\n", + "\n", + "In that case the function changing the hazard (`Measure.hazard_change`) will be a function with the following signature:\n", + "\n", + " f(hazard: Hazard, # The hazard to apply on\n", + " exposures: Exposures, # The exposure for the impact computation\n", + " impfset: ImpactFuncSet, # The impfset for the impact computation\n", + " base_hazard: Hazard, # The hazard for the impact computation\n", + " exposures_region_id: Optional[list[int]] = None, # Region id to filter to\n", + " ) -> Hazard\n", + "```\n", + "\n", + "```{warning}\n", + "If you provide a new_hazard_path and other modifiers, CLIMADA will load the new file first and then apply the modifiers to it. (A warning will be issued to ensure this sequence is intended).\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "f6061c1c-b21f-4aef-a394-c172784a25ab", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "--- Scaling Config ---\n", + "HazardModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\thaz_int_add=-10\n", + "\t\t\thaz_freq_mult=0.8\n", + ")\n", + "\n", + "--- Replacement Config ---\n", + "HazardModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\tnew_hazard_path='path/to/new_floods.h5'\n", + ")\n", + "\n", + "--- Cutoff Config ---\n", + "HazardModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\timpact_rp_cutoff=0.05\n", + ")\n" + ] + } + ], + "source": [ + "from climada.entity.measures.measure_config import HazardModifierConfig\n", + "\n", + "# 1. Scaling existing hazard\n", + "# Let's say we want to simulate a 20% reduction in frequency\n", + "# and a reduction by 10m/s in the intensity for our tropical cyclones.\n", + "haz_mod = HazardModifierConfig(\n", + " haz_type=\"TC\",\n", + " haz_int_add=-10, # Reduce hazard intensity by 10 units\n", + " haz_freq_mult=0.8, # Scale hazard frequency by 20%\n", + ")\n", + "\n", + "print(\"--- Scaling Config ---\")\n", + "print(haz_mod)\n", + "\n", + "# 2. Replacing the hazard from a file\n", + "# Useful for measures that correspond to a different hazard modelling.\n", + "# E.g., a dike leading to a change in (physical) flood modelling.\n", + "haz_mod_new = HazardModifierConfig(\n", + " haz_type=\"FL\", new_hazard_path=\"path/to/new_floods.h5\"\n", + ")\n", + "\n", + "print(\"\\n--- Replacement Config ---\")\n", + "print(haz_mod_new)\n", + "\n", + "# 3. Applying a cutoff on the return period of the impacts\n", + "# Useful when measures are defined to avoid damage for a specific RP (exceedance frequency).\n", + "# Note that it looks a the distribution of the impacts, not the hazard intensity!\n", + "haz_mod_cutoff = HazardModifierConfig(\n", + " haz_type=\"TC\",\n", + " impact_rp_cutoff=1\n", + " / 20, # Set intensity to 0 for events with impacts with a return period below 20 years\n", + ")\n", + "\n", + "print(\"\\n--- Cutoff Config ---\")\n", + "print(haz_mod_cutoff)" + ] + }, + { + "cell_type": "markdown", + "id": "c7499c1a-2491-42c4-bdbb-d224090b85fb", + "metadata": {}, + "source": [ + "## Modifying Exposures: `ExposuresModifierConfig`\n", + "\n", + "The `ExposuresModifierConfig` is used to define how an adaptation measure changes the exposure (refer to the [exposure tutorial](exposure-tutorial)).\n", + "\n", + "When \"translated\" to a `Measure` object the `ExposuresModifierConfig` populates the `exposures_change` attribute with a function that takes an `Exposures` and returns a modified one, according to the specifications.\n", + "\n", + "`ExposuresModifierConfig` allows you to modify the impact function assigned to different hazard, to set a list of points to 0 value, or to load a different Exposures:\n", + "\n", + "- Remapping the impact function: via `reassign_impf_id` with a dictionary of the form `{haz_type: {old_id: new_id}}`.\n", + "- Setting values to zero: via `set_to_zero` with a list of indices of the exposure GeoDataFrame.\n", + "- Replacing the exposure: via providing the `new_exposures_path` parameter. It need to be a valid HDF5 exposure file readable by `Exposures.from_hdf5()`\n", + "\n", + "```{warning}\n", + "If you provide a new_exposures_path and other modifiers, CLIMADA will load the new file first and then apply the modifiers to it. (A warning will be issued to ensure this sequence is intended).\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5237930d-a18c-4498-afe5-373c5dadf882", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "--- First Config ---\n", + "ExposuresModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\treassign_impf_id={'TC': {1: 2}}\n", + "\t\t\tset_to_zero=[0, 25, 78]\n", + ")\n", + "\n", + "--- Replacement Config ---\n", + "ExposuresModifierConfig(\n", + "\t\tNon default fields:\n", + "\t\t\tnew_exposures_path='path/to/exposures.h5'\n", + ")\n" + ] + } + ], + "source": [ + "from climada.entity.measures.measure_config import ExposuresModifierConfig\n", + "\n", + "# 1. Changing existing Exposures\n", + "exp_mod = ExposuresModifierConfig(\n", + " reassign_impf_id={\"TC\": {1: 2}}, # Remaps exposures points with impf_TC == 1 to 2.\n", + " set_to_zero=[\n", + " 0,\n", + " 25,\n", + " 78,\n", + " ], # Sets the value of exposure points with index 0, 25 and 78 to 0.\n", + ")\n", + "\n", + "print(\"--- First Config ---\")\n", + "print(exp_mod)\n", + "\n", + "# 2. Replacing the expoosure from a file\n", + "exp_mod_new = ExposuresModifierConfig(new_exposures_path=\"path/to/exposures.h5\")\n", + "\n", + "print(\"\\n--- Replacement Config ---\")\n", + "print(exp_mod_new)" + ] + }, + { + "cell_type": "markdown", + "id": "2c2d4488-28e5-4ced-b9cf-e4d5c0cade3e", + "metadata": {}, + "source": [ + "## Defining the financial aspects of the measure\n", + "\n", + "For in depth description of CostIncome objects, refer to the [related tutorial](cost-income-tutorial).\n", + "\n", + "```{note}\n", + "The default for mkt_price_year if not provided is the current year.\n", + "```\n", + "\n", + "You can easily define the CostIncome object to be associated with the measure using `CostIncomeConfig`:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "7c107fc5-606b-4904-8b8e-059f846c2e39", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "--- Growth & Income Config ---\n", + "CostIncomeConfig(\n", + "\t\tNon default fields:\n", + "\t\t\tinit_cost=500000.0\n", + "\t\t\tperiodic_cost=20000.0\n", + "\t\t\tperiodic_income=100000.0\n", + "\t\t\tcost_yearly_growth_rate=0.02\n", + "\t\t\tincome_yearly_growth_rate=0.03\n", + ")\n", + "\n", + "--- Custom Schedule Config ---\n", + "CostIncomeConfig(\n", + "\t\tNon default fields:\n", + "\t\t\tcustom_cash_flows=[{'date': '2024-01-01', 'value': -1000000}, {'date': '2029-01-01', 'value': -200000}, {'date': '2034-01-01', 'value': 500000}]\n", + ")\n" + ] + } + ], + "source": [ + "from climada.entity.measures.measure_config import CostIncomeConfig\n", + "\n", + "# This models a measure where costs increase by 2% annually,\n", + "# but it generates 100k in yearly income which grows by 3%.\n", + "growth_finance = CostIncomeConfig(\n", + " init_cost=500_000.0,\n", + " periodic_cost=20_000.0,\n", + " cost_yearly_growth_rate=0.02,\n", + " periodic_income=100_000.0,\n", + " income_yearly_growth_rate=0.03,\n", + " freq=\"Y\",\n", + ")\n", + "\n", + "print(\"\\n--- Growth & Income Config ---\")\n", + "print(growth_finance)\n", + "\n", + "\n", + "# Custom Cash Flow\n", + "# If the investment isn't linear (e.g., a major retrofit in year 5),\n", + "# you can define a list of specific events.\n", + "custom_schedule = [\n", + " {\"date\": \"2024-01-01\", \"value\": -1000000}, # Initial cost\n", + " {\"date\": \"2029-01-01\", \"value\": -200000}, # Mid-term overhaul\n", + " {\"date\": \"2034-01-01\", \"value\": 500000}, # Terminal value\n", + "]\n", + "\n", + "custom_finance = CostIncomeConfig(custom_cash_flows=custom_schedule)\n", + "\n", + "print(\"\\n--- Custom Schedule Config ---\")\n", + "print(custom_finance)" + ] + }, + { + "cell_type": "markdown", + "id": "ab4216dd-fd0b-4939-844d-56bd5ea49504", + "metadata": {}, + "source": [ + "## Reading from and writing to\n", + "\n", + "You can easily write/read measure configurations from YAML, as well as from pandas Series.\n", + "\n", + "You can also create `Measures`/`MeasureSet` directly, using the same methods (these methods first load the file as a `MeasureConfig` and convert it directly to a `Measure`)\n", + "Similarly you can still create `MeasureSet` from legacy Excel or matlab files using `MeasureSet.from_excel()` which takes care of remapping the legacy parameter names to the new ones.\n", + "See the [measure tutorial](measure-tutorial) for more details on that." + ] + }, + { + "cell_type": "markdown", + "id": "63132690-dd6f-4f45-96a9-5519fa2dec07", + "metadata": {}, + "source": [ + "\n", + "```python\n", + "import pandas as pd\n", + "from climada.entity.measures.measure_config import MeasureConfig\n", + "\n", + "# 1. Exporting to YAML\n", + "# Assuming 'my_measure_config' is a MeasureConfig object created previously\n", + "my_measure_config.to_yaml(\"seawall_config.yaml\")\n", + "\n", + "# 2. Loading from YAML\n", + "loaded_measure_config = MeasureConfig.from_yaml(\"seawall_config.yaml\")\n", + "\n", + "# 3. Loading from Pandas\n", + "row_data = pd.Series({\n", + " \"name\": \"Mangrove_Restoration\",\n", + " \"haz_type\": \"TC\",\n", + " \"impf_mdd_mult\": 0.7,\n", + " \"init_cost\": 250000,\n", + " \"color_rgb\": (0.1, 0.8, 0.1)\n", + "})\n", + "\n", + "pandas_measure_config = MeasureConfig.from_row(row_data)\n", + "\n", + "# 4. Measure object directly\n", + "measure = Measure.from_yaml(\"seawall_config.yaml\")\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" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From ff6733eb1ad166d55d55157cc3d347b1e44339f1 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 10:30:36 +0200 Subject: [PATCH 16/37] Docstringyfies and cleans up a bit --- climada/entity/measures/cost_income.py | 282 ++++++++++++++++++------- 1 file changed, 209 insertions(+), 73 deletions(-) diff --git a/climada/entity/measures/cost_income.py b/climada/entity/measures/cost_income.py index 564bbd251f..bda1d6ac2a 100644 --- a/climada/entity/measures/cost_income.py +++ b/climada/entity/measures/cost_income.py @@ -16,19 +16,17 @@ --- -Define the Cash Flows class. +Define the CostIncome class to handle the cash flow of measures. """ -# Define default discount rates -# DISC_RATES = DiscRates(years=np.arange(1900, 2100), rates=np.ones(np.arange(1900, 2100).size)) - from datetime import datetime -from typing import Any, List, Optional, Tuple +from typing import Any, List, Optional, Tuple, cast import matplotlib.pyplot as plt import numpy as np import pandas as pd +from climada.entity.disc_rates.base import DiscRates from climada.entity.measures.measure_config import CostIncomeConfig @@ -41,22 +39,22 @@ class CostIncome: Attributes ---------- - freq : str - Frequency of the cash flows (e.g., 'Y', 'M', 'D'). - mkt_price_year : datetime + mkt_price_year : datetime, default to today's year. The reference year for market prices. - cost_growth_rate : float - Yearly growth rate of costs. init_cost : float Initial implementation cost (stored as negative). periodic_cost : float Recurring cost per period (stored as negative). periodic_income : float Recurring income per period. - income_growth_rate : float + cost_yearly_growth_rate : float + Yearly growth rate of costs. + income_yearly_growth_rate : float Yearly growth rate of income. custom_cash_flows : pd.DataFrame, optional User-defined cash flows indexed by date. + freq : str + Frequency of the cash flows (e.g., 'Y', '3M', '7D'). """ def __init__( @@ -71,7 +69,28 @@ def __init__( custom_cash_flows: Optional[pd.DataFrame] = None, freq: str = "Y", ): - """Initialize CostIncome with parameters.""" + """Initialize CostIncome with parameters. + + Parameters + ---------- + mkt_price_year : datetime, default to today's year. + The reference year for market prices. + init_cost : float + Initial implementation cost (stored as negative). + periodic_cost : float + Recurring cost per period (stored as negative). + periodic_income : float + Recurring income per period. + cost_yearly_growth_rate : float + Yearly growth rate of costs. + income_yearly_growth_rate : float + Yearly growth rate of income. + custom_cash_flows : pd.DataFrame, optional + User-defined cash flows indexed by date. + freq : str + Frequency of the cash flows (e.g., 'Y', '3M', '7D'). + """ + self.freq = freq self.mkt_price_year = datetime(mkt_price_year or datetime.today().year, 1, 1) self.cost_growth_rate = cost_yearly_growth_rate @@ -88,7 +107,21 @@ def __init__( self.custom_cash_flows = None def _prepare_custom_flows(self, df: pd.DataFrame) -> pd.DataFrame: - """Process and resample custom cash flow dataframe.""" + """Process and resample custom cash flow dataframe + + Enforce costs as negative numbers and date to the correct frequency. + + Parameters + ---------- + df : pd.DataFrame + Custom cashflow + + Returns + ------- + pd.DataFrame + Processed custom cashflow + """ + df = df.copy() if "cost" in df.columns: df["cost"] = -df["cost"].abs() @@ -99,6 +132,17 @@ def _prepare_custom_flows(self, df: pd.DataFrame) -> pd.DataFrame: @classmethod def from_config(cls, config: CostIncomeConfig) -> "CostIncome": + """Create a `CostIncome` from a `CostIncomeConfig`. + + Parameters + ---------- + config : CostIncomeConfig + + Returns + ------- + CostIncome + """ + df = None if config.custom_cash_flows is not None: df = pd.DataFrame(config.custom_cash_flows) @@ -115,22 +159,47 @@ def from_config(cls, config: CostIncomeConfig) -> "CostIncome": ) @classmethod - def from_dict(cls, d: dict) -> "CostIncome": + def from_dict(cls, args_dict: dict) -> "CostIncome": + """Create a `CostIncome` from a dictionary. + + Parameters + ---------- + args_dict : dict + + Returns + ------- + CostIncome + """ + return cls.from_config( CostIncomeConfig( - mkt_price_year=d.get("mkt_price_year"), - init_cost=d.get("init_cost", 0.0), - periodic_cost=d.get("periodic_cost", 0.0), - periodic_income=d.get("periodic_income", 0.0), - cost_yearly_growth_rate=d.get("cost_yearly_growth_rate", 0.0), - income_yearly_growth_rate=d.get("income_yearly_growth_rate", 0.0), - freq=d.get("freq", "Y"), - custom_cash_flows=d.get("custom_cash_flows"), + mkt_price_year=args_dict.get("mkt_price_year"), + init_cost=args_dict.get("init_cost", 0.0), + periodic_cost=args_dict.get("periodic_cost", 0.0), + periodic_income=args_dict.get("periodic_income", 0.0), + cost_yearly_growth_rate=args_dict.get("cost_yearly_growth_rate", 0.0), + income_yearly_growth_rate=args_dict.get( + "income_yearly_growth_rate", 0.0 + ), + freq=args_dict.get("freq", "Y"), + custom_cash_flows=args_dict.get("custom_cash_flows"), ) ) @classmethod def from_yaml(cls, path: str) -> "CostIncome": + """Create a `CostIncome` from a yaml file. + + Parameters + ---------- + path : str + Path to the yaml file. + + Returns + ------- + CostIncome + """ + import yaml with open(path) as f: @@ -151,6 +220,7 @@ def _freq_to_days(freq: str) -> str: float The equivalent number of days for the given frequency string. """ + try: # Convert the frequency string to a DateOffset object offset = pd.tseries.frequencies.to_offset(freq) @@ -166,20 +236,71 @@ def _freq_to_days(freq: str) -> str: def _get_width_days(self) -> float: """Return the number of days in the current frequency.""" + ref = pd.Timestamp("2000-01-01") offset = pd.tseries.frequencies.to_offset(self.freq) return float(((ref + offset) - ref).days) - def _get_custom_val(self, date, column: str) -> float: - if self.custom_cash_flows is not None: - return self.custom_cash_flows.loc[date, column] - - raise AttributeError("No custom cash flow is defined.") - def _calc_at_date( self, impl_date: pd.Timestamp, curr_date: pd.Timestamp ) -> Tuple[float, float, float]: - """Calculate cash flows for a single timestamp.""" + r"""Calculate cash flows for a single timestamp. + + Computes the total cash flow, total cost, and total income for a given + evaluation date, accounting for growth rates applied to base costs and + incomes, as well as any custom cash flow overrides. + + The calculation applies compound growth to both costs and incomes based + on the number of years elapsed since the market price reference date + (`self.mkt_price_year`). Different base amounts are used depending on + whether the current date is before, at, or after the implementation date. + + Parameters + ---------- + impl_date : pd.Timestamp + The implementation date that determines which cost/income regime + applies. Dates before this use zero base amounts; at or after this + date use the initialized or periodic amounts respectively. + curr_date : pd.Timestamp + The evaluation date for which cash flows are being calculated. This + is compared against `impl_date` to determine the applicable base + amounts and is also used to index into `custom_cash_flows` if present. + + Returns + ------- + Tuple[float, float, float] + A tuple containing: + + * total_cash_flow : float + Net cash flow for the period, calculated as `total_income + total_cost`. + Note: Costs are typically negative values in financial contexts, + so this represents the net position. + * total_cost : float + Total cost amount for the period, including both standard and + custom cost components. + * total_income : float + Total income amount for the period, including both standard and + custom income components. + + Notes + ----- + Growth calculations use compound interest formula: + + .. math:: + factor = (1 + rate)^{years\_passed} + + where `years_passed` is computed as `(curr_date - mkt_price_year).days / 365.0`. + + Cost and income regimes: + + - **Before impl_date**: Both base cost and income are zero + - **At impl_date**: Uses `init_cost` for cost and `periodic_income` for income + - **After impl_date**: Uses `periodic_cost` for cost and `periodic_income` for income + + Custom cash flows (if `self.custom_cash_flows` is not None) are added + on top of the calculated standard amounts. Missing dates in the custom + cash flow DataFrame will raise a KeyError. + """ # Calculate growth factor based on years from market price reference years_passed = (curr_date - self.mkt_price_year).days / 365.0 @@ -195,46 +316,69 @@ def _calc_at_date( cost = self.periodic_cost * cost_factor income = self.periodic_income * inc_factor - c_cost = self._get_custom_val(curr_date, "cost") - c_inc = self._get_custom_val(curr_date, "income") + if self.custom_cash_flows is not None: + c_cost = cast(float, self.custom_cash_flows.loc[curr_date, "cost"]) + c_inc = cast(float, self.custom_cash_flows.loc[curr_date, "income"]) + else: + c_cost, c_inc = 0.0, 0.0 total_cost = cost + c_cost total_inc = income + c_inc return (total_inc + total_cost), total_cost, total_inc def calc_cash_flows( - self, impl_date: Any, start_date: Any, end_date: Any, disc: Optional[Any] = None + self, impl_date, start_date, end_date, disc_rates: Optional[DiscRates] = None ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - """ - Calculate net cash flows, costs, and incomes over a period. + """Calculate net cash flows, costs, and incomes over a period. + + Computes cash flow metrics across a specified date range by iterating + through each period. Optionally + applies discounting to calculate Net Present Value (NPV) when discount + rates are provided. + + The method creates a period range based on the configured frequency + (`self.freq`) and evaluates cash flows at the start time of each period. + Results are returned as NumPy arrays for efficient downstream processing. Parameters ---------- - impl_date : Any - Date the measure is implemented. - start_date : Any - Start of the calculation period. - end_date : Any - End of the calculation period. - disc : DiscRates, optional - Discount rates object to calculate Net Present Value. + impl_date : + The implementation date that determines which cost/income regime + applies. + start_date : + The beginning of the calculation period. + end_date : + The end of the calculation period. + disc_rates : DiscRates, optional + An object containing discount rate information for NPV calculations. Returns ------- Tuple[np.ndarray, np.ndarray, np.ndarray] - (net, costs, incomes) + A tuple containing three NumPy arrays of equal length: + + * net : np.ndarray + Net cash flow for each period (income + cost). May be discounted + if `disc_rates` is provided. + * costs : np.ndarray + Total costs for each period. May be discounted if `disc_rates` + is provided. + * incomes : np.ndarray + Total incomes for each period. May be discounted if `disc_rates` + is provided. """ + impl_ts = pd.Timestamp(impl_date) periods = pd.period_range(start=start_date, end=end_date, freq=self.freq) results = [self._calc_at_date(impl_ts, p.start_time) for p in periods] net, costs, incs = map(np.array, zip(*results)) - if disc is not None: + if disc_rates is not None: # Vectorized discounting years = np.array([p.year for p in periods]) # Note: Assumes disc.rates is indexed/aligned with disc.years - rate_map = dict(zip(disc.years, disc.rates)) + rate_map = dict(zip(disc_rates.years, disc_rates.rates)) factors = np.array( [ 1 @@ -254,24 +398,21 @@ def calc_cash_flows( return net, costs, incs def calc_total( - self, impl_date: Any, start_date: Any, end_date: Any, disc: Optional[Any] = None + self, impl_date, start_date, end_date, disc: Optional[DiscRates] = None ) -> Tuple[float, float, float]: """ - Calculate the total or net present value of the cash flows over a given period + Calculate the total or net present value of the cash flows over a given period. Parameters: ----------- - impl_year: int - the year the measure is implemented - - start_year: int - the start year of the period - + impl_date: + The date the measure is implemented. + start_year: + The start date of the period. end_year: int - the end year of the period - - disc: DiscRates object - the discount rates (required if discounted is True) + The end year of the period. + disc: DiscRates, optional + The discount rates to apply. Returns: -------- @@ -280,7 +421,7 @@ def calc_total( """ net_cash_flows, costs, incomes = self.calc_cash_flows( - impl_date, start_date, end_date, disc=disc + impl_date, start_date, end_date, disc_rates=disc ) return np.sum(net_cash_flows), np.sum(costs), np.sum(incomes) @@ -308,9 +449,10 @@ def plot_cash_flows( to_plot: list List of strings indicating which cash flows to plot. Options are 'net', 'cost', 'income'. """ + # Calculate the cash flows over the given period net_cash_flows, costs, incomes = self.calc_cash_flows( - impl_date, start_date, end_date, disc=disc + impl_date, start_date, end_date, disc_rates=disc ) # Plot the cash flows with colors @@ -353,11 +495,11 @@ def plot_cash_flows( return ax def to_dataframe( - self, impl_date: Any, start_date: Any, end_date: Any, disc: Optional[Any] = None + self, impl_date, start_date, end_date, disc_rates: Optional[DiscRates] = None ) -> pd.DataFrame: """Return cash flows as a formatted DataFrame.""" net, costs, incs = self.calc_cash_flows( - impl_date, start_date, end_date, disc=disc + impl_date, start_date, end_date, disc_rates=disc_rates ) periods = pd.period_range(start=start_date, end=end_date, freq=self.freq) @@ -367,7 +509,12 @@ def to_dataframe( @staticmethod def comb_cost_income(cost_incomes: list["CostIncome"]) -> "CostIncome": - """Sum costs and incomes from all measures.""" + """Combine multiple CostIncomes together. + + Combination sums the costs and incomes from all provided CostIncome + objects. + """ + first_ci = cost_incomes[0] if not all( @@ -402,14 +549,3 @@ def comb_cost_income(cost_incomes: list["CostIncome"]) -> "CostIncome": periodic_income=sum(c.periodic_income for c in cost_incomes), income_yearly_growth_rate=first_ci.income_growth_rate, ) - - @staticmethod - def from_kwargs(kwargs) -> "CostIncome": - """Extracts financial keys from the excel row data.""" - return CostIncome( - init_cost=kwargs.get("init_cost", 0.0), - periodic_cost=kwargs.get("periodic_cost", 0.0), - periodic_income=kwargs.get("periodic_income", 0.0), - income_yearly_growth_rate=kwargs.get("income_yearly_growth_rate", 0.0), - cost_yearly_growth_rate=kwargs.get("cost_yearly_growth_rate", 0.0), - ) From 66a3f70a38ed11e58558ed115361a4c1e2ee9720 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 12:01:55 +0200 Subject: [PATCH 17/37] Fix resampling issues, few docstring adjustments --- climada/entity/measures/cost_income.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/climada/entity/measures/cost_income.py b/climada/entity/measures/cost_income.py index bda1d6ac2a..305047f007 100644 --- a/climada/entity/measures/cost_income.py +++ b/climada/entity/measures/cost_income.py @@ -55,6 +55,7 @@ class CostIncome: User-defined cash flows indexed by date. freq : str Frequency of the cash flows (e.g., 'Y', '3M', '7D'). + """ def __init__( @@ -128,7 +129,17 @@ def _prepare_custom_flows(self, df: pd.DataFrame) -> pd.DataFrame: if "date" in df.columns: df["date"] = pd.to_datetime(df["date"]) df = df.set_index("date") - return df.resample(self.freq).sum() + match self.freq: + case "Y": + resampling_freq = "YS" + case "M": + resampling_freq = "MS" + case "Q": + resampling_freq = "QS" + case _: + resampling_freq = self.freq + + return df.resample(resampling_freq).sum() @classmethod def from_config(cls, config: CostIncomeConfig) -> "CostIncome": @@ -248,19 +259,19 @@ def _calc_at_date( Computes the total cash flow, total cost, and total income for a given evaluation date, accounting for growth rates applied to base costs and - incomes, as well as any custom cash flow overrides. + incomes, as well as the custom cash flow if provided. The calculation applies compound growth to both costs and incomes based on the number of years elapsed since the market price reference date - (`self.mkt_price_year`). Different base amounts are used depending on - whether the current date is before, at, or after the implementation date. + (`self.mkt_price_year`). Parameters ---------- impl_date : pd.Timestamp The implementation date that determines which cost/income regime - applies. Dates before this use zero base amounts; at or after this - date use the initialized or periodic amounts respectively. + applies. Dates before this use have no cost or income; "at the date" uses + the implementation cost, and dates after use the initialized or + periodic amounts respectively. curr_date : pd.Timestamp The evaluation date for which cash flows are being calculated. This is compared against `impl_date` to determine the applicable base @@ -294,7 +305,7 @@ def _calc_at_date( Cost and income regimes: - **Before impl_date**: Both base cost and income are zero - - **At impl_date**: Uses `init_cost` for cost and `periodic_income` for income + - **At impl_date**: Uses `init_cost` for cost - **After impl_date**: Uses `periodic_cost` for cost and `periodic_income` for income Custom cash flows (if `self.custom_cash_flows` is not None) are added From 1a1c97dc1c5f08a2c07b27e31157adcb9cea0992 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 12:02:44 +0200 Subject: [PATCH 18/37] Removes periodic income from implementation period for consistency --- climada/entity/measures/cost_income.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/entity/measures/cost_income.py b/climada/entity/measures/cost_income.py index 305047f007..df0641358e 100644 --- a/climada/entity/measures/cost_income.py +++ b/climada/entity/measures/cost_income.py @@ -322,7 +322,7 @@ def _calc_at_date( cost, income = 0.0, 0.0 elif curr_date == impl_date: cost = self.init_cost * cost_factor - income = self.periodic_income * inc_factor + income = 0.0 else: cost = self.periodic_cost * cost_factor income = self.periodic_income * inc_factor From 5822a697bee394624b299d4c1cacc760072f17d0 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 12:03:07 +0200 Subject: [PATCH 19/37] Removes discount rate logic (to be handled in StaticAppraiser) --- climada/entity/measures/cost_income.py | 66 +++++--------------------- 1 file changed, 13 insertions(+), 53 deletions(-) diff --git a/climada/entity/measures/cost_income.py b/climada/entity/measures/cost_income.py index df0641358e..60c9c8f8eb 100644 --- a/climada/entity/measures/cost_income.py +++ b/climada/entity/measures/cost_income.py @@ -338,14 +338,12 @@ def _calc_at_date( return (total_inc + total_cost), total_cost, total_inc def calc_cash_flows( - self, impl_date, start_date, end_date, disc_rates: Optional[DiscRates] = None + self, impl_date, start_date, end_date ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """Calculate net cash flows, costs, and incomes over a period. Computes cash flow metrics across a specified date range by iterating - through each period. Optionally - applies discounting to calculate Net Present Value (NPV) when discount - rates are provided. + through each period. The method creates a period range based on the configured frequency (`self.freq`) and evaluates cash flows at the start time of each period. @@ -360,8 +358,6 @@ def calc_cash_flows( The beginning of the calculation period. end_date : The end of the calculation period. - disc_rates : DiscRates, optional - An object containing discount rate information for NPV calculations. Returns ------- @@ -369,14 +365,11 @@ def calc_cash_flows( A tuple containing three NumPy arrays of equal length: * net : np.ndarray - Net cash flow for each period (income + cost). May be discounted - if `disc_rates` is provided. + Net cash flow for each period (income + cost). * costs : np.ndarray - Total costs for each period. May be discounted if `disc_rates` - is provided. + Total costs for each period. * incomes : np.ndarray - Total incomes for each period. May be discounted if `disc_rates` - is provided. + Total incomes for each period. """ impl_ts = pd.Timestamp(impl_date) @@ -384,35 +377,11 @@ def calc_cash_flows( results = [self._calc_at_date(impl_ts, p.start_time) for p in periods] net, costs, incs = map(np.array, zip(*results)) - - if disc_rates is not None: - # Vectorized discounting - years = np.array([p.year for p in periods]) - # Note: Assumes disc.rates is indexed/aligned with disc.years - rate_map = dict(zip(disc_rates.years, disc_rates.rates)) - factors = np.array( - [ - 1 - / (1 + rate_map.get(yr, 0.0)) - ** (yr - pd.Timestamp(start_date).year) - for yr in years - ] - ) - # Handle potential length mismatch if years aren't in disc - valid = np.array([yr in rate_map for yr in years]) - return ( - net[valid] * factors[valid], - costs[valid] * factors[valid], - incs[valid] * factors[valid], - ) - return net, costs, incs - def calc_total( - self, impl_date, start_date, end_date, disc: Optional[DiscRates] = None - ) -> Tuple[float, float, float]: + def calc_total(self, impl_date, start_date, end_date) -> Tuple[float, float, float]: """ - Calculate the total or net present value of the cash flows over a given period. + Calculate the total value of the cash flows over a given period. Parameters: ----------- @@ -422,17 +391,15 @@ def calc_total( The start date of the period. end_year: int The end year of the period. - disc: DiscRates, optional - The discount rates to apply. Returns: -------- Tuple[float, float, float] - the total net, cost, and income present values over the given period + the total net, cost, and income values over the given period. """ net_cash_flows, costs, incomes = self.calc_cash_flows( - impl_date, start_date, end_date, disc_rates=disc + impl_date, start_date, end_date ) return np.sum(net_cash_flows), np.sum(costs), np.sum(incomes) @@ -441,7 +408,6 @@ def plot_cash_flows( impl_date: Any, start_date: Any, end_date: Any, - disc: Optional[Any] = None, to_plot: List[str] = ["net", "cost", "income"], ): """ @@ -455,15 +421,13 @@ def plot_cash_flows( The start date of the period. end_date: datetime The end date of the period. - disc: DiscRates object - The discount rates (optional). to_plot: list List of strings indicating which cash flows to plot. Options are 'net', 'cost', 'income'. """ # Calculate the cash flows over the given period net_cash_flows, costs, incomes = self.calc_cash_flows( - impl_date, start_date, end_date, disc_rates=disc + impl_date, start_date, end_date ) # Plot the cash flows with colors @@ -500,18 +464,14 @@ def plot_cash_flows( fig.autofmt_xdate() plt.xlabel("Date") plt.ylabel("Cash Flow") - plt.title("Discounted Cash Flows" if disc else "Cash Flows") + plt.title("Cash Flows") plt.legend() plt.show() return ax - def to_dataframe( - self, impl_date, start_date, end_date, disc_rates: Optional[DiscRates] = None - ) -> pd.DataFrame: + def to_dataframe(self, impl_date, start_date, end_date) -> pd.DataFrame: """Return cash flows as a formatted DataFrame.""" - net, costs, incs = self.calc_cash_flows( - impl_date, start_date, end_date, disc_rates=disc_rates - ) + net, costs, incs = self.calc_cash_flows(impl_date, start_date, end_date) periods = pd.period_range(start=start_date, end=end_date, freq=self.freq) return pd.DataFrame( From 971a77255722f51e0b624f2a019f2e33eafe7147 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 12:07:12 +0200 Subject: [PATCH 20/37] Adds unit tests --- .../entity/measures/test/test_cost_income.py | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 climada/entity/measures/test/test_cost_income.py diff --git a/climada/entity/measures/test/test_cost_income.py b/climada/entity/measures/test/test_cost_income.py new file mode 100644 index 0000000000..bf546b9b91 --- /dev/null +++ b/climada/entity/measures/test/test_cost_income.py @@ -0,0 +1,397 @@ +""" +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 . + +--- + +Unit tests for the CostIncome class. +""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import numpy as np +import pandas as pd +import pytest + +from climada.entity.disc_rates.base import DiscRates +from climada.entity.measures.cost_income import CostIncome + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_disc_rates(years=None, rates=None): + """Build a minimal DiscRates stub.""" + dr = MagicMock(spec=DiscRates) + dr.years = np.array(years or list(range(2020, 2031))) + dr.rates = np.array(rates or [0.05] * len(dr.years)) + return dr + + +# --------------------------------------------------------------------------- +# __init__ / construction +# --------------------------------------------------------------------------- + + +class TestInit: + def test_defaults(self): + ci = CostIncome() + assert ci.init_cost == 0.0 + assert ci.periodic_cost == 0.0 + assert ci.periodic_income == 0.0 + assert ci.cost_growth_rate == 0.0 + assert ci.income_growth_rate == 0.0 + assert ci.freq == "Y" + assert ci.custom_cash_flows is None + + def test_costs_stored_negative(self): + ci = CostIncome(init_cost=100, periodic_cost=50) + assert ci.init_cost == -100.0 + assert ci.periodic_cost == -50.0 + + def test_costs_already_negative_stay_negative(self): + ci = CostIncome(init_cost=-200, periodic_cost=-30) + assert ci.init_cost == -200.0 + assert ci.periodic_cost == -30.0 + + def test_income_stored_positive(self): + ci = CostIncome(periodic_income=-80) + assert ci.periodic_income == 80.0 + + def test_mkt_price_year_default_is_current_year(self): + ci = CostIncome() + assert ci.mkt_price_year.year == datetime.today().year + + def test_mkt_price_year_custom(self): + ci = CostIncome(mkt_price_year=2015) + assert ci.mkt_price_year.year == 2015 + + def test_custom_cash_flows_processed(self): + df = pd.DataFrame( + { + "date": ["2020-01-01", "2020-06-01"], + "cost": [100, 200], + "income": [50, 60], + } + ) + ci = CostIncome(custom_cash_flows=df, freq="Y") + # After resampling to yearly, should have one row per year + assert isinstance(ci.custom_cash_flows, pd.DataFrame) + assert "cost" in ci.custom_cash_flows.columns + # Costs should be negative after processing + assert (ci.custom_cash_flows["cost"] <= 0).all() + + +# --------------------------------------------------------------------------- +# from_dict / from_config / from_yaml +# --------------------------------------------------------------------------- + + +class TestFromDict: + def test_basic(self): + d = { + "mkt_price_year": 2020, + "init_cost": 500, + "periodic_cost": 100, + "periodic_income": 200, + "cost_yearly_growth_rate": 0.02, + "income_yearly_growth_rate": 0.03, + "freq": "Y", + } + ci = CostIncome.from_dict(d) + assert ci.init_cost == -500.0 + assert ci.periodic_income == 200.0 + assert ci.cost_growth_rate == 0.02 + + def test_defaults_for_missing_keys(self): + ci = CostIncome.from_dict({}) + assert ci.init_cost == 0.0 + assert ci.freq == "Y" + + def test_with_custom_cash_flows(self): + d = { + "freq": "Y", + "custom_cash_flows": [ + {"date": "2021-01-01", "cost": 100, "income": 50}, + ], + } + ci = CostIncome.from_dict(d) + assert ci.custom_cash_flows is not None + + +class TestFromYaml: + def test_from_yaml(self, tmp_path): + yaml_content = """ +cost_income: + mkt_price_year: 2020 + init_cost: 1000 + periodic_cost: 200 + periodic_income: 300 + cost_yearly_growth_rate: 0.01 + income_yearly_growth_rate: 0.02 + freq: Y +""" + p = tmp_path / "ci.yaml" + p.write_text(yaml_content) + ci = CostIncome.from_yaml(str(p)) + assert ci.init_cost == -1000.0 + assert ci.periodic_income == 300.0 + + +# --------------------------------------------------------------------------- +# _freq_to_days +# --------------------------------------------------------------------------- + + +class TestFreqToDays: + def test_yearly(self): + result = CostIncome._freq_to_days("Y") + assert result == "365d" + + def test_monthly(self): + result = CostIncome._freq_to_days("M") + assert result == "30d" + + def test_daily(self): + result = CostIncome._freq_to_days("D") + assert result == "1d" + + def test_invalid(self): + with pytest.raises(ValueError): + CostIncome._freq_to_days("INVALID_FREQ_XYZ") + + +# --------------------------------------------------------------------------- +# _get_width_days +# --------------------------------------------------------------------------- + + +class TestGetWidthDays: + def test_yearly(self): + ci = CostIncome(freq="Y") + assert ci._get_width_days() == 365.0 + + def test_daily(self): + ci = CostIncome(freq="D") + assert ci._get_width_days() == 1.0 + + +# --------------------------------------------------------------------------- +# _calc_at_date +# --------------------------------------------------------------------------- + + +class TestCalcAtDate: + def test_before_impl_date_is_zero(self): + ci = CostIncome(mkt_price_year=2020, init_cost=1000, periodic_income=500) + impl = pd.Timestamp("2021-01-01") + curr = pd.Timestamp("2020-01-01") + net, cost, inc = ci._calc_at_date(impl, curr) + assert net == 0.0 + assert cost == 0.0 + assert inc == 0.0 + + def test_at_impl_date_uses_init_cost(self): + ci = CostIncome(mkt_price_year=2021, init_cost=1000, periodic_income=0) + impl = pd.Timestamp("2021-01-01") + net, cost, inc = ci._calc_at_date(impl, impl) + assert cost == pytest.approx(-1000.0, rel=1e-3) + assert inc == pytest.approx(0.0) + + def test_after_impl_date_uses_periodic_cost(self): + ci = CostIncome(mkt_price_year=2020, periodic_cost=200, periodic_income=0) + impl = pd.Timestamp("2021-01-01") + curr = pd.Timestamp("2022-01-01") + net, cost, inc = ci._calc_at_date(impl, curr) + assert cost < 0 + assert abs(cost) == 200 + + def test_income_growth_applied(self): + ci = CostIncome( + mkt_price_year=2020, periodic_income=100, income_yearly_growth_rate=0.10 + ) + impl = pd.Timestamp("2020-01-01") + curr = pd.Timestamp("2021-01-01") + _, _, inc = ci._calc_at_date(impl, curr) + expected = 100 * (1.10**1.0) + assert inc == pytest.approx(expected, rel=1e-2) + + def test_net_equals_income_plus_cost(self): + ci = CostIncome(mkt_price_year=2020, periodic_cost=100, periodic_income=150) + impl = pd.Timestamp("2020-01-01") + curr = pd.Timestamp("2021-01-01") + net, cost, inc = ci._calc_at_date(impl, curr) + assert net == pytest.approx(cost + inc) + + def test_custom_cash_flows_added(self): + df = pd.DataFrame( + { + "date": ["2021-01-01"], + "cost": [500.0], + "income": [200.0], + } + ) + ci = CostIncome(mkt_price_year=2021, custom_cash_flows=df, freq="Y") + print(ci.custom_cash_flows) + impl = pd.Timestamp("2021-01-01") + curr = pd.Timestamp("2021-01-01") + net, cost, inc = ci._calc_at_date(impl, curr) + assert inc == pytest.approx(200.0, rel=1e-3) + assert cost == pytest.approx(-500.0, rel=1e-3) + + +# --------------------------------------------------------------------------- +# calc_cash_flows +# --------------------------------------------------------------------------- + + +class TestCalcCashFlows: + def test_returns_three_arrays(self): + ci = CostIncome(mkt_price_year=2020, periodic_income=100) + net, costs, incs = ci.calc_cash_flows("2020-01-01", "2020-01-01", "2025-01-01") + assert isinstance(net, np.ndarray) + assert isinstance(costs, np.ndarray) + assert isinstance(incs, np.ndarray) + + def test_length_matches_periods(self): + ci = CostIncome(freq="Y") + net, costs, incs = ci.calc_cash_flows("2020-01-01", "2020-01-01", "2024-01-01") + periods = pd.period_range("2020-01-01", "2024-01-01", freq="Y") + assert len(net) == len(periods) + + def test_zero_cost_income(self): + ci = CostIncome() + net, costs, incs = ci.calc_cash_flows("2020-01-01", "2020-01-01", "2023-01-01") + np.testing.assert_array_equal(net, 0.0) + + def test_nonzero_cost_income(self): + ci = CostIncome( + mkt_price_year=2020, init_cost=5000, periodic_cost=200, periodic_income=1000 + ) + net, cost, income = ci.calc_cash_flows( + impl_date="2020-01-01", start_date="2019-01-01", end_date="2025-01-01" + ) + np.testing.assert_array_equal( + net, [0.0, -5000.0, 800.0, 800.0, 800.0, 800.0, 800.0] + ) + np.testing.assert_array_equal( + cost, [0.0, -5000.0, -200.0, -200.0, -200.0, -200.0, -200.0] + ) + np.testing.assert_array_equal( + income, [0.0, 0.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0] + ) + + +# --------------------------------------------------------------------------- +# calc_total +# --------------------------------------------------------------------------- + + +class TestCalcTotal: + def test_total_is_sum_of_cash_flows(self): + ci = CostIncome(mkt_price_year=2020, periodic_income=100, periodic_cost=50) + net_arr, cost_arr, inc_arr = ci.calc_cash_flows( + "2020-01-01", "2020-01-01", "2024-01-01" + ) + total_net, total_cost, total_inc = ci.calc_total( + "2020-01-01", "2020-01-01", "2024-01-01" + ) + assert total_net == pytest.approx(float(np.sum(net_arr))) + assert total_cost == pytest.approx(float(np.sum(cost_arr))) + assert total_inc == pytest.approx(float(np.sum(inc_arr))) + + def test_returns_floats(self): + ci = CostIncome() + result = ci.calc_total("2020-01-01", "2020-01-01", "2022-01-01") + assert all(isinstance(v, (float, np.floating)) for v in result) + + +# --------------------------------------------------------------------------- +# to_dataframe +# --------------------------------------------------------------------------- + + +class TestToDataframe: + def test_columns(self): + ci = CostIncome(periodic_income=100) + df = ci.to_dataframe("2020-01-01", "2020-01-01", "2023-01-01") + assert set(df.columns) == {"date", "net", "cost", "income"} + + def test_row_count(self): + ci = CostIncome(freq="Y") + df = ci.to_dataframe("2020-01-01", "2020-01-01", "2022-01-01") + expected = len(pd.period_range("2020-01-01", "2022-01-01", freq="Y")) + assert len(df) == expected + + +# --------------------------------------------------------------------------- +# comb_cost_income +# --------------------------------------------------------------------------- + + +class TestCombCostIncome: + def test_costs_are_summed(self): + ci1 = CostIncome(mkt_price_year=2020, init_cost=100, periodic_cost=50) + ci2 = CostIncome(mkt_price_year=2020, init_cost=200, periodic_cost=30) + combined = CostIncome.comb_cost_income([ci1, ci2]) + assert combined.init_cost == -300.0 + assert combined.periodic_cost == -80.0 + + def test_incomes_are_summed(self): + ci1 = CostIncome(mkt_price_year=2020, periodic_income=100) + ci2 = CostIncome(mkt_price_year=2020, periodic_income=200) + combined = CostIncome.comb_cost_income([ci1, ci2]) + assert combined.periodic_income == 300.0 + + def test_mismatched_mkt_price_year_raises(self): + ci1 = CostIncome(mkt_price_year=2020) + ci2 = CostIncome(mkt_price_year=2021) + with pytest.raises(ValueError, match="market price years"): + CostIncome.comb_cost_income([ci1, ci2]) + + def test_mismatched_cost_growth_rate_raises(self): + ci1 = CostIncome(mkt_price_year=2020, cost_yearly_growth_rate=0.02) + ci2 = CostIncome(mkt_price_year=2020, cost_yearly_growth_rate=0.05) + with pytest.raises(ValueError, match="cost_growth_rate"): + CostIncome.comb_cost_income([ci1, ci2]) + + def test_mismatched_income_growth_rate_raises(self): + ci1 = CostIncome(mkt_price_year=2020, income_yearly_growth_rate=0.01) + ci2 = CostIncome(mkt_price_year=2020, income_yearly_growth_rate=0.03) + with pytest.raises(ValueError, match="income_growth_rate"): + CostIncome.comb_cost_income([ci1, ci2]) + + def test_single_element_list(self): + ci = CostIncome(mkt_price_year=2020, init_cost=500, periodic_income=100) + combined = CostIncome.comb_cost_income([ci]) + assert combined.init_cost == -500.0 + assert combined.periodic_income == 100.0 + + def test_preserves_growth_rates(self): + ci1 = CostIncome( + mkt_price_year=2020, + cost_yearly_growth_rate=0.02, + income_yearly_growth_rate=0.03, + ) + ci2 = CostIncome( + mkt_price_year=2020, + cost_yearly_growth_rate=0.02, + income_yearly_growth_rate=0.03, + ) + combined = CostIncome.comb_cost_income([ci1, ci2]) + assert combined.cost_growth_rate == 0.02 + assert combined.income_growth_rate == 0.03 From 243cbacdc95380a0ed8c347f1e932fd62fdb80ae Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 14:24:10 +0200 Subject: [PATCH 21/37] Adds nice __repr__ --- climada/entity/measures/cost_income.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/climada/entity/measures/cost_income.py b/climada/entity/measures/cost_income.py index 60c9c8f8eb..1d205f093c 100644 --- a/climada/entity/measures/cost_income.py +++ b/climada/entity/measures/cost_income.py @@ -107,6 +107,21 @@ def __init__( else: self.custom_cash_flows = None + def __repr__(self) -> str: + lines = [ + "CostIncome(", + f" mkt_price_year = {self.mkt_price_year.year}", + f" freq = {self.freq!r}", + f" init_cost = {self.init_cost:,.2f}", + f" periodic_cost = {self.periodic_cost:,.2f}", + f" periodic_income = {self.periodic_income:,.2f}", + f" cost_yearly_growth_rate = {self.cost_growth_rate:.2%}", + f" income_yearly_growth_rate = {self.income_growth_rate:.2%}", + f" custom_cash_flows = {None if self.custom_cash_flows is None else f'DataFrame({len(self.custom_cash_flows)} rows)'}", + ")", + ] + return "\n".join(lines) + def _prepare_custom_flows(self, df: pd.DataFrame) -> pd.DataFrame: """Process and resample custom cash flow dataframe From a17f2e27ce8cd4d479f973be2ce28250e248a705 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 14:26:08 +0200 Subject: [PATCH 22/37] Improves freq handling, nicer plot --- climada/entity/measures/cost_income.py | 157 +++++++++++++++---------- 1 file changed, 94 insertions(+), 63 deletions(-) diff --git a/climada/entity/measures/cost_income.py b/climada/entity/measures/cost_income.py index 1d205f093c..33ece8de1c 100644 --- a/climada/entity/measures/cost_income.py +++ b/climada/entity/measures/cost_income.py @@ -144,17 +144,22 @@ def _prepare_custom_flows(self, df: pd.DataFrame) -> pd.DataFrame: if "date" in df.columns: df["date"] = pd.to_datetime(df["date"]) df = df.set_index("date") - match self.freq: + + freq = self._make_offset_compat(self.freq) + return df.resample(freq).sum() + + @staticmethod + def _make_offset_compat(freq: str, start=True) -> str: + suffix = "S" if start else "E" + match freq: case "Y": - resampling_freq = "YS" + return "Y" + suffix case "M": - resampling_freq = "MS" + return "M" + suffix case "Q": - resampling_freq = "QS" + return "Q" + suffix case _: - resampling_freq = self.freq - - return df.resample(resampling_freq).sum() + return freq @classmethod def from_config(cls, config: CostIncomeConfig) -> "CostIncome": @@ -231,8 +236,8 @@ def from_yaml(cls, path: str) -> "CostIncome": with open(path) as f: return cls.from_dict(yaml.safe_load(f)["cost_income"]) - @staticmethod - def _freq_to_days(freq: str) -> str: + @classmethod + def _freq_to_days(cls, freq: str) -> str: """ Convert a frequency string to the equivalent number of days. @@ -249,6 +254,7 @@ def _freq_to_days(freq: str) -> str: try: # Convert the frequency string to a DateOffset object + freq = cls._make_offset_compat(freq, start=False) offset = pd.tseries.frequencies.to_offset(freq) # Calculate the number of days by applying the offset to a base date @@ -264,7 +270,8 @@ def _get_width_days(self) -> float: """Return the number of days in the current frequency.""" ref = pd.Timestamp("2000-01-01") - offset = pd.tseries.frequencies.to_offset(self.freq) + freq = self._make_offset_compat(self.freq, start=False) + offset = pd.tseries.frequencies.to_offset(freq) return float(((ref + offset) - ref).days) def _calc_at_date( @@ -342,7 +349,10 @@ def _calc_at_date( cost = self.periodic_cost * cost_factor income = self.periodic_income * inc_factor - if self.custom_cash_flows is not None: + if ( + self.custom_cash_flows is not None + and curr_date in self.custom_cash_flows.index + ): c_cost = cast(float, self.custom_cash_flows.loc[curr_date, "cost"]) c_inc = cast(float, self.custom_cash_flows.loc[curr_date, "income"]) else: @@ -423,66 +433,87 @@ def plot_cash_flows( impl_date: Any, start_date: Any, end_date: Any, - to_plot: List[str] = ["net", "cost", "income"], + figsize: Tuple[int, int] = (12, 7), + title: Optional[str] = None, ): - """ - Plot the cash flows over a given period. + """Plot periodic and cumulative cash flows over a given period. - Parameters: - ----------- - impl_date: datetime + Displays a two-panel figure: + - Top panel: stacked bar chart of costs and incomes per period, + with a net cash flow line overlay. + - Bottom panel: cumulative net cash flow over time. + + Parameters + ---------- + impl_date : The date the measure is implemented. - start_date: datetime - The start date of the period. - end_date: datetime - The end date of the period. - to_plot: list - List of strings indicating which cash flows to plot. Options are 'net', 'cost', 'income'. + start_date : + Start of the evaluation period. + end_date : + End of the evaluation period. + figsize : tuple, optional + Figure size as (width, height). Default is (12, 7). + title : str, optional + Overall figure title. Defaults to 'Cash Flow Analysis'. + + Returns + ------- + plt.Figure """ + net, costs, incomes = self.calc_cash_flows(impl_date, start_date, end_date) + periods = pd.period_range(start=start_date, end=end_date, freq=self.freq) + dates = [p.start_time for p in periods] + cumulative_net = np.cumsum(net) - # Calculate the cash flows over the given period - net_cash_flows, costs, incomes = self.calc_cash_flows( - impl_date, start_date, end_date - ) + width = pd.Timedelta(days=self._get_width_days() * 0.6) - # Plot the cash flows with colors - date_range = pd.date_range( - start=start_date, end=end_date, freq=self._freq_to_days(self.freq) + fig, (ax_bar, ax_cum) = plt.subplots( + 2, + 1, + figsize=figsize, + sharex=True, + gridspec_kw={"height_ratios": [3, 1], "hspace": 0.08}, ) - width = self._get_width_days() * 0.8 # 80% width for visibility - fig, ax = plt.subplots() - - if "cost" in to_plot: - ax.bar(date_range, costs, color="red", label="Cost", width=width) - if "income" in to_plot: - ax.bar( - date_range, - incomes, - color="blue", - label="Income", - alpha=0.7, - width=width, - ) - if "net" in to_plot: - ax.bar( - date_range, - net_cash_flows, - color="blue", - edgecolor="red", - hatch="//", - label="Net", - alpha=0.5, - width=width, - ) - ax.xaxis_date() # <---- treat x-ticks as datetime - fig.autofmt_xdate() - plt.xlabel("Date") - plt.ylabel("Cash Flow") - plt.title("Cash Flows") - plt.legend() - plt.show() - return ax + # --- top panel: stacked bars + net line --- + ax_bar.bar( + dates, incomes, width=width, color="#4C9BE8", label="Income", zorder=2 + ) + ax_bar.bar(dates, costs, width=width, color="#E8604C", label="Cost", zorder=2) + ax_bar.plot( + dates, + net, + color="black", + linewidth=1.5, + marker="o", + markersize=4, + label="Net", + zorder=3, + ) + ax_bar.axhline(0, color="black", linewidth=0.6, linestyle="--", zorder=1) + + ax_bar.set_ylabel("Cash flow") + ax_bar.legend(frameon=False, fontsize=9) + ax_bar.spines[["top", "right"]].set_visible(False) + ax_bar.tick_params(axis="x", which="both", bottom=False) + ax_bar.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:,.0f}")) + + # --- bottom panel: cumulative net --- + # dates = dates.append() + positive = np.maximum(cumulative_net, 0) + negative = np.minimum(cumulative_net, 0) + ax_cum.fill_between(dates, positive, alpha=0.25, color="#4C9BE8", step="mid") + ax_cum.fill_between(dates, negative, alpha=0.25, color="#E8604C", step="mid") + # ax_cum.plot(dates, cumulative_net, color="black", linewidth=1.5, zorder=3) + ax_cum.axhline(0, color="black", linewidth=0.6, linestyle="--", zorder=1) + + ax_cum.set_ylabel("Cumulative net") + ax_cum.spines[["top", "right"]].set_visible(False) + ax_cum.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:,.0f}")) + + fig.autofmt_xdate(rotation=30, ha="right") + fig.suptitle(title or "Cash Flow Analysis", fontsize=13, y=1.01) + return (ax_bar, ax_cum) def to_dataframe(self, impl_date, start_date, end_date) -> pd.DataFrame: """Return cash flows as a formatted DataFrame.""" From abe1fdffe0a126c11a4ece74f75f95776cc5dc0a Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 14:26:43 +0200 Subject: [PATCH 23/37] Adds test for freq manipulations --- climada/entity/measures/test/test_cost_income.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/climada/entity/measures/test/test_cost_income.py b/climada/entity/measures/test/test_cost_income.py index bf546b9b91..f9d6667db3 100644 --- a/climada/entity/measures/test/test_cost_income.py +++ b/climada/entity/measures/test/test_cost_income.py @@ -185,6 +185,14 @@ def test_yearly(self): ci = CostIncome(freq="Y") assert ci._get_width_days() == 365.0 + def test_3yearly(self): + ci = CostIncome(freq="3Y") + assert ci._get_width_days() == 3 * 365.0 + + def test_monthly(self): + ci = CostIncome(freq="M") + assert ci._get_width_days() == 30.0 + def test_daily(self): ci = CostIncome(freq="D") assert ci._get_width_days() == 1.0 From 1513c115b37f20b9d56deff06eab488d33f63234 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 14:28:14 +0200 Subject: [PATCH 24/37] Adds tutorial file --- doc/user-guide/climada_cost_income.ipynb | 1020 ++++++++++++++++++++++ 1 file changed, 1020 insertions(+) create mode 100644 doc/user-guide/climada_cost_income.ipynb diff --git a/doc/user-guide/climada_cost_income.ipynb b/doc/user-guide/climada_cost_income.ipynb new file mode 100644 index 0000000000..bf1834140e --- /dev/null +++ b/doc/user-guide/climada_cost_income.ipynb @@ -0,0 +1,1020 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2147f042", + "metadata": {}, + "source": [ + "(cost-income-tutorial)=\n", + "# Measure cash flows with `CostIncome`\n", + "\n", + "This notebook introduces the `CostIncome` class from CLIMADA, which is used to model the financial cash flows of adaptation or risk-reduction measures over time.\n", + "\n", + "A `CostIncome` object tracks:\n", + "- **Initial (one-off) costs** — the upfront implementation cost\n", + "- **Periodic costs** — recurring expenses (e.g., maintenance)\n", + "- **Periodic income** — recurring revenues (e.g., insurance savings, avoided losses)\n", + "- **Growth rates** — how costs and incomes evolve over time\n", + "- **Custom cash flows** — arbitrary user-defined flows (layered on top)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "a73bc8d1", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# Adjust the import path if running outside the CLIMADA package tree\n", + "from climada.entity.measures.cost_income import CostIncome" + ] + }, + { + "cell_type": "markdown", + "id": "a97e1043", + "metadata": {}, + "source": [ + "## Quickstart\n", + "\n", + "The simplest way to create a `CostIncome` is by passing keyword arguments directly.\n", + "\n", + "| Parameter | Meaning | Sign convention |\n", + "|---|---|---|\n", + "| `init_cost` | One-off implementation cost | stored negative |\n", + "| `periodic_cost` | Recurring cost each period | stored negative |\n", + "| `periodic_income` | Recurring income each period | stored positive |\n", + "| `mkt_price_year` | Reference year for price levels | — |\n", + "| `freq` | Period frequency (e.g. `'Y'`, `'M'`, `'Q'`) | — |\n", + "\n", + "```{note} **Sign convention**\n", + "`CostIncome` stores costs as negative numbers internally.\n", + "```\n", + "\n", + "```{note}\n", + "Financial values in `CostIncome` are currently unitless (no currency is specified)\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "4ba16743", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CostIncome(\n", + " mkt_price_year = 2020\n", + " freq = 'Y'\n", + " init_cost = -20,000.00\n", + " periodic_cost = -5,000.00\n", + " periodic_income = 12,000.00\n", + " cost_yearly_growth_rate = 0.00%\n", + " income_yearly_growth_rate = 0.00%\n", + " custom_cash_flows = None\n", + ")\n" + ] + } + ], + "source": [ + "ci = CostIncome(\n", + " mkt_price_year=2020,\n", + " init_cost=20_000, # 50 000 upfront\n", + " periodic_cost=5_000, # 5 000 / year maintenance\n", + " periodic_income=12_000, # 8 000 / year in avoided losses\n", + " freq=\"Y\", # annual cash flows\n", + ")\n", + "\n", + "print(ci)" + ] + }, + { + "cell_type": "markdown", + "id": "3c9a8398", + "metadata": {}, + "source": [ + "## Calculating cash flows\n", + "\n", + "Three methods are available:\n", + "\n", + "| Method | Returns |\n", + "|---|---|\n", + "| `calc_cash_flows(impl_date, start_date, end_date)` | Three `np.ndarray`: net, costs, incomes |\n", + "| `calc_total(...)` | Three scalars: summed net, cost, income |\n", + "| `to_dataframe(...)` | A tidy `pd.DataFrame` |\n", + "\n", + "The `impl_date` is when the measure is *deployed*. Cash flows before this date are zero; the initial cost lands on `impl_date`; periodic flows start the following period." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "25bf1e1f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Period | Net | Cost | Income\n", + "---------------------------------------------\n", + "2020 | 0 | 0 | 0\n", + "2021 | 0 | 0 | 0\n", + "2022 | -20000 | -20000 | 0\n", + "2023 | 7000 | -5000 | 12000\n", + "2024 | 7000 | -5000 | 12000\n", + "2025 | 7000 | -5000 | 12000\n", + "2026 | 7000 | -5000 | 12000\n", + "2027 | 7000 | -5000 | 12000\n", + "2028 | 7000 | -5000 | 12000\n", + "2029 | 7000 | -5000 | 12000\n", + "2030 | 7000 | -5000 | 12000\n" + ] + } + ], + "source": [ + "impl_date = \"2022-01-01\"\n", + "start_date = \"2020-01-01\"\n", + "end_date = \"2030-01-01\"\n", + "\n", + "net, costs, incomes = ci.calc_cash_flows(impl_date, start_date, end_date)\n", + "\n", + "print(\"Period | Net | Cost | Income\")\n", + "print(\"-\" * 45)\n", + "periods = pd.period_range(start=start_date, end=end_date, freq=\"Y\")\n", + "for p, n, c, i in zip(periods, net, costs, incomes):\n", + " print(f\"{p} | {n:>9.0f} | {c:>9.0f} | {i:>6.0f}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "971ce0dd", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Total net : 36000\n", + "Total cost : -60000\n", + "Total income : 96000\n" + ] + } + ], + "source": [ + "total_net, total_cost, total_income = ci.calc_total(impl_date, start_date, end_date)\n", + "print(f\"Total net : {total_net:>10.0f}\")\n", + "print(f\"Total cost : {total_cost:>10.0f}\")\n", + "print(f\"Total income : {total_income:>10.0f}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "f3fd0c35", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
datenetcostincome
020200.00.00.0
120210.00.00.0
22022-20000.0-20000.00.0
320237000.0-5000.012000.0
420247000.0-5000.012000.0
520257000.0-5000.012000.0
620267000.0-5000.012000.0
720277000.0-5000.012000.0
820287000.0-5000.012000.0
920297000.0-5000.012000.0
1020307000.0-5000.012000.0
\n", + "
" + ], + "text/plain": [ + " date net cost income\n", + "0 2020 0.0 0.0 0.0\n", + "1 2021 0.0 0.0 0.0\n", + "2 2022 -20000.0 -20000.0 0.0\n", + "3 2023 7000.0 -5000.0 12000.0\n", + "4 2024 7000.0 -5000.0 12000.0\n", + "5 2025 7000.0 -5000.0 12000.0\n", + "6 2026 7000.0 -5000.0 12000.0\n", + "7 2027 7000.0 -5000.0 12000.0\n", + "8 2028 7000.0 -5000.0 12000.0\n", + "9 2029 7000.0 -5000.0 12000.0\n", + "10 2030 7000.0 -5000.0 12000.0" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = ci.to_dataframe(impl_date, start_date, end_date)\n", + "df" + ] + }, + { + "cell_type": "markdown", + "id": "612c0b66", + "metadata": {}, + "source": [ + "## Visualising cash flows\n", + "\n", + "`plot_cash_flows` draws a bar chart. You can choose which series to display via the `to_plot` argument." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "315fc0ac", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(, )" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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juHWqJ3SuxRPPfWUk0sNT2Cva08j0JMnT7YsX748x38GuC+++8C1B29k7+8+63aAPa49pflWUl9XpUuXtkyYMIFKdiAG5MI23kwjvjpNl05ZU6tWLROhGzdunBlPO2PGDLONRpH1ofts377dTG2jdIqlXr16mTsXgLOuPY0ojxw5Utq3b8+HgBy//gBnffcBrnD96VhxwF7XntKeydbrSvfVnD6a96JQoUJUtAMRIICt239ERITpEpb8C167+9SrV09++OEH2bVr1/WLxtdXNm7caH5B69ata7oH6S/3uXPnpEiRItQonHLtFS5cmJpHlvDdB2fh2oMzcf3BXa695EOTL168aIYU6PCXe+65xwml9x4ECLzQypUr5dlnn5V3331X/vzzT9vyZs2ayYYNG+TUqVPmfWJiouTOndv8EuovqI77sdJs3TreRzPF6/F0pgJdr+OIAGdce9bMtgDffXA1/L8Lrj94o1v97tNcF4sXLzb5fKpXry6bNm2SmTNnmtk04DgECLzIyZMnTUKPhx9+2EwXoklltCu29RdWX5ctW9YkGUwetWvXrp25c3vgwAHbsQICAkz3Hp1eRKN8GvEDuPbgivjuA9cevBHffXD3a0+HFegNoL1795oggyYsrFq1Kh+sozkywQFcx+XLly39+vWz9OzZ03Lo0CHb8gYNGlj69+9vXut0NvPnzzdTw61fvz7F/g899JCldevWtveRkZE5WHq4M649cP3BG/HdB64/eCN7f/dpokzkLHoQeAkds63d/3V8T7ly5SQhIcEs79y5s+zevdu89vPzkx49epjuPYMGDZI1a9aYyJ12/9m/f7+JAlox3htce3AHfPeBaw/eiO8+eMq1Z51mEznHR6MEOXg+OJHOMapDA5R+7Nqdp0+fPhIcHCxz5syxLbt27ZrJUvv3339LnTp1zHjv0qVLyxdffGHmuQW49uBO+O4D1x68Ed994NpDdhAg8HItW7aURx55xET5NECQlJRkonqnT58209loxngdI9S7d29nFxUehmsPXH/wRnz3gesP3ojvPvdBgMCLHTp0SJo2bSpLly61JRnUbKGBgYHOLho8HNceuP7gjfjuA9cfvBHffe6FQR1eyDqqZN26dZInTx5bcGDChAny3HPPSWRkpJNLCE/FtQeuP3gjvvvA9QdvxHefe/J3dgGQ86xTiehUI/fdd5+Zo/Sxxx6TK1euyKeffipFihThYwHXHjwO333g2oM34rsPXHvICoYYeClNRFizZk05ePCgGVKgvQdGjBjh7GLBC3DtgesP3ojvPnD9wRvx3ed+CBB4sXbt2knFihVl6tSpkitXLmcXB16Eaw9cf/BGfPeB6w/eiO8+90KAwIslJiaaGQsArj14E777wLUHb8R3H7j2kBkECAAAAAAAALMYAAAAAAAAAgQAAAAAAIAAAQAAAAAAIEAAAAAAAAAM3+tPAAAAAADAmxEgAAAAAAAABAgAAAAAAAABAgAAAAAAQIAAAAAAAAAQIAAAAAAAAKL+DwDn5rGa8bABAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ci.plot_cash_flows(\n", + " impl_date,\n", + " start_date,\n", + " end_date,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "4bb73614", + "metadata": {}, + "source": [ + "## Growth rates\n", + "\n", + "Costs and incomes can grow year-over-year using compound-interest factors anchored to `mkt_price_year`.\n", + "\n", + "$$\\text{value}(t) = \\text{base} \\times (1 + r)^{\\frac{t - t_0}{365}}$$\n", + "\n", + "Pass `cost_yearly_growth_rate` and / or `income_yearly_growth_rate` (as decimals, e.g. `0.02` for 2 %)." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b69f1ae7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(, )" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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IN/07th//vOfTJ3+GjVqmIDAJ598cunCCrh0aemIAo0m6/BHDRoAzrj29E6Z0ju2gwcPNnfQlN61sCUrvO6668xr7mLAVdefXlu2a05HEOiQW6WjCBYvXiw9evQwo1t27drFLwFOv/b0/1btpOkoAk1S+OWXX8p///tfkwOjevXqppPGtQdX/d3XunVrad++vdx///3muy4mJsb8HahBer05dN9995mAFXAtdMreM888Y0aH2uhoFb3e6HO4kBXIo08//dRapkwZa8uWLa2NGjWyli5d2vriiy+azxISEqxjxoyx1qxZ03rixAmzLDk52Tx/+OGH1mLFitnfA8689i5evGhfLyMjwzw3b97c+u6772ZaBjjz+psyZYr5LCUlxTzffffd1uXLl5vX//nPf6zh4eHW4OBg63//+19OPFxy7an33nvParFYzP+9sbGx9uU//PCDtVy5ctZ169Zx9uGS/3vV2bNnrXv27LG2atXK+s9//tO+fMuWLdbrrrvO+vnnn3P2USBz5swx15tee3feeaf5P/Whhx4ynyUmJtLncDECBMjzP9SGDRta3377bfP+yJEj1jfffNNapEgRExxQy5Yts950003W4cOHZ+qYff/99+Y/mF9++YWzDadee/qfRFb79u0z/6ns2rXLvmzv3r3mOT09nd8AXHL93XfffdZBgwaZ70C9/v7xj39YixcvnumPZsDZ/+/u2LHD2r59e2vdunWtx44ds2+rAfmiRYta/+///o+TDpd+923evNlaq1YtE6Cy/d2ngXu+/1BQGmTXgNQ777xj3qelpZnrUYMESUlJZhl9DtdiigFyG2FinnUYY/Pmzc1wbqVzzHRYd4UKFUyiGqVzwQcOHGiqGHz11VdmG6UlbzTLfP369TnbcOq1l12FDB3arZUMdAjali1bzLaaG0Oz3tqmvgDOvP50rq3mGli4cKEp86rX3XPPPSdjx441lTX279/PCYdLvvtq164tI0aMkL1798rbb78tR44cMcvnz59v/s9t27YtZx4u/b9Xc/1oJYNDhw7Zp/Rp/otq1aqZXBhAXtmm7Ol1N3r0aFMdTQUFBcnp06flwQcflLCwMLOsVatWps+hSVnpc7iAiwMQ8FKbNm2yxsfH29+fOXMm01ButXXrVmvZsmWtp0+fti+zDfuJjIy0tmvXzj4s6K233jKfM9wbrrr2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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ci_growth = CostIncome(\n", + " mkt_price_year=2020,\n", + " init_cost=50_000,\n", + " periodic_cost=5_000,\n", + " periodic_income=8_000,\n", + " cost_yearly_growth_rate=0.02, # costs grow 2% / year\n", + " income_yearly_growth_rate=0.03, # income grows 3% / year\n", + " freq=\"Y\",\n", + ")\n", + "\n", + "ci_growth.plot_cash_flows(impl_date, start_date, end_date)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "067c07aa", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " No growth With growth\n", + "Net 36000 -19821\n", + "Cost -60000 -97569\n", + "Income 96000 77748\n" + ] + } + ], + "source": [ + "# Compare totals with and without growth\n", + "no_growth = ci.calc_total(impl_date, start_date, end_date)\n", + "with_growth = ci_growth.calc_total(impl_date, start_date, end_date)\n", + "\n", + "labels = [\"Net\", \"Cost\", \"Income\"]\n", + "print(f\"{'':15s} {'No growth':>12s} {'With growth':>12s}\")\n", + "for label, ng, wg in zip(labels, no_growth, with_growth):\n", + " print(f\"{label:15s} {ng:>12.0f} {wg:>12.0f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "1408d2db", + "metadata": {}, + "source": [ + "## Custom cash flows\n", + "\n", + "For irregular or one-off flows, pass a `pd.DataFrame` with columns `date`, `cost`, and/or `income`.\n", + "\n", + "These are **added on top** of any periodic amounts; dates not present in the DataFrame simply contribute zero." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "c469839c-e2e2-43e9-ac33-93de7f04a6d9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
datenetcostincome
020200.00.00.0
120210.00.00.0
22022-50000.0-50000.00.0
320233000.0-5000.08000.0
42024-7000.0-15000.08000.0
520253000.0-5000.08000.0
6202618000.0-5000.023000.0
720273000.0-5000.08000.0
82028-17000.0-25000.08000.0
920293000.0-5000.08000.0
1020303000.0-5000.08000.0
\n", + "
" + ], + "text/plain": [ + " date net cost income\n", + "0 2020 0.0 0.0 0.0\n", + "1 2021 0.0 0.0 0.0\n", + "2 2022 -50000.0 -50000.0 0.0\n", + "3 2023 3000.0 -5000.0 8000.0\n", + "4 2024 -7000.0 -15000.0 8000.0\n", + "5 2025 3000.0 -5000.0 8000.0\n", + "6 2026 18000.0 -5000.0 23000.0\n", + "7 2027 3000.0 -5000.0 8000.0\n", + "8 2028 -17000.0 -25000.0 8000.0\n", + "9 2029 3000.0 -5000.0 8000.0\n", + "10 2030 3000.0 -5000.0 8000.0" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "custom_flows = pd.DataFrame(\n", + " {\n", + " \"date\": [\"2024-01-01\", \"2026-01-01\", \"2028-01-01\"],\n", + " \"cost\": [10_000, 0, 20_000], # extra one-off costs\n", + " \"income\": [0, 15_000, 0], # extra one-off income\n", + " }\n", + ")\n", + "\n", + "ci_custom = CostIncome(\n", + " mkt_price_year=2020,\n", + " init_cost=50_000,\n", + " periodic_cost=5_000,\n", + " periodic_income=8_000,\n", + " custom_cash_flows=custom_flows,\n", + " freq=\"Y\",\n", + ")\n", + "ci_custom.to_dataframe(impl_date, start_date, end_date)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "11f21296", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(, )" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ci_custom.plot_cash_flows(impl_date, start_date, end_date)" + ] + }, + { + "cell_type": "markdown", + "id": "f1f011b6", + "metadata": {}, + "source": [ + "## Sub-annual frequencies\n", + "\n", + "`freq` accepts any pandas-compatible period string. Common options:\n", + "\n", + "| `freq` | Meaning |\n", + "|---|---|\n", + "| `\"Y\"` | Annual |\n", + "| `\"Q\"` | Quarterly |\n", + "| `\"M\"` | Monthly |\n", + "| `\"7D\"` | Every 7 days |\n", + "\n", + "```{note}\n", + "The periodic amounts are interpreted as **per-period** values, not annualised.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "277e6948", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
datenetcostincome
02022-01-5000.0-5000.00.0
12022-02200.0-500.0700.0
22022-03200.0-500.0700.0
32022-04200.0-500.0700.0
42022-05200.0-500.0700.0
52022-06200.0-500.0700.0
62022-07200.0-500.0700.0
72022-08200.0-500.0700.0
82022-09200.0-500.0700.0
92022-10200.0-500.0700.0
102022-11200.0-500.0700.0
112022-12200.0-500.0700.0
\n", + "
" + ], + "text/plain": [ + " date net cost income\n", + "0 2022-01 -5000.0 -5000.0 0.0\n", + "1 2022-02 200.0 -500.0 700.0\n", + "2 2022-03 200.0 -500.0 700.0\n", + "3 2022-04 200.0 -500.0 700.0\n", + "4 2022-05 200.0 -500.0 700.0\n", + "5 2022-06 200.0 -500.0 700.0\n", + "6 2022-07 200.0 -500.0 700.0\n", + "7 2022-08 200.0 -500.0 700.0\n", + "8 2022-09 200.0 -500.0 700.0\n", + "9 2022-10 200.0 -500.0 700.0\n", + "10 2022-11 200.0 -500.0 700.0\n", + "11 2022-12 200.0 -500.0 700.0" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ci_monthly = CostIncome(\n", + " mkt_price_year=2022,\n", + " init_cost=5_000,\n", + " periodic_cost=500,\n", + " periodic_income=700,\n", + " freq=\"M\",\n", + ")\n", + "\n", + "df_monthly = ci_monthly.to_dataframe(\n", + " impl_date=\"2022-01-01\",\n", + " start_date=\"2022-01-01\",\n", + " end_date=\"2022-12-01\",\n", + ")\n", + "df_monthly" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "03a80747", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(, )" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABAAAAAJ9CAYAAACrcQCmAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAchtJREFUeJzt3Qd8VFX2wPGTAkkoCT2EjqFLE5DeWZpSRF0QFAUbiK4UFWQFKQoIuyKuCCurKxYUdv/IKggKLL1IkyaIItKkSoAkQEggmf/nXJ3ZJIQwgZnMm3m/7+czzMybNzNvbi6T3PPOPTfI4XA4BAAAAAAABLRgXx8AAAAAAADwPgIAAAAAAADYAAEAAAAAAABsgAAAAAAAAAA2QAAAAAAAAAAbIAAAAAAAAIANEAAAAAAAAMAGCAAAAAAAAGADBAAAAAAAALABAgAAAPjQqlWrJCgoSGbPnp0r79e6dWupUKFCrryXvzl06JD5WYwdO9ar76Pv0a9fP6++BwAAWSEAAACwvcuXL8tbb70lrVq1kqJFi0qePHmkRIkS0rFjR/nHP/4hycnJlm8jHVRe7+LtAa0nNW3alAEyAABeEuqtFwYAwF/O+t59992yd+9eadu2rYwYMUKKFy8ucXFx5uz8wIEDZcuWLTJr1iyxutq1a8sLL7yQ5XZ/8P3338vGjRulcuXK8u9//1v+9re/SWRkpASapKQkCQkJ8fVhAABsiAAAAMDWZ/67dOkiP/zwg/zrX/+SP/7xjxkef/755+W7776TpUuXij+IiYmRhx56SPzVe++9J/nz55c5c+ZIw4YNZe7cufLkk09KoAkPD/f1IQAAbIopAAAA29IB5549e2TYsGHXDP6datasaR532rx5s5m/XaVKFcmXL58ULFhQmjVrJgsWLLjmuUePHpXHHntMypcvL2FhYWZ6wZ133mmmFWTl3XfflRo1aph99TlTpkyR3LJ+/Xrp1KmTFCpUSCIiIqROnTpmWoTD4XDt8/HHH5v0fM2McEpNTZWoqCiz/ZtvvnFt12kT2j4PP/ywW+9/5coV+eijj+T+++83baQBAP35ZFfH4JdffpGePXtK4cKFTeBAp2z8+OOPGfZNTEyUUaNGSaNGjaRYsWKmbStVqiQvvviiXLp0KdtjOnXqlOTNm1cefPDBLB9/9tlnzed2vufZs2dNX4mNjTWDfD0uzb6YMGHCDWsAfPnll2YKimaf6HNLlSol3bp1M/0TAABPIQMAAGBbmmauBgwY4PZzdKCvA77evXtLmTJlzFSBDz74QO69915z5rpPnz5mv6tXr0r79u3l2LFj8tRTT0nVqlUlISHBZBSsWbNGnnjiiQyvO3PmTDl9+rQ8/vjjZkCtg22djqDv4XxNdwbRZ86cybAtODhYihQpku3zFi9eLN27dzcD5CFDhpiB6/z5880Ad/fu3a7pD+3atTPX//3vf80gXOn0CP1c+j66vXHjxma7pvJrqrtOq3DHwoULzed/5JFHzH29fvrpp017aRAms4sXL5oBc5MmTWTixIly8OBBefPNN83n0Oc4U+y1/TWQoAEeHcjr9tWrV5vgyvbt2+Xrr7++7jFFR0eb1/vss8/k3Llzpl3SBzj0592yZUsTDFL6Hvqz1f6kART9/NpXNGDy0ksvXfd99Hh0sF+rVi0TmNAgzIkTJ2TlypXm+bfffrtbbQgAwA05AACwqSJFijgKFiyYo+dcuHDhmm0XL150VKlSxVG9enXXtp07d+qpc8eUKVOyfb2VK1ea/WJiYhznzp3L8JrFihVzNG7c2K3j0tfI6hIVFZVhv1atWjnKly/vun/16lVzX9vh6NGjGbZ36tTJvMb69etd26tWrepo0qSJ6/6ECRMckZGRjm7dujnatGnj2j569Gjz3MOHD7t1/HfddZejQoUKjrS0NHP/7NmzjrCwMMfQoUOv2Vc/g7725MmTM2zXttbtX331lWtbcnKy48qVK9e8xqhRo8y+mzZtcm07ePCg2TZmzBjXtqVLl5ptb731Vobnz50712z/8MMPzf3z58+b+4MGDbrhZ9X9HnnkEdd9/Yy67fTp0zd8LgAAt4IpAAAA29Iz1zktMqep5k6aQq4ZAHqtZ7q1iJ2+ptKz+GrFihUmlfxG+vfvb878Omn6vJ5N379/v9vH1qBBA1m2bFmGy+eff57tc7799ls5fPiwSUnXbAMnPVP+5z//2dzWM+BO+jn1rL+m1js/n2YDdOjQQTZs2GDqKji3a6p9uXLlbnjcepZez8TrdAFNj1d6tl3Piuu0gJSUlGueoxkHmqGQnjPbIH2baQp/aGioKytDz+RrlsQf/vAHs23Tpk3ZHpvud9ttt10zHUHv689YpywonTahqfs6DUILS+aE8+euGSl6jAAAeAsBAACAbeng3zmQdZemqWthOk0P12CAps3rvO2///3v5vHz58+ba53D//LLL5sCgjqfu169eqZCf/p58unpIDMzrRmgAQZ36f46YE1/0TT57Pz888/mOqs0c01JT7+Pc5Ctg1RNddc0eB306za96H2tJaDp+VorwTll4EZmz55tagnoEoA//fST66KBBR2sf/HFF9c8R9s0czE9/fwqc5vNmDHDzMXX+f86HUJ/Xs4pDBoQyI4GJHS6xo4dO0ywRGnARKc76JQCHfg7Aw06BUHn7FesWNHUcnjmmWdMEOZGdL/69eubKQ96fJ07dzav5U7gCACAnCAAAACwLR3g6hn7AwcOuLV/Wlqamdevc/71bPW8efPkq6++MoM85zx93cdp3LhxZiCry9npPPH333/fzFnPfOZa+WpZuPRF/tzRpk0bMyjWAbBznr8O9KtXr25WIdDta9euNfUI3Jn/r++v7aK0CKEuAei86IBYZVUMMLv2Sv+ZXn/9dfM6emzvvPOOKbanPy8NOmT+eWWXnZEnTx5TpFHp8erztF5DehoY0rP/up8WMtR6EZoZ0aNHj2zfRwf9GjDRWgCDBw82GSXPPfec6TPpCy4CAHCrKAIIALAtTd/WQZdW5X/ttdduuL8WxNu1a5c5s6+D+/Scg8PM9GywDkD1omfItaicVtcfOnSoeczXtGK9yqravBbTS7+P8yy7nk3Xgb5mQGgmhLNInw74dbsO/jVIoMGCG9EBrgZgNCjSokWLax7XYoS6RKNW/E8/RcFdWkxRVwxYsmSJmTbgpIEbd+ln1OkIn3zyifzlL38xwQPN6Ljjjjuu2bdkyZJm5Qe96KBfswf++c9/mn6WXXvosWlBQb0onU6iWQFjxowxzwUAwBPIAAAA2JYO0vTMtZ4lTj/PPfOgXx9Pf9Y581lzHShnXgYwPj7eDITT0xR0Z6q9LhlnBTqQ1ekKmtWgc/GddPA6adIkc1vPYKenA31tl//7v//LcJZfb2/bts2k7Gt2haba34ie3dfBr9Yb0IBM5ouuSqDH4jxjn1P6M9NgRPqfmU5hcCfgk/nsvv5MBw4caKYAZD77r2ftMy8rqJ+rbt26N/x5Z165QenZf11i0ir9BAAQGMgAAADYls7fXrRokdx9991y3333mTnzmrKt8/p1HrmeedUl8pxL9mmwQAfwuoScDvZ0aT9dpk1Ty/UsuHOOuNIl3HTQqK/rHMzpPHLdV8+gOweGvqYDZJ0jr5kJmrauS9hpAT4NiOjn18+uc/PT04H+G2+8Ifv27ZPnn3/etV2nAuhcfm0THbjfiNZL0DP8zZs3N2fZs9KwYUNz5l/PoutSes4ige7SIMLIkSPNvHpdqlGnfOiZfE3pzwmd+qF1GjSjQPtN5qUZ9TNrvQUNlmgf0UwJbR9d3lHrFTiLDmZF21gzHLTvaTBGM0W0IKDWm9C6EQAAeAoBAACAremgTs9a6zQAPaOtZ721MKAOgvXsuG5/6KGHXINlnUOug149Y67F7nTgr7d37tyZIQCg68DrgFMH0bpevA6My5Yta56rgzpfzfnPyl133WUCFq+88opMnTrVDEB1Dr4WovvTn/50zf460NXK+nomPX0GgA5etT21aKA78/91IK6rBjgr6WdFB/zajlpHQY/RnddNT9taz/5rpoHOr9cU/V69epl5/Vqoz116HHrWXzMV/vjHP7pWeXDSn+2jjz5qjlFXXtDPpQN/rRXx4osvXrN/en379jUZDtqPfv31V1Ocslq1aqZ9evfunaPPCwBAtr/PdC1AmggAACB7f/3rX01AQVdAyKpeAQAAVkcAAAAA4AY020GnfOjSg1kVTAQAwB8wBQAAAOA6Dh48aJY71LR+ndqgafkAAPgrAgAAAADXoTUctF6AFobU5R+Zkw8A8GdMAQAAAAAAwAaCfX0AAAAAAADA+wgAAAAAAABgAwQAAAAAAACwAQIAAAAAAADYAAEAAAAAAABsgAAAAAAAAAA2QAAAAAAAAAAbIAAAAAAAAIANEAAAAAAAAMAGCAAAAAAAAGADBAAAAAAAALABAgAAAAAAANgAAQAAAAAAAGyAAAAAAAAAADZAAAAAAAAAABsgAAAAAAAAgA0QAAAAAAAAwAYIAAAAAAAAYAMEAAAAAAAAsAECAAAAAAAA2AABAAAAAAAAbIAAAAAAAAAANkAAAAAAAAAAGyAAAAAAAACADRAAAAAAAADABggAAAAAAABgAwQAAAAAAACwAQIAAAAAAADYAAEAAAAAAABsgAAAAAAAAAA2QAAAAAAAAAAbIAAAAAAAAIANEAAAAAAAAMAGCAAAAAAAAGADBAAAAAAAALABAgAAAAAAANgAAQAAAAAAAGyAAAAAAAAAADZAAAAAAAAAABsgAAAAAAAAgA0QAAAAAAAAwAYIAAAAAAAAYAMEAAAAAAAAsAECAAAAAAAA2AABAAAAAAAAbIAAAAAAAAAANkAAAAAAAAAAGyAAAAAAAACADRAAAAAAAADABggAAAAAAABgAwQAAAAAAACwAQIAAAAAAADYAAEAAAAAAABsgAAAAAAAAAA2QAAAAAAAAAAbIAAAAAAAAIANEAAAAAAAAMAGCAAAAAAAAGADBAAAAAAAALABAgAAAAAAANgAAQAAAAAAAGyAAAAAAAAAADZAAAAAAAAAABsgAAAAAAAAgA0QAAAAAAAAwAYIAAAAAAAAYAMEAAAAAAAAsAECAAAAAAAA2AABAAAAAAAAbIAAAAAAAAAANkAAwIMcDockJCSYawAAAAAArIQAgAclJiZKVFSUuQYAAAAAwEoIAAAAAAAAYAMEAAAAAAAAsAECAAAAAAAA2AABAAAAAAAAbIAAAAAAAAAANkAAAAAAAAAAGyAAAAAAAACADRAAAAAAAADABggAAAAAAABgAwQAAAAAAACwAQIAAAAAAADYAAEAAAAAAABsgAAAAAAAAAA2QAAAAAAAAAAbIAAAAAAAAIANhPr6AACr6/ruKfEXCx+P9urr0xaB10650W/8vY180T5OtBPtY7d+pPg/Z9128qe+RD+ydlvRl3yHDAAAAAAAAGyAAAAAAAAAADZAAAAAAAAAABsgAAAAAAAAgA0QAAAAAAAAwAYIAAAAAAAAYAMEAAAAAAAAsAECAAAAAAAA2AABAAAAAAAAbIAAAAAAAAAANkAAAAAAAAAAGyAAAAAAAACADRAAAAAAAADABggAAAAAAABgAwQAAAAAAACwAQIAAAAAAADYAAEAAAAAAABsgAAAAAAAAAA2QAAAAAAAAAAbIAAAAAAAAIANEAAAAAAAAMAGCAAAAAAAAGADBAAAAAAAALCBUF8fAGAnJ779Un5c+LpcPHlA8peMlSpdn5OYeneLXdEetBP9iP9zVsJ3Eu1En+L/nBXx3UT7eBIZAEAufnlvm/mYJB77XtKuJptrva/b7Yj2oJ3oR/yfsxK+k2gn+hT/56yI7ybax9OCHA6Hw+OvalMJCQkSFRUl8fHxEhkZ6evDgYd0ffeUR15n9bi2ZtAv1/yXC5Lg0DweeY9QL4f0rqZ57rXSrqZc5xHPtIe32yJQ2slXbeWpNgrU9vHHvuTP/ShQ28cf20nRVtZtJ3/qS/QjG/8dEBQkBUtXl1ZjVnjkPRY+Hi2BhCkAQC7RtP9rB//Kkc0vwZzxzKv4mmfaIzDaIjv0G9rHWn0pcP/P0T652U6B3Zec6FO0j3X6UUD+n3M4fvu7G1kiAADkEp3zn/jL9+YL2yUoSAqUrCyNhsz1yHvM7lNcvKnfJ7967LU2vdFLLpz8yWvt4e22CJR28lVbeaqNArV9/LEv+XM/CtT28cd2UrSVddvJn/oS/cjufwdU8sjrByICAEAu0YJ/OuffJSjIRCir3vOiRBQp5ZH3KFPGuylKEUU8k96pqvZ48bf2+L0dPN0e3m6LQGknX7WVp9ooUNvHH/uSP/ejQG0ff2wnRVtZt538qS/Rj+z9d0Dlrs955PUDEUUAgVyi1f6LVW9pbgcFh5i5SfWf+qfE1LvLtu1R/6n3TDsEh4bZvj1oJ/oR/+d8i+8k2ok+xf85K+K7ifbxNDIAgFx06cxhc93g6dkSXbu97dtef6nZeRlEd9FOtA99if9rVsJ3Em1FX+L/nJXwnZQzZAAAueTSmSNy6dfDEhQSKkWrNKHdAQAAAOQqAgBALjnz/TpzXajCHRIaXoB2BwAAAJCrCABkMmPGDKlYsaKEh4dL/fr1Ze3atbn7E0HAOrPvt75UrHpzXx8KAAAAABsiAJDOvHnzZMiQIfLSSy/J9u3bpUWLFtK5c2c5cuSI735CCAgOh0Pi9v2WAVCsWgtfHw4AAAAAGyIAkM7UqVPlsccek8cff1yqV68u06ZNk7Jly8rMmTN99xNCQEg8vk+SE36V4LwRUui2+r4+HAAAAAA2RADgdykpKbJt2zbp0KFDhgbS+xs2bMiy8ZKTkyUhISHDBchu/n/RSo0kJE8YjQQAAAAg17EM4O/OnDkjqampEh0dnaGB9P7JkyezbLxJkybJuHHjrtl+5513SkhIiPTu3Vuefvppad78f3O+9+7dK0OHDpWvv/7a3B81apRUqlRJ+vXrZ+7HxsbKwoULpWvXrnLgwAGzbfbs2fLTTz/Jq6++au537NhR3njjDalRo4brddetWydvv/22fPrpp+a+vq/u161bN3O/cOHCsn79ennkkUdky5YtsrJRrPiTNpsO3PAzObM41LBhw1w/iw8++ECaNWsm586dM9u++OIL0/7aXupGPydPtNXDG7bIXhEZEHFSnln/uHhLjam/9Rlv9T1/6jfaFrn1/ylz3/OndnK2VW79f3L2vXfX/9a2/uCVEw199l3uT31J+5E3/j9l1/feXT9U/MVXZZ722v+nG/U9f+pH6pN8vX3yt5E//p/L7b+N3vXi3zCe9lPrN7zy/8mdvudP/Uhtrj3a638bZe57n1UpIv7ikbVFcuVvo1vte/qa7ghy6ORkyPHjx6V06dLmbH+TJv9bom3ChAny0Ucfyb59+7LMANCLk2YA6JSB+Ph4iYyMtHSrnurfVfxJ9PsL/batrqalSfVPl0rilavydZfmUqdYIfHXdvKnfuPPfcYObeVPbURfsm470Y8Cr50U/+es207+1JfoR9ZuK/qS75AB8LtixYqZs/aZz/afPn36mqwAp7CwMHMBsrPzTLwZ/BfKm0dqFomisQAAAAD4BDUAfpc3b16z7N+yZcsyNJDeb9q0qS9+NggQa0+cMdfNYopKSHCQrw8HAAAAgE2RAZCOzuXo27evNGjQwEwDmDVrllkCcODAgb77CSFgAgDNY4r5+lAAAAAA2BgBgHR69eolcXFxMn78eDlx4oTUrFlTFi9eLOXLl/fdTwh+Lelqqmw9/VtBkBYEAAAAAAD4EAGATAYNGmQugCdsOX1WktPSpGS+MImNzE+jAgAAAPAZagAAuZT+HxTE/H8AAAAAvkMAAMiFAADp/wAAAAB8jQAA4CXxyVdkV1y8uU0AAAAAAICvEQAAvGTDyThJc4iZ+18qfwTtDAAAAMCnCAAAXsLyfwAAAACshAAA4CXM/wcAAABgJQQAAC84demy7I+/IFr3v1lMUdoYAAAAgM8RAAC8YN3v1f9rFY2SwmF5aWMAAAAAPkcAAPCCtSfizHVzzv4DAAAAsAgCAICHORwO5v8DAAAAsBwCAICHHUq8JMcuJkme4CBpWKII7QsAAADAEggAAF6q/l+/eGHJnyeU9gUAAABgCQQAAA9j+T8AAAAAVkQAAPCgNIdD1v+eAdAiphhtCwAAAMAyCAAAHrT3bIKcTb4i+UND5I7ihWhbAAAAAJZBAADwQvp/45JFJU8w/70AAAAAWAcjFMCDmP8PAAAAwKoIAAAekpKaJt+cOmtuM/8fAAAAgNUQAAA8ZPuZ83LpaqoUCcsr1QsXpF0BAAAAWAoBAMDD6f/NY4pKcFAQ7QoAAADAUoIcDofD1wcRKBISEiQqKkri4+MlMjJSrOxU/67iT6LfXyhW17JlS1m7dq2888478uSTT/r6cAAAAAAgAzIAAA+4ePGifPPNN+Z2u3btaFMAAAAAlkMAAPAAPfN/5coVKVeunNx22220KQAAAADLIQAAeMB///tf19n/IOb/AwAAALAgAgCAhwMAAAAAAGBFBACAWxQXFyc7duwwt9u2bUt7AgAAALAkAgDALVq5cqXoYho1atSQmJgY2hMAAACAJREAAG4R6f8AAAAA/AEBAOAWEQAAAAAA4A8IAAC34OjRo7J//34JDg6WVq1a0ZYAAAAALIsAAHALVqxYYa4bNGgghQoVoi0BAAAAWBYBAOAWkP4PAAAAwF8QAABuklb+JwAAAAAAwF8QAABu0g8//CDHjx+XsLAwadq0Ke0IAAAAwNIIAAA3yXn2v1mzZhIREUE7AgAAALA0AgDATSL9HwAAAIA/IQAA3ITU1FRZuXKlud2uXTvaEAAAAIDlEQAAbsL27dvl/PnzEhkZKfXr16cNAQAAAFgeAQDgFtL/W7duLaGhobQhAAAAAMsjAADcBOb/AwAAAPA3BACAHEpOTpZ169aZ28z/BwAAAOAvCAAAObRx40ZJSkqSkiVLSo0aNWg/AAAAAH6BAABwk+n/bdu2laCgINoPAAAAgF8gAADkEPP/AQAAAPgjSwUAPvvsM+nYsaMUK1bMnFndsWOHW8+bP3++ScUOCwsz1wsWLLhmnxkzZkjFihUlPDzcLNu2du3aDI87HA4ZO3aslCpVSiIiIkx19z179njssyEwJCQkyObNm10ZAAAAAADgLywVALh48aI0a9ZMXnvttRzNx+7Vq5f07dtXdu7caa579uwpmzZtcu0zb948GTJkiLz00ktm/fYWLVpI586d5ciRI659pkyZIlOnTpXp06fLli1bzPzu9u3bS2Jiosc/J/zXmjVrJDU1VW677TapUKGCrw8HAAAAANwW5NBT3xZz6NAhc7ZeB+t169bNdl8d/OtZ2SVLlri2derUSQoXLiyffvqpud+oUSOpV6+ezJw507VP9erV5Z577pFJkyaZs/965l+DBCNGjHBVeo+OjpbJkyfLgAED3DpuPY6oqCiJj4+XyMhIsbJT/buKP4l+f6FYwdChQ2XatGnyxBNPyKxZs3x9OAAAAADgnxkAN0MzADp06JBhm04j2LBhg7mdkpIi27Ztu2Yfve/c5+DBg3Ly5MkM++h0glatWrn2yYoGCXTQn/6CwMb8fwAAAAD+yu8DADpw1zP16el93a7OnDljUraz28d5nd0+WdHsAT3j77yULVvWY58L1nP69GnZvXu3uc38fwAAAAD+xmcBgDlz5kiBAgVcl8xF+XIi81JsmtKfeZun9klv5MiRJt3feTl69OhNfwZY34oVK8x17dq1pXjx4r4+HAAAAADIkVDxkW7dupm5+U6lS5e+qdfRYn2Zz9LrmVrn2XxdUSAkJCTbffQ1lO4TExOT5T5Z0WkCeoE9kP4PAAAAwJ/5LAOgYMGCUqlSJddFl967GU2aNJFly5Zl2LZ06VJp2rSpuZ03b16z7F/mffS+cx8tOKhBgPT7aO2A1atXu/YBnBkA7dq1ozEAAAAA+B2fZQBk5ezZs2ZpvuPHj5v7P/zwg7nWwbnzLH1mgwcPlpYtW5pq/d27d5fPP/9cli9fLuvWrXPtM2zYMLM8YIMGDUzAQKu36/sMHDjQPK5p/roCwMSJE6Vy5crmorfz5csnffr0yZXPDmvTlSl+/vlnCQ0NNf0NAAAAAPyNpQIAX3zxhfTv3991/4EHHjDXY8aMkbFjx5rb/fr1M4OxVatWmft6hn7u3LkyatQoGT16tMTGxsq8efMyTC/QpQLj4uJk/PjxcuLECalZs6YsXrxYypcv79pn+PDhkpSUJIMGDZJz586Z52smgWYqAM70/4YNG9InAAAAAPilIIdWuvMjrVu3NhdnQMBKdBlAXQ1ACwJGRkaKlZ3q31X8SfT7C336/poJ8umnn5ogkwaSAAAAAMDfWCoD4EYSExPlwIEDsmjRIl8fCmxEY2TM/wcAAADg7/wqAKDp+Cy1h9y2Z88eOXXqlClU2bhxY34AAAAAAPySz1YBAPxt/n+LFi1Y9hEAAACA3yIAALgZAGD5PwAAAAD+jAAAkI2rV6/K6tWrzW0CAAAAAAD8GQEAIBtbt241qzsULlxY6tatS1sBAAAA8FsEAAA30v/btGkjISEhtBUAAAAAv0UAAMgG8/8BAAAABAoCAMB1JCUlyYYNG8xt5v8DAAAA8HcEAIDrWL9+vSQnJ0vp0qWlSpUqtBMAAAAAv0YAAHAj/T8oKIh2AgAAAODXCAAA18H8fwAAAACBhAAAkIXz58/Ltm3bzO22bdvSRgAAAAD8HgEAIAurVq2StLQ0M/e/TJkytBEAAAAAv0cAAMgC6f8AAAAAAg0BACALBAAAAAAABBoCAEAmx48fl++//95U/m/Tpg3tAwAAACAgEAAAMlmxYoW5vuOOO6RIkSK0DwAAAICAQAAAyIT0fwAAAACBiAAAkI7D4XBlALRr1462AQAAABAwCAAA6Rw4cECOHDkiefLkkebNm9M2AAAAAAIGAQAgi/T/Jk2aSP78+WkbAAAAAAGDAACQDvP/AQAAAAQqAgDA79LS0pj/DwAAACBg5TgA8OCDD8qsWbPkxx9/9M4RAT6ya9cuiYuLkwIFCkjDhg35OQAAAACwdwBAB0dTp06VatWqSalSpaR3797y97//Xfbt2+edIwRyOf2/ZcuWpgggAAAAANg6APDOO++Ywf7x48dNICAqKkrefPNNuf322yUmJsY7RwnkAub/AwAAAAhkN10DoGDBglK4cGFzKVSokISGhkrJkiU9e3RALklJSZE1a9aY2+3ataPdAQAAAAScHAcARowYIY0bN5ZixYrJqFGjzMBp5MiRcurUKdm+fbt3jhLwss2bN8vFixdNv65VqxbtDQAAACDghOb0CX/5y1+kePHiMmbMGOnevbtUr17dO0cG+CD9v23bthIczOIYAAAAAAJPjgMAepZ/9erVsmrVKnn99dclJCREWrVqJa1btzYXAgLwR8z/BwAAABDoghwOh+NWXmDnzp0ybdo0+fjjj8066qmpqWJXCQkJpihifHy8REZGipWd6t9V/En0+wu99tqa+q+1LK5cuSI//fSTxMbGeu29AAAAAMBvMgCcWQCaAaCXtWvXmoFv3bp1pU2bNp4/QsDLtA/r4L98+fJy22230d4AAAAAAlKOAwB6pvTChQtSp04dk/L/xBNPmHXTrX7GG3An/T8oKIiGAgAAABCQchwA+OijjxjwI6Aw/x8AAACAHeQ4ANClSxfX7V9++cWcMS1durSnjwvIFXFxcbJjxw5zmyksAAAAAAJZjtc700J/48ePN8XudM50uXLlpFChQvLKK6+YxwB/snLlStE6mDVq1JCYmBhfHw4AAAAAWCcD4KWXXpL33ntPXnvtNWnWrJkZPK1fv17Gjh0rly9flgkTJnjnSAEvIP0fAAAAgF3kOADwwQcfyLvvvivdunVzbdOCgDoNYNCgQQQA4FcIAAAAAACwixxPATh79qxUq1btmu26TR8D/MXRo0dl//79EhwcLK1atfL14QAAAACAtQIAerZ/+vTp12zXbfoY4G9n/xs0aGDqWAAAAABAIMvxFIApU6bI3XffLcuXL5cmTZqYVQA2bNhgzqYuXrzYO0cJeAHp/wAAAADsJMcZAJoq/eOPP0qPHj3k/PnzJu3/3nvvlR9++EFatGjhnaMEPEyLV65YscLcbteuHe0LAAAAIODlOANAlSpVimJ/8GsasDp+/LiEhYVJ06ZNfX04AAAAAGCNDIBdu3a5fblZV65ckREjRkitWrUkf/78Jsjw8MMPm0HajcyfP9+s466DOb1esGDBNfvMmDFDKlasKOHh4VK/fn1Zu3btNWeEdSlDfd+IiAhp3bq17Nmz56Y/D/wj/V+XstSfNwAAAAAEOrcyAOrWrWvm+usgOTu6T2pq6k0dyKVLl+Tbb7+V0aNHm2KC586dkyFDhpjlBrdu3Xrd523cuFF69eolr7zyipmWoIP/nj17yrp166RRo0Zmn3nz5pnX0iCADvjeeecd6dy5s+zdu1fKlSvnqm0wdepUmT17tlSpUkVeffVVad++vTlTXLBgwZv6TLAu5v8DAAAAsJsgx41G9SJy+PBht1+wfPny4ilbtmyRhg0bmvd3DtQz08F/QkKCLFmyxLWtU6dOUrhwYfn000/NfQ0E1KtXT2bOnOnap3r16nLPPffIpEmTTGBDz/xrkECzEFRycrJER0fL5MmTZcCAAW4drx5HVFSUxMfHS2RkpFjZqf5dxZ9Ev7/QY6+lQapixYqZGhbffPONK1AEAAAAAGL3KQB6Zl0HtDq4/+CDD6R48eLmdlYXT9KBtGYVZLdEm2YAdOjQIcO2jh07mpUJVEpKimzbtu2affS+c5+DBw/KyZMnM+yj0wm04KFzn6xokEAH/ekvsL7t27ebwb/2aZ0OAgAAAAB24FYA4Pvvv5eLFy+a2+PGjZMLFy54+7jk8uXL8uKLL0qfPn2yPZuuA3c9U5+e3tft6syZM+aMb3b7OK+z2ycrmj2gZ/ydl7Jly97EJ4Wv0v+1zkNo6E3VwQQAAACAwK0B0L9/f2nevLlJl//rX/8qBQoUyHLfl19+2a03njNnTobUek3hdy4jqAUBH3jgAUlLSzPz9m9EswTS02PMvM1T+6Q3cuRIGTZsmOu+ZgAQBLA+5v8DAAAAsCO3AgBaGG/MmDGyaNEiMyDWwXpWZ071MXcDAFrcL/3c69KlS7sG/1rET9PydZ32G82lL1my5DVn6U+fPu06m69zvUNCQrLdR19D6T4xMTFZ7pMVnSagF/gPnbahBSJVu3btfH04AAAAAGCtAEDVqlVl7ty55nZwcLA5g1qiRIlbemOtrJ+5ur5z8L9//35ZuXKlFC1a9Iav06RJE1m2bJkMHTrUtW3p0qWutd3z5s1r5nnrPlrLwEnvd+/e3dzW5QE1CKDb7rjjDlftgNWrV5sigAgcWjMiKSnJ/Lx1yUgAAAAAsIscT4DWtHxvuHr1qtx///1mKUDNNNB5+86z9kWKFDED+awMHjxYWrZsaQbqOqD//PPPZfny5a6zvErT9Pv27SsNGjQwAYNZs2bJkSNHZODAga7MBV0BYOLEiVK5cmVz0dv58uUzNQgQeOn/bdu2zXZ6BwAAAAAEGstUQPvll1/kiy++cNUcSE+zAbRgm+rXr58cOnRIVq1aZe7rmX7NThg1apSMHj1aYmNjZd68eRmmF+hSgXFxcTJ+/Hg5ceKE1KxZUxYvXpxh1YLhw4ebM8ODBg2Sc+fOmedrJkHmLAX4N+b/AwAAALCrIIdWuvMjGgjQy9ixY8VqtAigrgagyxfeqHaBr53q31X8SfT7Cz3y89FsEs0u0SCSp5etBAAAAAArs0wGgDsSExPlwIEDZooAkFNr1qwxg3/NEmHwDwAAAMBu/CoAoOn4R48e9fVhwE+R/g8AAADAzm46AKBV8nWZvMxFAcuVK+eJ4wI8jgAAAAAAADvLcQBAl+h79NFHZcOGDRm2aykBraquKdaA1Wiwavfu3eZ2mzZtfH04AAAAAGD9AIBW4Q8NDTXz8GNiYlhKDX5hxYoV5rp27dpSvHhxXx8OAAAAAFg/ALBjxw7Ztm2bVKtWzTtHBHgB6f8AAAAA7C44p0+oUaOGnDlzxjtHA3gJAQAAAAAAdhfs7vrpzsvkyZNl+PDhsmrVKomLi8vwmF4Aqzl48KC56NSVli1b+vpwAAAAAMC6UwAKFSqUYa6/Fvxr165dhn0oAgirn/1v2LChWUoSAAAAAOzIrQDAypUrvX8kgJcLAGYOWgEAAACAnbgVAGjVqpX3jwTwAs1MIQAAAAAAADdRBPCrr76SdevWue6//fbbUrduXenTp4+cO3eONoWl7NmzR06dOiURERHSuHFjXx8OAAAAAPhPAOCFF15wFfvbvXu3DBs2TO666y75+eefzW3AivP/W7RoIWFhYb4+HAAAAACw9hSA9LSaui4FqObPny9du3aViRMnyrfffmsCAYCVsPwfAAAAANxkBkDevHnl0qVL5vby5culQ4cO5naRIkVYBhCWcvXqVVm9erW5TQFAAAAAAHaX4wyA5s2bm1T/Zs2ayebNm2XevHlm+48//ihlypTxxjECN2Xr1q0mKFW4cGFTpwIAAAAA7CzHGQDTp0+X0NBQ+b//+z+ZOXOmlC5d2mxfsmSJdOrUyRvHCNxS+n+bNm0kJCSEVgQAAABgaznOAChXrpwsWrTomu1vvPGGp44J8Ajm/wMAAADALQQA0ktKSpIrV65k2BYZGXkrLwl4hPbNDRs2mNvM/wcAAACAm5gCcPHiRXnmmWekRIkSUqBAATO/Ov0FsIL169dLcnKymaJSpUoVXx8OAAAAAPhfAGD48OGyYsUKmTFjhllX/d1335Vx48ZJqVKl5MMPP/TOUQK3kP4fFBRE+wEAAACwvRxPAVi4cKEZ6Ldu3VoeffRRadGihVSqVEnKly8vc+bMkQcffND2jQrfY/4/AAAAANxiBsDZs2elYsWKrvn+et+5POCaNWty+nKAx50/f162bdtmbjP/HwAAAABuMgBw2223yaFDh8ztGjVqyL/+9S9XZkChQoVy+nKAx61atUrS0tKkatWqrmUqAQAAAMDuchwA6N+/v+zcudPcHjlypKsWwNChQ+WFF17wxjECOUL6PwAAAAB4oAaADvSd2rRpI/v27ZOtW7dKbGys1KlTJ6cvB3gcAQAAAAAA8EAAILNy5cqZC2AFx48fl++//95U/tdClQAAAACAHE4B0KX/dM5/QkLCNY/Fx8fL7bffLmvXrnX35QCv0H6q7rjjDilSpAitDAAAAAA5DQBMmzZNnnjiCVP5P7OoqCgZMGCATJ061d2XA7yC9H8AAAAAuMUAgBb+69Sp03Uf79Chg2vpNcAXHA4HAQAAAAAAuNUAwKlTpyRPnjzXfTw0NFR+/fVXd18O8LiffvpJjh49avpp8+bNaWEAAAAAuJkAgK6nvnv37us+vmvXLomJiXH35QCvpf83adJE8ufPTwsDAAAAwM0EAO666y55+eWX5fLly9c8lpSUJGPGjJEuXbq4+3KA1woAtmvXjtYFAAAAgEyCHDpx2s0pAPXq1ZOQkBB55plnpGrVqmapNV1y7e2335bU1FT59ttvJTo6WuxKV0jQgoi6KkJWxRKt5FT/ruJPot9fmO3jaWlpUqJECYmLi5N169ZJs2bNcu3YAAAAAMAfhLq7ow7sN2zYIE899ZSMHDnSFFxTGgTo2LGjzJgxw9aDf/iWTkHRwX+BAgWkYcOG/DgAAAAA4GYDAKp8+fKyePFiOXfunCm4pkGAypUrS+HChXPyMoDX5v+3bNky22KVAAAAAGBXOQoAOOmA/8477/T80QC3GABg/j8AAAAA3GIRQMCqUlJSZM2aNeY2AQAAAAAAyBoBAPi9zZs3y8WLF6VYsWJSq1YtXx8OAAAAAFgSAQAETPp/27ZtJTiYLg0AAAAAWWG0BL/H/H8AAAAAuDECAPBrmvr/zTffmNvM/wcAAACA6yMAAL+2du1auXLlilmi8rbbbvP14QAAAACAZREAQMCk/wcFBfn6cAAAAADAsiwVABg7dqxUq1ZN8ufPL4ULF5Y//OEPsmnTphs+b/78+VKjRg0JCwsz1wsWLLhmnxkzZkjFihUlPDxc6tevb84cp+dwOMz7lypVSiIiIqR169ayZ88ej34+eB7z/wEAAADADwMAVapUkenTp8vu3btl3bp1UqFCBenQoYP8+uuv133Oxo0bpVevXtK3b1/ZuXOnue7Zs2eGwMG8efNkyJAh8tJLL8n27dulRYsW0rlzZzly5IhrnylTpsjUqVPN+2/ZskVKliwp7du3l8TERK9/btycuLg42bFjh2sFAAAAAADA9QU59NS3RSUkJEhUVJQsX778ugXedPCv+y1ZssS1rVOnTiaD4NNPPzX3GzVqJPXq1ZOZM2e69qlevbrcc889MmnSJHP2X8/8a5BgxIgR5vHk5GSJjo6WyZMny4ABA7J8b91HL+mPt2zZshIfHy+RkZFiZaf6dxV/Ev3+wmu2/d///Z/88Y9/lNtvv12+++47nxwXAAAAAPgLS2UApJeSkiKzZs0yAYA6depkmwGgWQLpdezYUTZs2OB6nW3btl2zj9537nPw4EE5efJkhn10OkGrVq1c+2RFgwd6fM6LDv6Re0j/BwAAAAA/DgAsWrRIChQoYObqv/HGG7Js2TIpVqzYdffXgbueqU9P7+t2debMGUlNTc12H+d1dvtkZeTIkeZsv/Ny9OjRm/jEuNUAAOn/AAAAAGDhAMCcOXPMQN95cRbla9OmjZnXrWfeNZVf5/OfPn0629fKXP1dU/ozb/PUPulploCm+qe/IHdosGX//v0SHBxsMjUAAAAAABYNAHTr1s0M9J2XBg0amO26AkClSpWkcePG8t5770loaKi5vh4t1pf5LL0GDJxn8zV7ICQkJNt99DVUdvvAmmf/td8UKlTI14cDAAAAAJbnswBAwYIFzUDfedGl97KiZ+HTF9rLrEmTJmaaQHpLly6Vpk2bmtt58+Y1y/5l3kfvO/fR5QE1CJB+H60dsHr1atc+sBbm/wMAAABAzoSKRVy8eFEmTJhgMgNiYmLMEm8zZsyQX375xVR6v57BgwdLy5YtTbX+7t27y+eff25WDdBlBJ2GDRtmlgfUs8UaMNDigroE4MCBA83jmuavKwBMnDhRKleubC56O1++fNKnT59c+fxwnwaFCAAAAAAAgJ8GADRNf9++ffLBBx+Ywn1FixaVO++809QG0GXenPr16yeHDh2SVatWmft6hn7u3LkyatQoGT16tMTGxsq8efPM0n/plwrUgML48ePlxIkTUrNmTVm8eLGUL1/etc/w4cMlKSlJBg0aJOfOnTPP10wCzVSAtWg/0Z+j1mAgQwMAAAAA3BPk0NOpfqR169bmMnbsWLGahIQEsxygrghg9YKAp/p3FX8S/f5C1+3p06fLn/70J1P935kJAAAAAADwkwwAdyQmJsqBAwfMUoGwrxUrVpjrdu3a+fpQAAAAAMBv+FUAQNPxdfk32FdqaqqsXLnS3CYAAAAAAAB+sAoAcDO2b98u58+fN1MsdHUHAAAAAIB7CADArzjn/GsdiNBQv0pgAQAAAACfIgAAv8LyfwAAAABwcwgAwG8kJyfLunXrzG3m/wMAAABAzhAAgN/YuHGjJCUlScmSJaVGjRq+PhwAAAAA8CsEAOB36f9t27aVoKAgXx8OAAAAAPgVAgDwG8z/BwAAAICbRwAAfiEhIUE2b95sbjP/HwAAAAByjgAA/MKaNWskNTVVYmNjpXz58r4+HAAAAADwOwQA4BdI/wcAAACAW0MAAH6BAAAAAAAA3BoCALC8X5OSZffu3eZ2mzZtfH04AAAAAOCXCADA8tafOGOu69SpI8WLF/f14QAAAACAXyIAAMtb+3sAgOr/AAAAAHDzCADA8taeiDPXbdu29fWhAAAAAIDfIgAASzuceEmOXLgkoaGh0rJlS18fDgAAAAD4LQIAsLR1v6f/N2zYUAoWLOjrwwEAAAAAv0UAAJbG/H8AAAAA8AwCALAsh8PhygCgACAAAAAA3BoCALCsfecT5czlFIkICZbGjRv7+nAAAAAAwK8RAIBlrT3+29n/RtFFJSwszNeHAwAAAAB+jQAALGvdyd+W/2seU9TXhwIAAAAAfo8AACzpalqabPw9ANAippivDwcAAAAA/B4BAFjSzjPxknjlqhTKm0dqFony9eEAAAAAgN8jAABLL//XLKaohAQH+fpwAAAAAMDvEQCApQMAzUn/BwAAAACPIAAAy0m6mipbT58zt5n/DwAAAACeQQAAlrPl9FlJTkuTmHzhEhuZ39eHAwAAAAABgQAALJ3+HxTE/H8AAAAA8AQCALBsAID0fwAAAADwHAIAsJT45CuyKy7e3G4RU9TXhwMAAAAAAYMAACxlw8k4SXOIVIrMLzH5I3x9OAAAAAAQMAgAwJrz/0sV8/WhAAAAAEBAIQAAS2H+PwAAAAB4BwEAWMbJS5dlf/wF0br/TUsy/x8AAAAAPIkAACxj3e/p/7WKRknhsLy+PhwAAAAACCgEAGAZpP8DAAAAgPcQAIAlOBwOVwZAc5b/AwAAAACPIwAASziYeFGOXbwseYKDpGGJIr4+HAAAAAAIOAQAYAlrj8eZ6/rFC0v+PKG+PhwAAAAACDgEAGAJzP8HAAAAAJsGAAYMGCBBQUEybdq0G+47f/58qVGjhoSFhZnrBQsWXLPPjBkzpGLFihIeHi7169eXtWvXXjMHfezYsVKqVCmJiIiQ1q1by549ezz6mZC1NIdDNpz8bf5/i5hiNBMAAAAA2CUA8J///Ec2bdpkBuM3snHjRunVq5f07dtXdu7caa579uxpnu80b948GTJkiLz00kuyfft2adGihXTu3FmOHDni2mfKlCkydepUmT59umzZskVKliwp7du3l8TERK99Tvxmz9kEOZt8RfKHhsgdxQvRLAAAAABghwDAsWPH5JlnnpE5c+ZInjx5bri/ZgjoQH3kyJFSrVo1c92uXbsMmQM6sH/sscfk8ccfl+rVq5vHypYtKzNnznSd/ddtGiC49957pWbNmvLBBx/IpUuX5JNPPvHq54W4qv83LllU8gRbrksCAAAAQECw1GgrLS3NnMF/4YUX5Pbbb3frOZoB0KFDhwzbOnbsKBs2bDC3U1JSZNu2bdfso/ed+xw8eFBOnjyZYR+dTtCqVSvXPllJTk6WhISEDBfkHPP/AQAAAMBmAYDJkydLaGioPPvss24/Rwfu0dHRGbbpfd2uzpw5I6mpqdnu47zObp+sTJo0SaKiolwXzSpAzqSkpsk3p86a28z/BwAAAIAADABoin+BAgVcl9WrV8ubb74ps2fPNsX/ciLz/prSn3mbp/ZJT6cbxMfHuy5Hjx7N0XFDZPuZ83LpaqoUCcsr1QsXpEkAAAAAwEt8tuB6t27dpFGjRq77//73v+X06dNSrlw51zY9c//cc8+Z+fmHDh3K8nW0WF/ms/T6Os6z+cWKFZOQkJBs99HXULpPTExMlvtkRacJ6AW3nv7fPKaoBOcw8AMAAAAA8IMMgIIFC0qlSpVclyeffFJ27dolO3bscF10FQCtB/D1119f93WaNGkiy5Yty7Bt6dKl0rRpU3M7b968Ztm/zPvofec+ujygBgHS76O1AzQrwbkPvFsAkPR/AAAAAAjQDIDMihYtai7p6SoAOjCvWrXqdZ83ePBgadmypakf0L17d/n8889l+fLlsm7dOtc+w4YNM8UFGzRoYAIGs2bNMksADhw40Dyuaf66TODEiROlcuXK5qK38+XLJ3369PHip7a3i1euyrZfz5nbBAAAAAAAwCYBAHf169fPTAdYtWqVua9n6OfOnSujRo2S0aNHS2xsrMybNy/D9IJevXpJXFycjB8/Xk6cOGGW+Vu8eLGUL1/etc/w4cMlKSlJBg0aJOfOnTPP10wCzVSAd2w6dVaupDmkTP4IKV8wH80MAAAAAF4U5NBKd36kdevW5jJ27FixGl0GUFcD0IKAkZGRYmWn+nf19SHIuC17Zeaen6V35bLyRrM62e4b/f7CXDsuAAAAAAhEfpUBkJiYKAcOHJBFixb5+lDgAcz/BwAAAIDc41cBAE3HZ6m9wHD2cop8dzbB3G5eMmPtBwAAAABAAK0CAHtbf/KM6NyTqoUKSol84b4+HAAAAAAIeAQA4BNrT8SZ6xYxnP0HAAAA7Oqzzz6TOnXqSEREhLnW+96gdeRCQkLM0vNO58+fNyvCaZF5d54/bdo08Xd+NQUAgYP5/wAAAEDg0Rrzly5dcmtfXcL9wQcfNINwfd7u3bvlvvvukzlz5pgl3t2hS7fr891RuHBhGTlypHz55ZdiV2QAINcdu5gkPydclOAgkSbM/wcAAAAChg7+CxQo4NZFB//KuTCd81q3u/sa7gYb1KBBg2TDhg2yZs2aLB/X5eVr164thQoVkjvvvNPsq5577jlZu3atjBgxwrxn586dxV8RAECuW3v8jLmuW7SQRObNw08AAAAAgNcVKVJEhg8fLi+++OI1jy1evFief/55mT17tpw9e9ZkCnTt2lXi4uLk9ddflxYtWsjkyZPlwoULsmTJEr/9aREAgO/S/0sVo/UBAACAAKIp+TpIdudSs2bNa9L39X6tWrXcfg19v5wYMmSIHD58WP7zn/9k2P7222/LCy+8IPXq1ZPg4GC59957pVq1aiYwEEioAYBcpWk9a38PADQvSQAAAAAACCQ6gM+fP79b+44bN87M+XfWAHBe63Z3XyOnIiIiZMyYMfLnP//ZpPU7aSFA3aaPOV25ckWOHTsmgYQMAOSq/fEX5FRSsoQFB0uDEoVpfQAAAMCm9Cz7/Pnzzbz78PBwc62rAPTo0cOr7/vYY49JWlqafPDBB65tZcuWNan+ujKA83Lx4kXXdAHNCggEZAAgVznP/t8ZXVgiQkNofQAAAMDmQQC95KaQkBCZMGGCDBgwwLXtmWeekcGDB5vifzoNICkpyRQB1GkAZcqUkejoaDlw4ID4u8AIY8BvsPwfAAAAAF+77777pFKlSq77Xbp0kddee02eeOIJs1xgxYoV5c033zSZAs7aAcuXLzcrBOi+/irI4VxrAbcsISFBoqKiJD4+XiIjIy3doqf6d83190xNc0iNuUslPuWKLL67mdQr7v4UgOj3F3r12AAAAAAg0JEBgFyz62y8GfwXzBMqtYtG0fIAAAAAkIsIACDX0/+bliwqoQFSRAMAAAAA/AWjMOQa5v8DAAAAgO8QAECuSE5Nlc2nzprbzWOK0eoAAAAAkMsIACBXbD19TpJS06RERJhULVSAVgcAAACAXEYAALli3Yk4c908pqgEBQXR6gAAAACQywgAIFes/b0AIOn/AAAAAOAbBADgdYkpV2T7mfPmdgvm/wMAAACAT4T65m1hJxtPnZVUh0MqFMwnZQvk8/XhAAAAAPCyru+e8nobL3w82uvvEWjIAIDXsfwfAAAAAF9q3bq1TJs2zfY/BAIA8Drm/wMAAACA7xEAgFf9mpQs359LNLebxRSltQEAAAD4zKpVq6RQoULy7rvvStmyZaVo0aIyfPjwDPssW7ZMGjVqZPaLiYmRSZMmuR77+OOPpXr16uax5s2by/bt2zNkGYwYMULatWsn+fPnl8aNG8uxY8dk7NixUrx4cSlTpowsWLDAtb/D4ZC//e1vUq1aNfN6+vzvv//eq5+fAAC8av3v1f9vLxwpxcLDaG0AAAAAPpWYmCi7d++W/fv3y7p16+Ttt982gQGlA/ru3buboMCvv/4q+/btkzZt2pjH1q5dK0899ZS888475rH7779fOnbsKPHx8a7XnjNnjrz55psSFxdnggAtWrSQqKgoOXHihIwZM0aeeOIJuXLlitl35syZ8t5778nChQvlzJkzcu+990rXrl0lJSXFa5+dAAByJ/2/VDFaGgAAAIDPORwOc1Y/PDzcnM1v2rSpbNu2zTw2a9YseeCBB+S+++6TPHnymMG7nslXH374oTz00EPSsmVL89iQIUOkcOHC8uWXX7peWx+vWbOmeW19jaSkJBk6dKiEhobKgw8+aAIDhw8fNvtq4GH8+PFSuXJl8/izzz5r9t+0aZPXPjsBAHjV2hNx5roF6f8AAAAALCAyMlLy5fvf6mR6pl6zApQOznVAnpVffvlFKlSokGFbxYoVzXankiVLum7re0RH/2+lAud7XrhwwVwfOnTIBAw0/d95OXfuXIbX8zSWAYTXHE68JEcuXJLQoCBpEs38fwAAAADWVr58efnpp5+yfEzn8OugPT29r9tvhtYg0JUJOnXqJLmFDAB4ffm/esULSf48xJoAAAAAWNsTTzwhn376qSnWd/XqVTO//5tvvjGP6dl6neO/fv1689hbb71lUvrvuuuum3qvp59+Wl5++WX54YcfzP2EhAT5/PPPXdkI3sCoDF7D8n8AAACAPS18/H+p7/6kXr16Mn/+fBk9erQ88sgjUqBAARk8eLCpA9CqVSsz6H/sscdMUT+d679kyRKTun8znnnmGQkJCTHF/44ePSoFCxY0Kwu0bdtWvCXIoRUQ4BEasdEiERol0nklVnaqf1evvr52q1rzlsmZyynyWacm0rTkrU0BiH5/oceODQAAAADsiCkA8Ip95xPN4D8iJFjqF7+5iBgAAAAAwHMIAMAr1h7/bf5/o+iiEhYSQisDAAAAgI8RAICX5/9T/R8AAAAArIAAADzualqabDx11txuEVOMFgYAAAAACyAAAI/bcSZeLly5KoXy5pGaRaJoYQAAAACwAAIA8Lh1v6f/N4spKiHBQbQwAAAAAFgAAQB4cf4/6f8AAAAAYBUEAOBRSVdTZevpc+Y28/8BAAAAwDpCfX0ACCxbTp+V5LQ0ickXLrGR+X19OAAAAAB84FT/rl5/j+j3F3r9PQINGQDwWvp/UBDz/wEAAABYx7p166Rz585SuHBhKVSokNSpU0emTJkiKSkpN/2aOu7ZsWOH+AMCAPBKAID0fwAAAABWsmjRIjP479ixo+zfv1/Onz8v8+bNk71798qJEyfEDggAwGPik6/Irrh4c7tFTFFaFgAAAIAlOBwOefbZZ2XEiBEyZMgQKVbst4Ll1apVk9mzZ0v58uVl69at0qxZM5MZUKNGDfn0009dz//222+lcePGEhkZaZ7btetvUxwaNmxorps2bSoFChSQiRMnipVZKgDQr18/kz6R/qKNfCPz5883P6CwsDBzvWDBgmv2mTFjhlSsWFHCw8Olfv36snbt2ms6xNixY6VUqVISEREhrVu3lj179nj08wW6DSfjJM0hUikyv8Tkj/D14QAAAACAoWf8Dx48KL17986yRTQboFOnTvLAAw/Ir7/+KjNnzpQnnnhC1q9fbx5/5plnzKBf9zt27Ji88MILZvvmzZvN9YYNG+TChQvy5z//2dItbqkAgNJG1/QL52Xx4sXZ7r9x40bp1auX9O3bV3bu3Gmue/bsKZs2bXLto2kdGuV56aWXZPv27dKiRQuT+nHkyBHXPjrvY+rUqTJ9+nTZsmWLlCxZUtq3by+JiYle/byB4svDJ2TYhp3mdlxyirkPAAAAAFagg3pVunRpycqXX34pxYsXlz/96U+SJ08eadWqlfTp00c++OAD87huO3z4sBw/ftyceG7ZsqX4I8sFALQxdfDtvBQpUiTb/adNm2YG6iNHjjTpG3rdrl07s91JB/aPPfaYPP7441K9enXzWNmyZU1Ux3n2X7dpgODee++VmjVrmh/0pUuX5JNPPvH6Z/Z3Oth/bOU2OZd8xdw/n3zF3CcIAAAAAMAKnCn/x44dy/LxX375RSpUqJBh22233Wa2q3/+859y+fJlk02u4049ceyPLBcAWLVqlZQoUUKqVKliUi5Onz59wwyADh06ZNimRR00BUNpNcdt27Zds4/ed+6jqSAnT57MsI8GIjTq49wnK8nJyZKQkJDhYkev7/hR0tf7d2glzN+3AwAAAICv6fiyQoUKMnfu3CwfL1OmjBw6dCjDNh0n6nYVGxsrH374oRk3vvvuu/L888+bcabyp9XPQsVCNC3/j3/8oynAoI09evRoadu2rWlYHZBnRX8A0dHRGbbpfd2uzpw5I6mpqdnu47zOah9N87ieSZMmybhx48QfeXLNzJ8jIsygPz29/3PSFdbmBAAAAOBzOkh/6623TA0ALeSn6f1FixaVH3/8USZPniwvv/yyOfmsteOefPJJc6JZs8GXLFlinq+Dfz3RrGNEXUIwODhYQkN/G07rtgMHDkjdunXF6nwWAJgzZ44MGDDAdV8bVufyO2kafoMGDUwwQOdjaGr+9WSOuGhKf+ZtntonPZ1uMGzYMNd9zQDQqQV2jKbt3r3btJeTtlvVqlV9elwAAAAA/P+Eo6d06dLFjDtfffVVc7JZlStXztSRi4mJMY9p7Tgd52lxeJ0y3rx5c7Pf8uXLZfjw4abQnw74//KXv0idOnXMY6+88opZYUCnnOsqAy+++KJYlc8CAN26dZNGjRq57mdVjEF/CBoA0IqN16N1Apxn8J00cuM8m69zPUJCQrLdR19D6T76nlntkxXNSrheZoKdjBkzRu677z4z6HcGTfRatwMAAACAVTRv3ly++uqrLB/TJf2uNwVcMwCuRwf+evEHPqsBULBgQalUqZLrokvvZRYXFydHjx7NMCjPrEmTJrJs2bIM25YuXWrWYVR58+Y1hRoy76P3nfvo8oAaBEi/j9YOWL16tWsfXJ9mZ+hSjLVr1zbLLOr1Z599Jj169KDZAAAAAMAiLFMDQFMpxo4da84k64BfCzDoGop6Bj+7geTgwYPNEgw6b6N79+7y+eefm/SMdevWufbRNH1N69ApBRowmDVrllkCcODAgeZxPWOtqR4TJ06UypUrm4vezpcvn5kbAveCANlN0wAAAAAA+JZlAgCapq/zyDW14vz58yYI0KZNG5k3b57JFnDq16+fCQ7oagFKz9BrJcdRo0aZeRxanVGfk356gdYW0GyC8ePHy4kTJ0x9gcWLF5vpBU46nyMpKUkGDRok586dM8/XTIL07w0AAAAAgL8KcqSv3OYHWrdubS6aLWA1WgQwKipK4uPjTWVJAAAAAACswjIZAO5ITEw0yyssWrTI14cCAAAAAIBf8asAgKbja1FAAAAAAADgJ6sAAAAAAACA3EMAAAAAAAAAGyAAAAAAAACADRAAAAAAAADABggAAAAAAABgA361CoDVORwOc52QkODrQwEAAAAA2EjBggUlKCgo230IAHhQYmKiuS5btqwnXxYAAAAAgGzFx8dLZGRktvsEOZynrXHL0tLS5Pjx425FXgAAAAAA8BR3xqEEAAAAAAAAsAGKAAIAAAAAYAMEAAAAAAAAsAECAAAAAAAA2AABAAAAAAAAbIAAAAAAAAAANkAAAAAAAAAAGyAAAAAAAACADRAAAAAAAADABggAAAAAAABgAwQAAAAAAACwAQIAAAAAAADYAAEAAAAAAABsgAAAAAAAAAA2QAAAAAAAAAAbIAAAAAAAAIANEADwIIfDIQkJCeYaAAAAAAArIQDgQYmJiRIVFWWuAQAAAACwEgIAmcyYMUMqVqwo4eHhUr9+fVm7dq1vfjIAAAAAAHgQAYB05s2bJ0OGDJGXXnpJtm/fLi1atJDOnTvLkSNHPNnmAAAAAADkuiAHE9ZdGjVqJPXq1ZOZM2e6tlWvXl3uuecemTRp0g0bU+f/6xSA+Ph4iYyM9NbPDAAAAACAHAvN+VMCU0pKimzbtk1efPHFDNs7dOggGzZsyPI5ycnJ5pI+AGB3iYlJknIl1deHAQAAAMBG8uYJkYIFI3x9GNbnyKHDhw870tLSrtmu2/Qxf3Xs2DEt3e9Yv359hu0TJkxwVKlSJcvnjBkzxjwn80X3r169umP8+PGOuLg4c9t5UUOGDHHdnzNnjmPTpk2u+126dDH76LVzmz6u+znv6/NV+tfV99H3c96fPn26Y//+/a77TZs2Nc95+OGHXduWLFliLs77+pjSfZ3b9DX0tZz3s/tM1apVc8SWq+D46wt/dvz7jbcdsWXLmUubho0dPyz+r7l2btPHdT/n/Ufuuc/s47yvl03zFjiefaif6/7LT/3JsfTdD13376h+u3nOPe3au7b9Y/wkc3He18d0H93XuU1fQ1/LeV/fQ98r/Xvrc/SYnPf5TPyc6Hv8f+I7gu9yfj/xO5e/I/jbiL9hrft3eevGzcx4xJ/GT9U9OCZ0V46nAISEhMiJEyekRIkSGbbHxcWZbamp/nn29/jx41K6dGlztr9Jkyau7RMmTJCPPvpI9u3b51YGQNmyZW07BSAt6ZLEb90sV4NDRfLk8fXhAAAAALCDK1ckNO2qRDVoKMER+Xx9NIE1BUDjBUFBQddsv3Dhgqmc76+KFStmghsnT57MsP306dMSHR2d5XPCwsLMBf8TnjdIgsPDJYh2AQAAAJALHMnJknb5Im3tyQDAsGHDzLUO/kePHi358v0vsqJn/Tdt2iR169YVf5U3b16z7N+yZcukR48eru16v3v37j49NgAAAAAAci0AoMviOTMAdu/ebQbMTnq7Tp068vzzz4s/0yBH3759pUGDBmYawKxZs8wSgAMHDvT1oQEAAAAAkDsBgJUrV5rr/v37y5tvvhmQc9x79eplahmMHz/e1DmoWbOmLF68WMqXL+/rQwMAAAAA4JbkuAig008//SQHDhyQli1bSkRExHVrA9iJFgGMioqydRHA5F1bJDg8PzUAAAAAAORqDYCw2ndSBPAGgiWHzp49K+3atZMqVarIXXfdZc6Uq8cff1yee+65nL4cAAAAAACwYgBgyJAhkidPHjM3Pn0hQE2f/+qrrzx9fAAAAAAAwBfLAC5dulS+/vprKVOmTIbtlStXlsOHD3vimAAAAAAAgK8zAC5evJjhzL/TmTNnJIy13wEAAAAACIwAgBb9+/DDD133tfBfWlqa/OUvf5E2bdp4+vgAAAAAAIAvpgDoQL9169aydetWSUlJkeHDh8uePXtMccD169d74pgAAAAAAICvMwBq1Kghu3btkoYNG0r79u3NlIB7771Xtm/fLrGxsZ4+PgAAAAAA4IsMAFWyZEkZN26cJ94fAAAAAABYNQBw/vx52bx5s5w+fdrM/0/v4Ycf9tSxAQAAAAAAXwUAFi5cKA8++KBJ/S9YsKApAuiktwkAAAAAAAAQADUAnnvuOXn00UclMTHRZAKcO3fOddFCgAAAAAAAIAACAMeOHZNnn31W8uXL550jAgAAAAAAvg8AdOzY0SwBCAAAAAAAArgGwN133y0vvPCC7N27V2rVqiV58uTJ8Hi3bt08eXwAAAAAAMADghwOhyMnTwgOvn7SgBYBTE1NFbtKSEiQqKgoiY+Pl8jISLGbtKRLkrxriwSH55egsDBfHw4AAAAAG3AkJ0va5YsSVvtOCY5gqrpHMwAyL/sHAAAAAAACsAYAAAAAAADwPwQAAAAAAACwAQIAAAAAAADYAAEAAAAAAABsgAAAAAAAAAA2cFMBgAMHDsioUaOkd+/ecvr0abPtq6++kj179nj6+AAAAAAAgC8CAKtXr5ZatWrJpk2b5LPPPpMLFy6Y7bt27ZIxY8Z44pgAAAAAAICvAwAvvviivPrqq7Js2TLJmzeva3ubNm1k48aNnj4+AAAAAADgiwDA7t27pUePHtdsL168uMTFxXnimAAAAAAAgK8DAIUKFZITJ05cs3379u1SunRpTx0XAAAAAADwZQCgT58+MmLECDl58qQEBQVJWlqarF+/Xp5//nl5+OGHPXlsAAAAAADAVwGACRMmSLly5czZfi0AWKNGDWnZsqU0bdrUrAwAAAAAAACsJ8jhcDhudilATfvXDIA77rhDKleuLHaXkJAgUVFREh8fL5GRkWI3aUmXJHnXFgkOzy9BYWG+PhwAAAAANuBITpa0yxclrPadEhyRz9eHY2mhN7MMYKtWrSQ2NtZcAAAAAABAAE4BaN++vZkCoMsBfvfdd945KgAAAAAA4NsAwPHjx2X48OGydu1aqV27trlMmTJFfvnll1s6kCtXrpjigrVq1ZL8+fNLqVKlTFFBfb8bmT9/vqlFEBYWZq4XLFhwzT4zZsyQihUrSnh4uNSvX98cf3o6E2Ls2LHmfSMiIqR169ayZ8+eW/pMAAAAAAD4bQCgWLFi8swzz5jK/1oHoFevXvLhhx9KhQoVpG3btjd9IJcuXZJvv/1WRo8eba4/++wz+fHHH6Vbt27ZPm/jxo3mGPr27Ss7d+401z179pRNmza59pk3b54MGTJEXnrpJVO3oEWLFtK5c2c5cuSIax8NYkydOlWmT58uW7ZskZIlS5psh8TExJv+TAAAAAAA+H0RQKfU1FRZsmSJGbjv2rXL3PcUHYg3bNhQDh8+bKYdZEUH/1p8T4/BqVOnTlK4cGH59NNPzf1GjRpJvXr1ZObMma59qlevLvfcc49MmjTJnP3XM/8aJNAsBJWcnCzR0dEyefJkGTBgQJbvrfvoxUmPo2zZshQBpAggAAAAgFxCEUAvZgA4aQbAoEGDJCYmRvr06SO33367LFq0SDxJq+kHBQVJoUKFss0A6NChQ4ZtHTt2lA0bNpjbKSkpsm3btmv20fvOfQ4ePCgnT57MsI9OJ9Bih859sqLBA63677zo4B8AAAAAgIAIAPz5z382c+k13V/PzE+bNs0Mnj/++GOTVu8ply9fNoUGNbiQ3ZJ6+t56pj49va/b1ZkzZ0xWQnb7OK+z2ycrI0eONEEK5+Xo0aM38UkBAAAAALDgMoCrVq2S559/3qTeaz2AmzVnzpwMqfWawq9z850FAR944AFJS0szxftuRLME0tOU/szbPLVPepoloBcAAAAAAAIuAJBdSnxOaHE/nZvvVLp0adfgX4v4aVr+ihUrsj37r7RYX+az9KdPn3adzdcgRUhISLb76Gso3UenNGS1DwAAAAAAAR8A+OKLL0x6f548eczt7Nyoar9TwYIFzSU95+B///79snLlSilatOgNX6dJkyaybNkyGTp0qGvb0qVLpWnTpuZ23rx5zbJ/uk+PHj1c++j97t27m9s6pUGDALrtjjvucNUOWL16tSkCCAAAAACALQIAWi1fz46XKFHC3L4eTZe/2VUArl69Kvfff79ZAlCLCerrOM/aFylSxAzkszJ48GBp2bKlGajrgP7zzz+X5cuXy7p161z7DBs2zCwP2KBBAxMwmDVrllkCcODAga7j1hUAJk6cKJUrVzYXvZ0vXz5TgwAAAAAAAFsEAHQufla3PemXX35xZRfUrVs3w2OaDdC6dWtzu1+/fnLo0CFTi0Dpmf65c+fKqFGjzFKEsbGxMm/evAzTC7ReQVxcnIwfP15OnDghNWvWlMWLF0v58uVd+wwfPlySkpLMygbnzp0zz9dMgsxZCgAAAAAA+KMgh1a6y4EPP/zQDKgzF7/TlHkdiD/88MPiTRoI0MvYsWPFahISEsxygLoiwI1qFwSitKRLkrxriwSH55cgiiMCAAAAyAWO5GRJu3xRwmrfKcER+WhzTwYAtKCenkXX6QDp6Rl23XazUwDckZiYKDVq1JDvv/9eChQoIFZDAIAAAAAAAIDcRQDAi6sAXG9pPE3h17Pf3qTp+EePHvXqewAAAAAAYOsAgFbH14G/Xtq1ayehof97qp7112X7OnXq5K3jBAAAAAAAuREAcFb/37Fjh3Ts2DFDCr5W6K9QoYLcd999t3IsAAAAAADA1wGAMWPGmGsd6GsRwPDwcG8dEwAAAAAA8HUNgEceecTTxwAAAAAAAKwWAND5/m+88Yb861//kiNHjpjl/9I7e/asJ48PAAAAAAB4QHBOnzBu3DiZOnWq9OzZ06x3P2zYMLn33nslODhYxo4d64ljAgAAAAAAvs4AmDNnjvzjH/+Qu+++2wQDevfuLbGxsVK7dm355ptv5Nlnn/X0MQIAAAAALMRx5YpIWppYgeNqxqx0eDAAcPLkSalVq5a5rSsBaBaA6tKli4wePTqnLwcAAAAA8LPBf+rZXyXIQoXhg8IiJCgkxNeHEXgBgDJlysiJEyekXLlyUqlSJVm6dKnUq1dPtmzZImFhYd45SgAAAACANaSlmcF/3mq1JSivNcaAOvi3yrEEVACgR48e8t///lcaNWokgwcPNlMA3nvvPVMQcOjQod45SgAAAACApeiAOzgin68PAzkQ5HA4HHILdN7/hg0bTDZAt27dxM4SEhIkKirKTIuIjIwUu0lLuiTJu7ZIcHh+CSIbBAAAAH7KSvPbrUjn3DuuXpGw2ncSAAj0DIDMGjdubC4AAAAA4O+sOL/diphzH8ABgC+++MLtF7R7FgAAAAAAP2bB+e1WxJz7AA4A3HPPPW69WFBQkKSmpt7qMQEAAACATzG/HbYNAKQx/wUAAAAICMxvv0H7sKY8Atgt1wAAAAAA4B+Y3+4e5rcjUOU4ADB+/PhsH3/55Zdv5XgAAAAAeAvz293C/HYEqhwHABYsWJDh/pUrV+TgwYMSGhoqsbGxBAAAAAAAi2N+O2BPOQ4AbN++/ZptCQkJ0q9fP+nRo4enjgsAAAAAAFitBkBkZKSZGtClSxfp27evJ14SAAAAyBGK27nRRhS4A2zNY0UAz58/L/Hx8Z56OQAAAMBtFLdzHwXuAPvKcQDgb3/7W4b7DodDTpw4IR999JF06tTJk8cGAAAAuIfidm6jwB1gXzkOALzxxhsZ7gcHB0vx4sXlkUcekZEjR3ry2AAAAIAcobgdAHgwAKAV/wEAAJB7mNvuRhsxtx0Acq8GAAAAADyPue3uY247AHg4AHD58mV56623ZOXKlXL69GlJS0vL8Pi3336b05cEAADA9TC33W3MbQcADwcAHn30UVm2bJncf//90rBhQwkKCsrpSwAAACCHmNsOAMj1AMCXX34pixcvlmbNmt3ymwMAADC/PXvMbQcAeEpwTp9QunRpKViwoHjbgAEDTHbBtGnTbrjv/PnzpUaNGhIWFmauFyxYcM0+M2bMkIoVK0p4eLjUr19f1q5de81yhmPHjpVSpUpJRESEtG7dWvbs2ePRzwQAALKe3552+SKX67SB4+oV5rYDAHyTAfD666/LiBEj5O9//7uUL19evOE///mPbNq0yQzGb2Tjxo3Sq1cveeWVV6RHjx5m8N+zZ09Zt26dNGrUyOwzb948GTJkiAkCaObCO++8I507d5a9e/dKuXLlzD5TpkyRqVOnyuzZs6VKlSry6quvSvv27eWHH37IlYAHAAC2xPx2tzC3HQDgCUEOPfWdA7/++qsZYK9Zs0by5csnefLkyfD42bNnb+mAjh07ZgbuX3/9tdx9991m4K6X69HBf0JCgixZssS1rVOnTlK4cGH59NNPzX19vXr16snMmTNd+1SvXl3uuecemTRpkjn7r8EGfR8Nbqjk5GSJjo6WyZMnm2wEd+hxREVFSXx8vERGRordpCVdkuRdWyQ4PL8EhYX5+nAAAH7AkZxsznKH1b5TgiPy+fpwAAAIaDnOAOjdu7cZpE+cONEMkD1ZBFBXFOjbt6+88MILcvvtt7v1HM0AGDp0aIZtHTt2dE0dSElJkW3btsmLL76YYZ8OHTrIhg0bzO2DBw/KyZMnzTYnnU7QqlUrs8/1AgAaJNBL+gAAAABOzG2/Mea3AwBg4QCADoh10F2nTh2PH4yebQ8NDZVnn33W7efowF0DEenpfd2uzpw5I6mpqdnu47zOap/Dhw9f9701e2DcuHFuHysAwD5Yu919rN0OAIBFAwDVqlWTpKSkW37jOXPmZDizrqsLvPnmm/Ltt9/mOKsg8/6a0p95m6f2SW/kyJEybNiwDBkAZcuWzdGxAwACFHPb3cb8dgAALBoAeO211+S5556TCRMmSK1ata6pAeDu3Pdu3bq5ivSpf//733L69GlXUT6lZ+71vTSd/9ChQ1m+TsmSJV1n8J30dZxn84sVKyYhISHZ7qOvoXSfmJiYLPfJik4T0AsAANfD2u0AAMBvAwBaYE+1a9cuy7PlOmh3h1bWT19d/8knn5SuXbteM5dfawL079//uq/TpEkTWbZsWYY6AEuXLpWmTZua23nz5jXL/uk+ukqAk97v3r27ua3LA2oQQLfdcccdrtoBq1evNtMSAADXYn579pjbDgAA/D4AsHLlSq8cSNGiRc0lPc0u0IF51apVr/u8wYMHS8uWLc1AXQf0n3/+uSxfvtwsA+ikafoaSGjQoIEJGMyaNUuOHDkiAwcONI9r4EJXANDChpUrVzYXva2rHPTp08crnxcA/Bnz293D3HYAAODXAQCtjO9L/fr1M9MBVq1aZe7rmf65c+fKqFGjZPTo0RIbGyvz5s3LML1AlwqMi4uT8ePHy4kTJ6RmzZqyePFiKV++vGuf4cOHm9oGgwYNknPnzpnnayZB+iwFAMDvmN/uFua2AwAAKwlyaO5+DqxZsybbx/VsvDe1bt3aXMaOHStWo0UAo6KiJD4+3u1aCIEkLemSJO/aIsHh+SWI2ghAQGPtdgAAABtkAOjgO7P0lfLdrQFwMxITE+XAgQOyaNEir70HACjmt2eP+e0AAAA2CABoenx6V65cke3bt5v0e10ZwJs0Hf/o0aNefQ8AYH67e5jfDgAAEOABAE1xz6x9+/ZmOTytxL9t2zZPHRsA+Abz293C/HYAAIAADwBcT/HixeWHH37w1MsBgM+xfjsAAABsHQDYtWtXhvtaQ1Ar67/22mtSp04dTx4bAAAAAADwVQCgbt26puhf5sUDGjduLP/85z89dVwAcgGF7q7TLldT6H8AAAAIODkOABw8eDDD/eDgYJP+Hx4e7snjAuBlFLrLHgXuAAAAIHYPAJQvX947RwIgd1HoLlsUuAMAAIBtAwArVqyQZ555Rr755huJjIzM8Fh8fLw0bdpU/v73v0uLFi28cZwAvIRCdwAAAIA9uB0AmDZtmjzxxBPXDP6dSwMOGDBApk6dSgAAlsM89+u0C/PcAQAAAFtxOwCwc+dOmTx58nUf79Chg/z1r3/11HEBHsE89+wxzx0AAACwD7cDAKdOnZI8efJc/4VCQ+XXX3/11HEBnsE892wxzx0AAACwD7cDAKVLl5bdu3dLpUqVsnx8165dEhMT48ljAzyGee4AAAAA7M7tAMBdd90lL7/8snTu3PmaJf+SkpJkzJgx0qVLF28cI/yMleaWW+lYAAAAAMCXghwOh8PdKQD16tWTkJAQsxpA1apVJSgoSL7//nt5++23JTU1Vb799luJjo4Wu0pISDAFEXVVhKyKJQY6R0qyJO/dKY7kJLHaPPewGnVMFgAAAAAA2JXbAQB1+PBheeqpp+Trr78W59M0CNCxY0eZMWOGVKhQQezM7gEAZxDAkZoqVsI8dwAAAADIYQDA6dy5c/LTTz+ZIEDlypWlcOHCtCUBAAAAAABAoAUAkDUyAAAAAAAAfl8EEDfmjKVoIAAAAAAAgNxSsGBBM0U/OwQAPCgxMdFcly1b1pMvCwAAAABAttypRccUAA9KS0uT48ePuxV5CUSa+aDBj6NHj9q2CCJyjn4DT6AfwVPoS/A0+hToR8gtZADksuDgYClTpozYnQ7+CQCAfgO+f+DP+F0G+hSsiO8m3KrgW34FAAAAAABgeQQAAAAAAACwAQIA8JiwsDAZM2aMuQboN8hNfP+AvgSr4vsJ9CNYCUUAAQAAAACwATIAAAAAAACwAQIAAAAAAADYAAEAAAAAAABsgAAAAAAAAAA2QAAAAAAAAAAbIAAAALC8y5cv+/oQECC2bt1KfwIA2BYBANzQ2bNn5cyZM+Z2WloaLQa3/PLLL/LJJ5/Ixo0b5fz587QabsrBgwelTp06MnHiRFoQt+Tnn3+W7t27S8OGDeVf//oXrYlbdvToUVm4cKHs3r1bUlNTzTaHw0HLIkf4Oxu5jQAAsvXSSy9JtWrVZNasWb91mGC6DLKnf/wMHjxYatSoYfpN+/btZdiwYXLixAmaDjnqRwMHDpQqVaqYy7PPPkvr4ab70qBBg6Ry5coSFBQkUVFRUqBAAVoTt+T55583fx+9+eab0rx5c/nTn/5kgkzaxwgCwF38nQ1fYDSHLOkZ28cee0yWL18u5cqVk2+++Ua2bNliHuMXG67n0KFD0rZtW9m2bZssXbpUvv76a3njjTdM39m7dy8NB7f89NNPUrRoUVm3bp1s3rxZ/v3vf0uxYsVoPeTYf/7zH8mfP7/5TtqwYYO5X716dVmyZAm/z3DT/vnPf5r+pL/jvvrqK3n33Xflu+++k0cffdQ8rkEAIDv8nQ1fIgAAl/QD+4iICClfvryMHDlSXn/9dTl27JgsWLBArly5QnQb1+03V69elXvuuUfee+89ady4sYSFhZn7ISEh5uwb4E4/ypMnj5QqVcqcVbvjjjvMH9rPPfecmQagf2wnJibSkHCrL/3666/y8ccfy6ZNm6RRo0aSlJQksbGxJuX20qVLDNSQoz7lvJ4/f77pR/odFRoaKn/84x+lbt26smbNGhMMyNwPgcz4Oxu+FOTgGwoi5o8iTe/XAZvSbpGQkGBSJZ2pbpoF8Oc//1nuuusu8zgRbmTuN1qoTS+FChUy90+dOiUPPfSQHD9+XJo0aSJdu3Y1c3CB7PqR1hrRM7X333+/dOjQQfbt2ycNGjQwmQFaj6Rdu3bywQcf0Ihwqy85p67pHG0NRg4dOlRWrFghO3fuzPA44E6f0jO3Dz74oBnwv/LKK67+M3z4cPniiy8kLi7OTHnTwADg5Py72fk9pOLj4/k7Gz7Bbz2Ys/waxe7SpYv87W9/MwN//ZKKjIx0Ff3T+bf65aV/lOsf4MxxQ1b9Jjw83DX4379/v1SoUMH8EaR/GJ07d85cU8wNN+pH+gd1mzZtpG/fvnLhwgXzR/WcOXNkx44dMmbMGHM2d+bMmTQksu1Lmimifcn5e8wZtP7DH/5gpisdOXKEwT9y1Kd08K+/43QaiU5ze/XVV82AX3+3zZ4923w/aQaTMwsAUFOnTnX97eMc/Cs9ycbf2fAFAgA2lpKSYtLW9I9r/eWlKbfvvPOO9OnTx/XHkvOPJ60D0LNnT/n2229l0aJFrsdJILGfG/UbJ03516wRnWv7yCOPmKrbmgGg/UfPqMDertePevfubR4vXLiw+eN72rRpUqtWLfNHtbrvvvtM39JggLPqNuztRn3JeYY2fSaA1pjQCu5ATvrUAw88YB4fO3asNGvWTD766CNTpFR/z+nvNu1zOjWA33FQWv9Ig9maRfvZZ5+ZVZEyr6il30v6tzR/ZyNX6RQA2NPevXsdlStXdixdutS1bd26dY6IiAjHlClTHGlpaWZbamqqub58+bLjrrvucvTs2dOxa9cux8cff+x49dVXfXb8sHa/yUr37t0dd999tyMlJSXb/RD4btSPsuLsM5UqVXI89dRTuXasCKzfZXFxcY68efM6Fi1alGE74E6fmjRpkmvb0aNHzd9DTvp3UvHixR1vv/02jQnHK6+84rj//vsd77//vqNDhw6Oxx9/3NUq6f8Gct7m72zkFjIAbEwj1Dqntn79+ua+RiA1ov3yyy/LpEmTzGPKmQWg89+efPJJ2bp1q4loarVbTfmGvbjbbzLTbACdBqBzJ/VsLjUk7O1G/UinkGSmfUaLAGp2gNaWAHL6u8yZgqtFSleuXJlhO+DO99OUKVPkxx9/NNvLlCljMpSc2ZCa6XbbbbdJjx49aEwbc/aHhx9+2CyD3K9fP/N38+7du82qNun3cf5u4+9s5CZ+69mY/tGja7V/8sknGbZrtW2d46bpbs50Sd33wIEDJoXp4MGDZjqAVlHWfWEv7vYb/WWmS/+tXr1annrqKVPMrV69eqawG5CTfqTLa+lgbeDAgSYFV4sAakV3ICffSbpKiSpQoIAp0nbx4kWzsg2Q099zs2bNcv19pDUAtD7SgAEDzO+6u+++W0qWLMkUSRtznuDQtH4tgKz07+aYmBjTp/RkSPr6JIq/s5GbCAAEsBvNz9dl/nQura61rX8M6ReW/oGkZ2efeeYZ+fTTT82Xk7Ngif4R5aycPGPGDLO2MgKPp/qN/jLbtWuX/OUvf5Gff/7Z7P/GG2+45nIjsHmyH2ntEa22rWfdNKCkZ3XTF1JCYPNUX9KCpM4K3KNHjzarAfB9ZE+e/PtIr3WpUv09t379etO3dH+y3AKfu3WwdD/NDNE6SNqftGBkVtlH/J2N3EIAIEDp0iLpC2SljzI6z4JoGq1+GekSW5q2ppzL1mhlUn1ciyQ5n/vaa6+Z+5ruhsDkqX5z+PBhc1+X/Js+fbp8/fXXUrt27Vz+NAiUfqSF//7xj3+YAGSdOnVy+dMgUH6XKWfgSFeY0OJtsB9P9SldRUIVL17cTA1YtmwZ30824k4/cnLupxmQmlmiBSOd09w0wO3E39nILQQAAoymMz799NNy1113mYueNXOeRXN+IekvMV2rfe7cuWYev65lO2/ePNd8SPXLL7+YX2oaBc9cQRmBx9P9pmLFiuZ+RESEWQoQ9uCtfqTZRlpZG/bhjd9lsDdP96n0v9sKFizok88E6/Yj3e+DDz5w3dd9dHltXV1Cb48bN85MZ2vQoIGZEqD4Oxu5hRFdANHos0YW9+zZIy+88IKULVvWrJ2tken00Wtdy7Z06dLmF5zSAiWamtSpUycZNGiQmcf2+uuvS69evczjLPUX2Og3oB/BSvhOAn0K/v7dpHUgFi5ceM3g/vbbbzcZSVoLoESJEmZKgGaUALkq19YbgFfFx8eb5UWefvpps8SaSk5OdowZM8bRsWNHx8WLF822GTNmOCpWrOiYM2dOhqWPdAmSiRMnOp544gmz1N/69ev5idkA/Qb0I1gJ30mgTyFQvpsyL3f83//+11GgQAFH3bp1HVu3bvXJ5wBUkP6TuyEHeINGGL/44gsz/0xT1vTHqgVoXnzxRdm4caMpnKU0PSk5OTlDAT/nvrAf+g3oR7ASvpNAn0KgfTc56YoRS5culd69e/vgEwD/81uuCvyOLkGjXzxaxKhVq1YmfUjXG3UO5J3VaRMSEkx6v9IvK01PcqYoOTH4tw/6DehHsBK+k0CfQqB/NzkfK1q0KIN/WAI1APyMLj0THR1tlhDRpfi0mMiECRPMY5nXE3VWF23evLnPjhfWQL8B/QhWwncS6FOw03cTJ9tgKcyE8B86n6hOnTqOv//97+b+sWPHHG+99ZYjf/78joSEhGv2//nnnx3Fixd37Nu3z7XtwIED5vrq1au5eOTwJfoN6EewEr6TQJ+CFfHdBLsgA8APOMs06JIijRo1MilIqlSpUnLHHXeYiv7ff//9Nc/Ttde1QmnVqlVl+/bt5rmNGzc285OcayEjcNFvQD+ClfCdBPoUrIjvJtgNAQAL07Si8+fPu9KG7rnnHpOOpGurOxUoUMDMP6pcufI1X2R79+4185J0mT9dZ7RWrVpm6ZGs5iYhcNBvQD+ClfCdBPoUrIjvJtgVI0ELmj9/vgwZMkTCwsLMWf9HHnlEnn76aTMnyTkHyTn3aMWKFRIbG2uKk6SkpEjevHlNwED30WqlR44cMcVLdu/ebdYuReCi34B+BCvhOwn0KVgR302wOwIAFrN161YZNWqUPP/889KmTRtZv369jBkzRs6cOSOvvvqqFClSxOynafx6Jn/t2rVmORKlg3+nxMREGThwoNSrV086dOjgs8+D3EG/Af0IVsJ3EuhTsCK+mwCKAFpGWlqauZ45c6ajTJkyjvj4eNdj06dPdzRu3NjxyiuvuLalpqaa58TGxjoWLVpktv3www+OBx54wHHkyBEffAL4Av0G9CNYCd9JoE/BivhuAv6HGgAW4Zznf/DgQbPmaPp5+v369ZP69evLkiVLZM+ePWabTgHYsmWL5MuXz5zl1ykDtWvXlri4OClevLjPPgdyF/0G9CNYCd9JoE/BivhuAv6HAICPLFu2TJ599ll58803ZfPmza7tzZo1kw0bNsjJkyfN/dTUVMmfP790797dfHktXbrUte/ixYvlu+++M1X+9fV0uoA+Hh4e7pPPBO+j34B+BCvhOwn0KVgR303A9REAyGUnTpyQrl27ykMPPSRnz56V9957z8zRdwYB9HaFChVk8uTJGSKW7du3N2f9f/rpJ9dr5cmTR4oVKyazZ882mQGaJYDARL8B/QhWwncS6FOwIr6bADekmw4AL7t48aLjkUcecfTq1cvx888/u7bfeeedjn79+pnbV69edXz44YeO4OBgx/r16zM8/8EHH3S0bt3adf/06dP8zGyAfgP6EayE7yTQp2BFfDcB7iEDIBfpfH1d2k/n9FesWNFU8lddunSR77//3twOCQmRnj17mpT/xx9/XFavXq1BGjMlYP/+/SZzwIm5/vZAvwH9CFbCdxLoU7AivpsA9wRpFMDNfeEBV65cMan7SpteU/z79u0rERERMmvWLNe2y5cvS+fOnWXv3r1mmT+d61+uXDn517/+JWXLluVnYTP0G9CPYCV8J4E+BSviuwm4MQIAFtCyZUt59NFHTWaABgDS0tJMJsCpU6dk165dptq/1gXo06ePrw8VFkK/Af0IVsJ3EuhTsCK+m4CMCAD42M8//yxNmzaVL7/80lXELyUlRfLmzevrQ4OF0W9AP4KV8J0E+hSsiO8m4FrUAPAR58yLdevWSYECBVyD/3HjxsngwYPl9OnTvjo0WBj9BvQjWAnfSaBPwYr4bgKuLzSbx+BFzuX9dPm/++67z6xX+uSTT8qlS5fko48+khIlStD+oN+A7x9YGr/LQJ+CFfHdBFwfUwB8SAv91apVSw4cOGBS/vXs/4gRI3x5SPAD9BvQj2AlfCeBPgUr4rsJyBoBAB9r3769VK5cWaZOnSrh4eG+Phz4CfoN6EewEr6TQJ+CFfHdBFyLAICPpaammor/AP0GfP/AX/G7DPQpWBHfTcC1CAAAAAAAAGADrAIAAAAAAIANEAAAAAAAAMAGCAAAAAAAAGADBAAAAAAAALABAgAAAAAAANgAAQAAAAAAAGyAAAAAAAAAADZAAAAAAAAAABsgAAAAAAAAgA0QAAAAAAAAQALf/wO9sCIXAjatlAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ci_monthly.plot_cash_flows(\n", + " \"2022-01-01\",\n", + " \"2022-01-01\",\n", + " \"2022-12-01\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "10191dfd", + "metadata": {}, + "source": [ + "## Combining multiple CostIncome objects\n", + "\n", + "`CostIncome.comb_cost_income()` aggregates a list of `CostIncome` objects into a single one by summing costs and incomes.\n", + "\n", + "```{warning}\n", + "All objects must share the same `mkt_price_year`, `cost_growth_rate`, and `income_growth_rate`.\n", + "```\n", + "\n", + "```{note}\n", + "`custom_cash_flows` are **not** carried over to the combined object. Merge custom flows manually beforehand if needed.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "666fd82b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CostIncome(\n", + " mkt_price_year = 2020\n", + " freq = 'Y'\n", + " init_cost = -50,000.00\n", + " periodic_cost = -5,000.00\n", + " periodic_income = 8,000.00\n", + " cost_yearly_growth_rate = 0.00%\n", + " income_yearly_growth_rate = 0.00%\n", + " custom_cash_flows = None\n", + ")\n" + ] + } + ], + "source": [ + "ci_a = CostIncome(\n", + " mkt_price_year=2020,\n", + " init_cost=30_000,\n", + " periodic_cost=2_000,\n", + " periodic_income=4_000,\n", + " freq=\"Y\",\n", + ")\n", + "\n", + "ci_b = CostIncome(\n", + " mkt_price_year=2020,\n", + " init_cost=20_000,\n", + " periodic_cost=3_000,\n", + " periodic_income=4_000,\n", + " freq=\"Y\",\n", + ")\n", + "\n", + "ci_combined = CostIncome.comb_cost_income([ci_a, ci_b])\n", + "\n", + "print(ci_combined)" + ] + }, + { + "cell_type": "markdown", + "id": "8a15baaf", + "metadata": {}, + "source": [ + "## Loading from dict / YAML\n", + "\n", + "### From a Python dictionary" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "eeac2088", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CostIncome(\n", + " mkt_price_year = 2020\n", + " freq = 'Y'\n", + " init_cost = -50,000.00\n", + " periodic_cost = -5,000.00\n", + " periodic_income = 8,000.00\n", + " cost_yearly_growth_rate = 2.00%\n", + " income_yearly_growth_rate = 3.00%\n", + " custom_cash_flows = None\n", + ")\n" + ] + } + ], + "source": [ + "config_dict = {\n", + " \"mkt_price_year\": 2020,\n", + " \"init_cost\": 50_000,\n", + " \"periodic_cost\": 5_000,\n", + " \"periodic_income\": 8_000,\n", + " \"cost_yearly_growth_rate\": 0.02,\n", + " \"income_yearly_growth_rate\": 0.03,\n", + " \"freq\": \"Y\",\n", + "}\n", + "\n", + "ci_from_dict = CostIncome.from_dict(config_dict)\n", + "print(ci_from_dict)" + ] + }, + { + "cell_type": "markdown", + "id": "0b2a868e", + "metadata": {}, + "source": [ + "### From a YAML file\n", + "\n", + "Create a YAML file structured as follows, then load it with `CostIncome.from_yaml`.\n", + "\n", + "```yaml\n", + "# measure_cost.yaml\n", + "cost_income:\n", + " mkt_price_year: 2020\n", + " init_cost: 50000\n", + " periodic_cost: 5000\n", + " periodic_income: 8000\n", + " cost_yearly_growth_rate: 0.02\n", + " income_yearly_growth_rate: 0.03\n", + " freq: \"Y\"\n", + " # Optional custom flows:\n", + " # custom_cash_flows:\n", + " # - date: \"2024-01-01\"\n", + " # cost: 10000\n", + " # income: 0\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" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From bc5fefc4693bce40b54e13f8b1632f766ad60abf Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 14:52:18 +0200 Subject: [PATCH 25/37] Adds future documentation placeholders, warnings in legacy guides --- doc/user-guide/adaptation.rst | 14 ++++++++++++++ doc/user-guide/climada_engine_CostBenefit.ipynb | 15 ++++++++++++--- doc/user-guide/climada_entity_MeasureSet.ipynb | 6 +++++- doc/user-guide/index.rst | 1 + 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 doc/user-guide/adaptation.rst diff --git a/doc/user-guide/adaptation.rst b/doc/user-guide/adaptation.rst new file mode 100644 index 0000000000..a7424da05b --- /dev/null +++ b/doc/user-guide/adaptation.rst @@ -0,0 +1,14 @@ +========================== +Adapation appraisal guides +========================== + +These guides show everything you need to know in order to evaluate adapation options with CLIMADA. + +.. toctree:: + :maxdepth: 1 + + .. Adaptation measures in CLIMADA + Using measure configurations + .. Defining measure cash flows + .. Cost benefit evaluation + .. Adapation planning evaluation diff --git a/doc/user-guide/climada_engine_CostBenefit.ipynb b/doc/user-guide/climada_engine_CostBenefit.ipynb index de98c79260..a86fee8e3e 100644 --- a/doc/user-guide/climada_engine_CostBenefit.ipynb +++ b/doc/user-guide/climada_engine_CostBenefit.ipynb @@ -7,6 +7,15 @@ "# END-TO-END COST BENEFIT CALCULATION" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{attention}\n", + "Adapation measures and cost-benefit evaluation are being completely revamped. Associated tutorials will be under their own menu \"Adaptation appraisal guides\"\n", + "```" + ] + }, { "attachments": {}, "cell_type": "markdown", @@ -1286,9 +1295,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Python [conda env:climada_env_dev]", "language": "python", - "name": "python3" + "name": "conda-env-climada_env_dev-py" }, "language_info": { "codemirror_mode": { @@ -1300,7 +1309,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.10" + "version": "3.11.15" }, "latex_envs": { "LaTeX_envs_menu_present": true, diff --git a/doc/user-guide/climada_entity_MeasureSet.ipynb b/doc/user-guide/climada_entity_MeasureSet.ipynb index 0af0b37d70..17a07036cb 100644 --- a/doc/user-guide/climada_entity_MeasureSet.ipynb +++ b/doc/user-guide/climada_entity_MeasureSet.ipynb @@ -6,7 +6,11 @@ "source": [ "# Adaptation Measures\n", "\n", - "Adaptation measures are defined by parameters that alter the exposures, hazard or impact functions. Risk transfer options are also considered. Single measures are defined in the `Measure` class, which can be aggregated to a `MeasureSet`." + "Adaptation measures are defined by parameters that alter the exposures, hazard or impact functions. Risk transfer options are also considered. Single measures are defined in the `Measure` class, which can be aggregated to a `MeasureSet`.\n", + "\n", + "```{attention}\n", + "Adapation measures and cost-benefit evaluation are being completely revamped. Associated tutorials will be under their own menu \"Adaptation appraisal guides\".\n", + "```" ] }, { diff --git a/doc/user-guide/index.rst b/doc/user-guide/index.rst index a5f2f709f5..014fc43f50 100644 --- a/doc/user-guide/index.rst +++ b/doc/user-guide/index.rst @@ -19,6 +19,7 @@ You can then go on to more specific tutorial about `Hazard `_, Hazard Exposures Impact + Adaptation appraisal Local exceedance intensities Uncertainty Quantification climada_engine_Forecast From 3725078bdd616827cec774718ee6f3bcfae1f0cc Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 14:55:20 +0200 Subject: [PATCH 26/37] This one does not exist yet --- doc/user-guide/adaptation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user-guide/adaptation.rst b/doc/user-guide/adaptation.rst index a7424da05b..3390e79817 100644 --- a/doc/user-guide/adaptation.rst +++ b/doc/user-guide/adaptation.rst @@ -8,7 +8,7 @@ These guides show everything you need to know in order to evaluate adapation opt :maxdepth: 1 .. Adaptation measures in CLIMADA - Using measure configurations + .. Using measure configurations .. Defining measure cash flows .. Cost benefit evaluation .. Adapation planning evaluation From cc7047652dea39743df6c856b8be4942d3cc5ac6 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 14:56:10 +0200 Subject: [PATCH 27/37] Includes guide in documentation index --- doc/user-guide/adaptation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user-guide/adaptation.rst b/doc/user-guide/adaptation.rst index 3390e79817..a7424da05b 100644 --- a/doc/user-guide/adaptation.rst +++ b/doc/user-guide/adaptation.rst @@ -8,7 +8,7 @@ These guides show everything you need to know in order to evaluate adapation opt :maxdepth: 1 .. Adaptation measures in CLIMADA - .. Using measure configurations + Using measure configurations .. Defining measure cash flows .. Cost benefit evaluation .. Adapation planning evaluation From 53ccaf7477808db1d618c325e5d6b78c6cd86953 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 9 Apr 2026 15:05:47 +0200 Subject: [PATCH 28/37] Includes notebook in documentation index --- doc/user-guide/adaptation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user-guide/adaptation.rst b/doc/user-guide/adaptation.rst index a7424da05b..647eddc7ac 100644 --- a/doc/user-guide/adaptation.rst +++ b/doc/user-guide/adaptation.rst @@ -9,6 +9,6 @@ These guides show everything you need to know in order to evaluate adapation opt .. Adaptation measures in CLIMADA Using measure configurations - .. Defining measure cash flows + Defining measure cash flows .. Cost benefit evaluation .. Adapation planning evaluation From a9fd1c699f0e03575c3e6b5af4bc3c2eafcdd68a Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 10 Apr 2026 09:25:41 +0200 Subject: [PATCH 29/37] Updates changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58775e3ef1..36ccd483c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ Code freeze date: YYYY-MM-DD ### Added -- Better type hints and overloads signatures for ImpactFuncSet [#1250](https://github.com/CLIMADA-project/climada_python/pull/1250) +- Better type hints and overloads signatures for `ImpactFuncSet` [#1250](https://github.com/CLIMADA-project/climada_python/pull/1250) +- Adds `CostIncome` class and related tutorial for future new `Measure` class. [#1277](https://github.com/CLIMADA-project/climada_python/pull/1277) ### Changed - Updated Impact Calculation Tutorial (`doc.climada_engine_Impact.ipynb`) [#1095](https://github.com/CLIMADA-project/climada_python/pull/1095). From 44a36d4ab33280197a60aa9897aece9760293341 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 10 Apr 2026 09:32:33 +0200 Subject: [PATCH 30/37] Updates changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58775e3ef1..bbeec48501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Code freeze date: YYYY-MM-DD ### Added - Better type hints and overloads signatures for ImpactFuncSet [#1250](https://github.com/CLIMADA-project/climada_python/pull/1250) +- Adds `MeasureConfig` and related dataclasses for new `Measure` object retrocompatibility and (de)serialization capabilities [#1276](https://github.com/CLIMADA-project/climada_python/pull/1276) ### Changed - Updated Impact Calculation Tutorial (`doc.climada_engine_Impact.ipynb`) [#1095](https://github.com/CLIMADA-project/climada_python/pull/1095). From e4d7fc434e4cd9d4a8542b15ee58f05efa12351b Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 10 Apr 2026 11:07:02 +0200 Subject: [PATCH 31/37] Updates rst files --- .../climada.entity._legacy_measures.rst | 22 +++++++++++++++++++ doc/api/climada/climada.entity.measures.rst | 18 --------------- doc/api/climada/climada.entity.rst | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 doc/api/climada/climada.entity._legacy_measures.rst delete mode 100644 doc/api/climada/climada.entity.measures.rst diff --git a/doc/api/climada/climada.entity._legacy_measures.rst b/doc/api/climada/climada.entity._legacy_measures.rst new file mode 100644 index 0000000000..19e622c1ef --- /dev/null +++ b/doc/api/climada/climada.entity._legacy_measures.rst @@ -0,0 +1,22 @@ +climada\.entity\._legacy_measures package +========================================= + +.. note:: + This package implements the legacy way of defining measures + and is retained for compatibility. + +climada\.entity\._legacy_measures\.base module +---------------------------------------------- + +.. automodule:: climada.entity._legacy_measures.base + :members: + :undoc-members: + :show-inheritance: + +climada\.entity\._legacy_measures\.measure\_set module +------------------------------------------------------ + +.. automodule:: climada.entity._legacy_measures.measure_set + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/climada/climada.entity.measures.rst b/doc/api/climada/climada.entity.measures.rst deleted file mode 100644 index 8e63a2082b..0000000000 --- a/doc/api/climada/climada.entity.measures.rst +++ /dev/null @@ -1,18 +0,0 @@ -climada\.entity\.measures package -================================= - -climada\.entity\.measures\.base module --------------------------------------- - -.. automodule:: climada.entity.measures.base - :members: - :undoc-members: - :show-inheritance: - -climada\.entity\.measures\.measure\_set module ----------------------------------------------- - -.. automodule:: climada.entity.measures.measure_set - :members: - :undoc-members: - :show-inheritance: diff --git a/doc/api/climada/climada.entity.rst b/doc/api/climada/climada.entity.rst index f7eac11700..fc1c479209 100644 --- a/doc/api/climada/climada.entity.rst +++ b/doc/api/climada/climada.entity.rst @@ -6,7 +6,7 @@ climada\.entity package climada.entity.disc_rates climada.entity.exposures climada.entity.impact_funcs - climada.entity.measures + climada.entity._legacy_measures climada\.entity\.entity\_def module ----------------------------------- From 8a0910143902062685bb058c5ddbaa8014bdb37d Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 10 Apr 2026 11:24:54 +0200 Subject: [PATCH 32/37] Adds new class to API ref --- doc/api/climada/climada.entity.rst | 1 + doc/api/climada/measures.rst | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 doc/api/climada/measures.rst diff --git a/doc/api/climada/climada.entity.rst b/doc/api/climada/climada.entity.rst index fc1c479209..f4f4df0d98 100644 --- a/doc/api/climada/climada.entity.rst +++ b/doc/api/climada/climada.entity.rst @@ -6,6 +6,7 @@ climada\.entity package climada.entity.disc_rates climada.entity.exposures climada.entity.impact_funcs + climada.entity.measures climada.entity._legacy_measures climada\.entity\.entity\_def module diff --git a/doc/api/climada/measures.rst b/doc/api/climada/measures.rst new file mode 100644 index 0000000000..62f8b2d949 --- /dev/null +++ b/doc/api/climada/measures.rst @@ -0,0 +1,14 @@ +climada\.entity\.measures package +================================= + +.. note:: + This package implements the new way of defining measures. + For the previous way, see :ref:`climada.entity._legacy_measures` + +climada\.entity\.measures\.measure_config module +------------------------------------------------ + +.. automodule:: climada.entity.measures.measure_config + :members: + :undoc-members: + :show-inheritance: From d787844f3d6b86201ab1dd5e0d973789788478ae Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 10 Apr 2026 11:29:45 +0200 Subject: [PATCH 33/37] Adds class to rst index --- doc/api/climada/measures.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/api/climada/measures.rst b/doc/api/climada/measures.rst index 62f8b2d949..d0557c58e3 100644 --- a/doc/api/climada/measures.rst +++ b/doc/api/climada/measures.rst @@ -12,3 +12,11 @@ climada\.entity\.measures\.measure_config module :members: :undoc-members: :show-inheritance: + +climada\.entity\.measures\.cost_income module +--------------------------------------------- + +.. automodule:: climada.entity.measures.cost_income + :members: + :undoc-members: + :show-inheritance: From 775c621abd54ba674f608ab4a08fe6f2196139b4 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 10 Apr 2026 11:31:02 +0200 Subject: [PATCH 34/37] Fixes wrong filename --- ...ectories.impact_calc_strat.rst => climada.entity.measures.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/api/climada/{climada.trajectories.impact_calc_strat.rst => climada.entity.measures.rst} (100%) diff --git a/doc/api/climada/climada.trajectories.impact_calc_strat.rst b/doc/api/climada/climada.entity.measures.rst similarity index 100% rename from doc/api/climada/climada.trajectories.impact_calc_strat.rst rename to doc/api/climada/climada.entity.measures.rst From 3ddf2b4d0a475f86f70d30d6ba7721e00bdf215c Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 10 Apr 2026 11:32:36 +0200 Subject: [PATCH 35/37] Revert "Fixes wrong filename" This reverts commit 775c621abd54ba674f608ab4a08fe6f2196139b4. --- ...ty.measures.rst => climada.trajectories.impact_calc_strat.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/api/climada/{climada.entity.measures.rst => climada.trajectories.impact_calc_strat.rst} (100%) diff --git a/doc/api/climada/climada.entity.measures.rst b/doc/api/climada/climada.trajectories.impact_calc_strat.rst similarity index 100% rename from doc/api/climada/climada.entity.measures.rst rename to doc/api/climada/climada.trajectories.impact_calc_strat.rst From 5b11b683723b675df00cb2299724b69b436accf4 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 10 Apr 2026 11:33:16 +0200 Subject: [PATCH 36/37] Correct rename --- doc/api/climada/{measures.rst => climada.entity.measures.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/api/climada/{measures.rst => climada.entity.measures.rst} (100%) diff --git a/doc/api/climada/measures.rst b/doc/api/climada/climada.entity.measures.rst similarity index 100% rename from doc/api/climada/measures.rst rename to doc/api/climada/climada.entity.measures.rst From ef39bde4cbfc769dac0e62b44b1bb81bed717c29 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 10 Apr 2026 11:35:30 +0200 Subject: [PATCH 37/37] Adds class to rst index --- doc/api/climada/climada.entity.measures.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/api/climada/climada.entity.measures.rst b/doc/api/climada/climada.entity.measures.rst index 62f8b2d949..82207c7a9a 100644 --- a/doc/api/climada/climada.entity.measures.rst +++ b/doc/api/climada/climada.entity.measures.rst @@ -12,3 +12,11 @@ climada\.entity\.measures\.measure_config module :members: :undoc-members: :show-inheritance: + +climada\.entity\.measures\.cost_income module +------------------------------------------------ + +.. automodule:: climada.entity.measures.cost_income + :members: + :undoc-members: + :show-inheritance: