From e44ce58740396ddb9d9b7412427c64a5f452d864 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 6 Nov 2026 16:21:40 +0100 Subject: [PATCH 1/6] Remaining linter issues, module level imports --- climada/entity/measures/__init__.py | 4 +++- climada/entity/measures/cost_income.py | 16 ++++++++++------ climada/entity/measures/helper.py | 19 +++++++++++++++---- climada/entity/measures/measure.py | 12 +++--------- climada/entity/measures/measure_config.py | 17 ++++++++--------- climada/entity/measures/types.py | 21 +++++++++++++++++++++ 6 files changed, 60 insertions(+), 29 deletions(-) diff --git a/climada/entity/measures/__init__.py b/climada/entity/measures/__init__.py index f536fabfa..172e7f6a8 100644 --- a/climada/entity/measures/__init__.py +++ b/climada/entity/measures/__init__.py @@ -22,6 +22,8 @@ """ +from .measure import Measure from .measure_config import MeasureConfig +from .measure_set import MeasureSet -__all__ = ["MeasureConfig"] +__all__ = ["Measure", "MeasureConfig", "MeasureSet"] diff --git a/climada/entity/measures/cost_income.py b/climada/entity/measures/cost_income.py index 2e6d11951..0a33cdd1a 100644 --- a/climada/entity/measures/cost_income.py +++ b/climada/entity/measures/cost_income.py @@ -19,7 +19,7 @@ Define the CostIncome class to handle the cash flow of measures. """ -from typing import Any, Optional, cast +from typing import Any, cast import matplotlib.pyplot as plt import numpy as np @@ -28,6 +28,8 @@ from climada.entity.measures.measure_config import CostIncomeConfig +__all__ = ["CostIncome"] + class CostIncome: """ @@ -67,7 +69,7 @@ def __init__( 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, + custom_cash_flows: pd.DataFrame | None = None, freq: str = "Y", ): """Initialize CostIncome with parameters. @@ -113,8 +115,10 @@ def __repr__(self) -> str: 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%}", - " custom_cash_flows = " - f"{None if self.custom_cash_flows is None else f'DataFrame({len(self.custom_cash_flows)} rows)'}", + ( + " custom_cash_flows = " + f"{None if self.custom_cash_flows is None else f'DataFrame({len(self.custom_cash_flows)} rows)'}" + ), ")", ] return "\n".join(lines) @@ -440,7 +444,7 @@ def plot_cash_flows( start_date: Any, end_date: Any, figsize: tuple[int, int] = (12, 7), - title: Optional[str] = None, + title: str | None = None, ): """Plot periodic and cumulative cash flows over a given period. @@ -580,7 +584,7 @@ def comb_cost_income(cost_incomes: list["CostIncome"]) -> "CostIncome": if str(err) == "All objects passed were None": custom_cash_flows = None else: - raise err + raise return CostIncome( mkt_price_year=first_ci.mkt_price_year, diff --git a/climada/entity/measures/helper.py b/climada/entity/measures/helper.py index 2d60220d1..f3dca40b1 100644 --- a/climada/entity/measures/helper.py +++ b/climada/entity/measures/helper.py @@ -22,9 +22,10 @@ from __future__ import annotations import logging +from collections.abc import Callable from functools import reduce from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast import numpy as np import pandas as pd @@ -46,12 +47,22 @@ HazardChange, ImpfsetChange, ) - from climada.hazard.base import Hazard + from climada.hazard.base import Hazard # noqa: TC004 T = TypeVar("T", Exposures, ImpactFuncSet, Hazard) LOGGER = logging.getLogger(__name__) +__all__ = [ + "change_impfset", + "helper_exposure", + "helper_hazard", + "helper_impfset", + "identity_function", + "impact_intensity_rp_cutoff_helper", + "replace_hazard", +] + def identity_function(x: T, **_kwargs: Any) -> T: """Return the input object unchanged. @@ -193,7 +204,7 @@ def hazard_change( base_exposures: Exposures, base_impfset: ImpactFuncSet, base_hazard: Hazard, - exposures_region_id: Optional[list[int]] = None, + exposures_region_id: list[int] | None = None, **_kwargs, ) -> Hazard: exp_imp = base_exposures @@ -304,7 +315,7 @@ def hazard_change(hazard: Hazard, **_kwargs) -> Hazard: hazard_change = composite_fun( impact_intensity_rp_cutoff_helper(hazard_modifier.impact_rp_cutoff), hazard_change, - ) + ) # ty: ignore[invalid-assignment] if hazard_modifier.new_hazard_path is not None: if not isinstance(hazard_modifier.new_hazard_path, (str, Path)): diff --git a/climada/entity/measures/measure.py b/climada/entity/measures/measure.py index 1c095723a..5503d1765 100755 --- a/climada/entity/measures/measure.py +++ b/climada/entity/measures/measure.py @@ -29,7 +29,7 @@ import inspect import logging from functools import wraps -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, TypeVar from climada.entity.measures.helper import ( helper_exposure, @@ -55,8 +55,6 @@ LOGGER = logging.getLogger(__name__) -# TODO: risk transfer? - def allow_kwargs(func): """ @@ -199,10 +197,6 @@ def __init__( self.color_rgb = (0, 0, 0) if color_rgb is None else color_rgb self._config = _config - # DONE always provide exp, impfset and hazard as kwargs by default - # Have a precedence system (if users provide their own it takes over) - # TODO Check that it works - @property def is_serializable(self) -> bool: """Returns True if the ``Measure`` was created from @@ -212,7 +206,7 @@ def is_serializable(self) -> bool: return self._config is not None @classmethod - def from_config(cls, config: MeasureConfig) -> "Measure": + def from_config(cls, config: MeasureConfig) -> Measure: """Create a ``Measure`` from a ``MeasureConfig`` object. The modifiers defined in the configuration are converted into change @@ -245,7 +239,7 @@ def from_config(cls, config: MeasureConfig) -> "Measure": ) @classmethod - def from_dict(cls, d: dict) -> "Measure": + def from_dict(cls, d: dict) -> Measure: """Create a ``Measure`` from a dictionary. The dictionary is first converted to a ``MeasureConfig`` with diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py index 77ec61d32..6927ef822 100644 --- a/climada/entity/measures/measure_config.py +++ b/climada/entity/measures/measure_config.py @@ -24,7 +24,6 @@ import logging import warnings from dataclasses import MISSING, dataclass, field, fields -from datetime import datetime from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -37,10 +36,10 @@ LOGGER = logging.getLogger(__name__) __all__ = [ - "HazardModifierConfig", + "CostIncomeConfig", "ExposuresModifierConfig", + "HazardModifierConfig", "ImpfsetModifierConfig", - "CostIncomeConfig", "MeasureConfig", ] @@ -400,9 +399,9 @@ class CostIncomeConfig(ModifierConfig): Explicit cash flow schedule as a list of records with at minimum a ``"date"`` key (ISO 8601 string) and a value key. If provided, overrides the periodic cost/income logic. - """ # noqa + """ - mkt_price_year: int | None = field(default_factory=lambda: datetime.today().year) + mkt_price_year: int | None = field(default_factory=lambda: pd.Timestamp.now().year) init_cost: float = 0.0 periodic_cost: float = 0.0 periodic_income: float = 0.0 @@ -412,7 +411,7 @@ class CostIncomeConfig(ModifierConfig): custom_cash_flows: list[dict] | None = None @classmethod - def from_cost_income(cls, cost_income: CostIncome) -> "CostIncomeConfig": + def from_cost_income(cls, cost_income: CostIncome) -> CostIncomeConfig: """ Construct a :class:`CostIncomeConfig` from a live :class:`CostIncome` object. @@ -530,7 +529,7 @@ def to_dict(self, omit_default: bool = True) -> dict: } @classmethod - def from_dict(cls, kwargs_dict: dict) -> "MeasureConfig": + def from_dict(cls, kwargs_dict: dict) -> MeasureConfig: """ Instantiate a :class:`MeasureConfig` from a flat dictionary. @@ -604,7 +603,7 @@ def to_yaml(self, path: str) -> None: ) @classmethod - def from_yaml(cls, path: str) -> "MeasureConfig": + def from_yaml(cls, path: str) -> MeasureConfig: """ Load a :class:`MeasureConfig` from a YAML file. @@ -627,7 +626,7 @@ def from_yaml(cls, path: str) -> "MeasureConfig": return cls.from_dict(yaml.safe_load(opened_file)["measures"][0]) @classmethod - def from_row(cls, row: pd.Series) -> "MeasureConfig": + def from_row(cls, row: pd.Series) -> MeasureConfig: """ Construct a :class:`MeasureConfig` from a legacy Excel row. diff --git a/climada/entity/measures/types.py b/climada/entity/measures/types.py index 1ad90986b..d20edeca5 100644 --- a/climada/entity/measures/types.py +++ b/climada/entity/measures/types.py @@ -1,3 +1,24 @@ +""" +This file is part of CLIMADA. + +Copyright (C) 2017 ETH Zurich, CLIMADA contributors listed in AUTHORS. + +CLIMADA is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free +Software Foundation, version 3. + +CLIMADA is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with CLIMADA. If not, see . + +--- + +Define useful types for the measure module. +""" + from collections.abc import Callable from typing import Concatenate From 1cb19e393d8bc2c6aa7f58daea39759c162e79b5 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 6 Nov 2026 16:25:44 +0100 Subject: [PATCH 2/6] Forgot cost income --- climada/entity/measures/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/climada/entity/measures/__init__.py b/climada/entity/measures/__init__.py index 172e7f6a8..0676679e9 100644 --- a/climada/entity/measures/__init__.py +++ b/climada/entity/measures/__init__.py @@ -22,8 +22,9 @@ """ +from .cost_income import CostIncome from .measure import Measure from .measure_config import MeasureConfig from .measure_set import MeasureSet -__all__ = ["Measure", "MeasureConfig", "MeasureSet"] +__all__ = ["CostIncome", "Measure", "MeasureConfig", "MeasureSet"] From e221fb2510c66f416c159ff8bd5877806b51fe01 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 6 Nov 2026 16:32:12 +0100 Subject: [PATCH 3/6] Cleanup tutorials --- doc/user-guide/climada_cost_income.ipynb | 6 +----- doc/user-guide/climada_measure_config.ipynb | 2 +- doc/user-guide/climada_measures.ipynb | 10 +++------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/doc/user-guide/climada_cost_income.ipynb b/doc/user-guide/climada_cost_income.ipynb index fd53c2585..381cfe94e 100644 --- a/doc/user-guide/climada_cost_income.ipynb +++ b/doc/user-guide/climada_cost_income.ipynb @@ -181,7 +181,7 @@ } ], "source": [ - "from climada.entity.measures.cost_income import CostIncome\n", + "from climada.entity.measures import CostIncome\n", "\n", "ci = CostIncome(\n", " mkt_price_year=2025,\n", @@ -237,8 +237,6 @@ "metadata": {}, "outputs": [], "source": [ - "from climada.entity.measures.cost_income import CostIncome\n", - "\n", "ci = CostIncome(\n", " mkt_price_year=2025,\n", " init_cost=10_000, # 10 000 upfront\n", @@ -1144,8 +1142,6 @@ } ], "source": [ - "import pandas as pd\n", - "\n", "custom_flows_a = pd.DataFrame(\n", " {\n", " \"date\": [\"2024\", \"2026\", \"2028\"],\n", diff --git a/doc/user-guide/climada_measure_config.ipynb b/doc/user-guide/climada_measure_config.ipynb index ebbeb6df7..cadca28fe 100644 --- a/doc/user-guide/climada_measure_config.ipynb +++ b/doc/user-guide/climada_measure_config.ipynb @@ -136,7 +136,7 @@ } ], "source": [ - "from climada.entity.measures.measure_config import MeasureConfig\n", + "from climada.entity.measures import MeasureConfig\n", "\n", "measure_dict = {\n", " \"name\": \"Tutorial measure\",\n", diff --git a/doc/user-guide/climada_measures.ipynb b/doc/user-guide/climada_measures.ipynb index d15ad4282..59ade40cc 100644 --- a/doc/user-guide/climada_measures.ipynb +++ b/doc/user-guide/climada_measures.ipynb @@ -35,8 +35,8 @@ "metadata": {}, "outputs": [], "source": [ - "from climada.entity.measures.measure import Measure\n", - "from climada.entity.measures.cost_income import CostIncome\n", + "from climada.entity.measures import Measure\n", + "from climada.entity.measures import CostIncome\n", "\n", "\n", "# Define one or more transformation function\n", @@ -261,7 +261,6 @@ "metadata": {}, "outputs": [], "source": [ - "from climada.entity.measures.measure import Measure\n", "from climada.entity.measures.measure_config import (\n", " CostIncomeConfig,\n", " ExposuresModifierConfig,\n", @@ -326,9 +325,6 @@ "metadata": {}, "outputs": [], "source": [ - "import numpy as np\n", - "\n", - "\n", "def phase_in(year, start_year, full_year):\n", " \"\"\"Linear effectiveness ramp. (Helper function)\n", "\n", @@ -456,7 +452,7 @@ "metadata": {}, "outputs": [], "source": [ - "from climada.entity.measures.measure import Measure\n", + "from climada.entity.measures import Measure\n", "\n", "meas_relocation = Measure(\n", " \"relocation\",\n", From 1654baa446c3a6b988b81fe7160ad4fb19e60544 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 10 Nov 2026 13:46:31 +0100 Subject: [PATCH 4/6] Better phrasing --- climada/entity/measures/measure_config.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/climada/entity/measures/measure_config.py b/climada/entity/measures/measure_config.py index 6927ef822..265b18553 100644 --- a/climada/entity/measures/measure_config.py +++ b/climada/entity/measures/measure_config.py @@ -208,10 +208,14 @@ class ImpfsetModifierConfig(ModifierConfig): Additive offset applied to the PAA curve after multiplication. Default is ``0.0``. impf_int_mult : float, optional - Multiplicative factor applied to the intensity axis. + Inverse of the multiplicative factor applied to the intensity axis. + That is ``0.5`` would double the values of the intensity axis, namely reducing by 50% + the damages for a given intensity. Default is ``1.0``. impf_int_add : float, optional - Additive offset applied to the intensity axis after multiplication. + Negative additive offset applied to the intensity axis after multiplication. + That a positive value shifts the values of the intensity axis to the left, namely + shifting the impact function to the right. Default is ``0.0``. new_impfset_path : str, optional Path to an Excel file containing a replacement impact function set. From 1f391b9d8df73ae50cde26d5a038666a86508f38 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 10 Nov 2026 17:28:53 +0100 Subject: [PATCH 5/6] Type guard against non valid change functions --- climada/entity/measures/measure.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/climada/entity/measures/measure.py b/climada/entity/measures/measure.py index 5503d1765..fd05d1c11 100755 --- a/climada/entity/measures/measure.py +++ b/climada/entity/measures/measure.py @@ -31,6 +31,8 @@ from functools import wraps from typing import TYPE_CHECKING, TypeVar +from climada.entity.exposures.base import Exposures +from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet from climada.entity.measures.helper import ( helper_exposure, helper_hazard, @@ -38,18 +40,17 @@ identity_function, ) from climada.entity.measures.measure_config import MeasureConfig +from climada.hazard.base import Hazard from .cost_income import CostIncome if TYPE_CHECKING: - from climada.entity.exposures.base import Exposures - from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet + from climada.entity.measures.types import ( ExposuresChange, HazardChange, ImpfsetChange, ) - from climada.hazard.base import Hazard T = TypeVar("T", Exposures, ImpactFuncSet, Hazard) @@ -331,9 +332,14 @@ def apply_exposures_changes( else exposures ) try: - return self.exposures_changes(changed_exp, **kwargs) + changed_exp = self.exposures_changes(changed_exp, **kwargs) + if not isinstance(changed_exp, Exposures): + raise TypeError( + "Exposures change function did not return an Exposures object." + ) + return changed_exp except TypeError as exc: - # Check if it's a missing argument error + # Make sure it's a missing argument error if "missing" in str(exc) and "required positional argument" in str(exc): raise TypeError( f"The function to apply to the exposures requires additional arguments\ @@ -389,7 +395,12 @@ def apply_impfset_changes( else impfset ) try: - return self.impfset_changes(changed_impfset, **kwargs) + changed_impfset = self.impfset_changes(changed_impfset, **kwargs) + if not isinstance(changed_impfset, ImpactFuncSet): + raise TypeError( + "Impfset change function did not return an ImpactFuncSet object." + ) + return changed_impfset except TypeError as exc: # Check if it's a missing argument error if "missing" in str(exc) and "required positional argument" in str(exc): @@ -446,7 +457,12 @@ def apply_hazard_changes( else hazard ) try: - return self.hazard_changes(changed_hazard, **kwargs) + changed_hazard = self.hazard_changes(changed_hazard, **kwargs) + if not isinstance(changed_hazard, Hazard): + raise TypeError( + "Hazard change function did not return an Hazard object." + ) + return changed_hazard except TypeError as exc: # Check if it's a missing argument error if "missing" in str(exc) and "required positional argument" in str(exc): From 5c145d0139b2a0c4cc36b464050954b4ae4ff037 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 10 Nov 2026 17:41:19 +0100 Subject: [PATCH 6/6] Integration tests v0.1 --- climada/test/conftest.py | 11 + climada/test/test_measures.py | 548 ++++++++++++++++++---------------- 2 files changed, 309 insertions(+), 250 deletions(-) diff --git a/climada/test/conftest.py b/climada/test/conftest.py index 727215b2f..d9c3da54f 100644 --- a/climada/test/conftest.py +++ b/climada/test/conftest.py @@ -51,6 +51,7 @@ from shapely.geometry import Point from climada.entity import Exposures, ImpactFunc, ImpactFuncSet +from climada.entity.measures import Measure from climada.hazard import Centroids, Hazard # --------------------------------------------------------------------------- @@ -306,3 +307,13 @@ def _make_impfset( @pytest.fixture def impfset(impfset_factory): return impfset_factory() + + +@pytest.fixture +def measure_on_exposures(): + def reduce_exposure_value(exposures, reduction_factor): + """Reduces the value of all assets by a fixed percentage.""" + exposures.gdf["value"] *= 1 - reduction_factor + return exposures + + return Measure(name="Exposure reduction", exposures_changes=reduce_exposure_value) diff --git a/climada/test/test_measures.py b/climada/test/test_measures.py index 78fc2af85..9b709f891 100644 --- a/climada/test/test_measures.py +++ b/climada/test/test_measures.py @@ -1,306 +1,354 @@ """ -Integration tests for the Measure class. - -These tests use real CLIMADA objects (Exposures, ImpactFuncSet, Hazard) built -from the shared conftest fixtures. They verify that Measure transformations -produce analytically expected results rather than mocking internals. - -Expected impact values (from conftest docstring): - AAI = 18 - RP20 = 0 (event 1, freq 0.03) - RP50 = 0 (event 2, freq 0.01) - RP100 = 500 (event 3, freq 0.006 → 1000 * 0.5) - RP250 = 3750 (event 4, freq 0.004 → 15000 * 0.25) -""" +This file is part of CLIMADA. -import numpy as np -import pytest -from scipy.sparse import csr_matrix +Copyright (C) 2017 ETH Zurich, CLIMADA contributors listed in AUTHORS. -from climada.entity.measures.cost_income import CostIncome -from climada.entity.measures.measure import Measure -from climada.test.conftest import HAZARD_TYPE +CLIMADA is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free +Software Foundation, version 3. -# =========================================================================== -# apply() — structural / immutability tests -# =========================================================================== +CLIMADA is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU General Public License for more details. +You should have received a copy of the GNU General Public License along +with CLIMADA. If not, see . -class TestApplyStructural: - """Verify that apply() correctly transforms the risk triplet.""" +--- - def test_identity_measure_returns_equal_objects(self, exposures, impfset, hazard): - """An identity measure should return objects equal to the originals.""" - m = Measure("identity") - new_exp, new_impfset, new_haz = m.apply(exposures, impfset, hazard) +Integration tests for the measure pipeline: +MeasureConfig -> Measure -> modified (Exposures, ImpactFuncSet, Hazard) -> Impact. - assert new_exp.gdf["value"].tolist() == exposures.gdf["value"].tolist() - assert new_haz.intensity.nnz == hazard.intensity.nnz - assert list(new_impfset.get_func(haz_type=HAZARD_TYPE)) == list( - impfset.get_func(haz_type=HAZARD_TYPE) - ) - - def test_enforce_copy_true_does_not_mutate_originals( - self, exposures, impfset, hazard - ): - """With enforce_copy=True, the originals must not be mutated.""" - original_values = exposures.gdf["value"].copy() +Expected values follow the analytical design of ``conftest.py`` +(linear impact function, baseline AAI = 18, at_event = [0, 0, 500, 3750, 0]). +""" - def double_values(exp, **kw): - exp.gdf["value"] *= 2 - return exp +from unittest.mock import patch - m = Measure("doubler", exposures_changes=double_values) - m.apply(exposures, impfset, hazard, enforce_copy=True) +import numpy as np +import pytest - np.testing.assert_array_equal( - exposures.gdf["value"].values, original_values.values +from climada.engine.impact_calc import ImpactCalc +from climada.engine.test.test_impact_calc import exposure +from climada.entity.measures.measure import Measure +from climada.entity.measures.measure_config import ( + CostIncomeConfig, + ExposuresModifierConfig, + HazardModifierConfig, + ImpfsetModifierConfig, + MeasureConfig, +) +from climada.hazard.base import Hazard + +LOGGER = "climada.entity.measures.measure.LOGGER" +BASELINE_AAI = 18.0 + +# Note: at_event() does not care about the frequency +BASELINE_AT_EVENT = [0.0, 0.0, 500.0, 3750.0, 15000.0] +CATEGORIES = [1, 1, 2, 1, 1, 3] + + +@pytest.fixture +def triplet(exposures_factory, impfset, hazard): + """Return (exposures, impfset, hazard) with centroids assigned.""" + exposures = exposures_factory(categories=CATEGORIES) + exposures.assign_centroids(hazard) + return exposures, impfset, hazard + + +@pytest.fixture +def config_factory(hazard): + """Return a function building a MeasureConfig from modifier overrides.""" + + def _make_config( + name="measure", + impfset=None, + hazard_mod=None, + exposures_mod=None, + cost_income=None, + ): + """ + Parameters + ---------- + name : str, optional + Measure name. + impfset, hazard_mod, exposures_mod, cost_income : dict, optional + Keyword arguments of the corresponding sub-configuration. + + Returns + ------- + MeasureConfig + """ + haz_type = hazard.haz_type + return MeasureConfig( + name=name, + haz_type=haz_type, + impfset_modifier=ImpfsetModifierConfig( + haz_type=haz_type, **(impfset or {}) + ), + hazard_modifier=HazardModifierConfig( + haz_type=haz_type, **(hazard_mod or {}) + ), + exposures_modifier=ExposuresModifierConfig(**(exposures_mod or {})), + cost_income=CostIncomeConfig(**(cost_income or {})), ) - def test_enforce_copy_false_mutates_original(self, exposures, impfset, hazard): - """With enforce_copy=False the change function receives the original object.""" - received_ids = [] - - def record_id(exp, **kw): - received_ids.append(id(exp)) - return exp - - m = Measure("recorder", exposures_changes=record_id) - m.apply(exposures, impfset, hazard, enforce_copy=False) - - assert received_ids[0] == id(exposures) - - def test_base_triplet_available_as_kwargs(self, exposures, impfset, hazard): - """Each change function receives base_exposures/impfset/hazard as kwargs.""" - received = {} - - def capture(**kwargs): - def fn(obj, **kw): - received.update(kw) - return obj - - return fn - - m = Measure( - "capture", - exposures_changes=capture(), + return _make_config + + +def _aai(measure, triplet, **kwargs): + """Return the AAI of the triplet after applying `measure`.""" + return measure.calc_impact(*triplet, **kwargs).aai_agg + + +class TestBaseline: + def test_identity_measure_reproduces_analytical_impact(self, triplet): + impact = Measure("baseline").calc_impact(*triplet) + assert impact.aai_agg == pytest.approx(BASELINE_AAI) + np.testing.assert_allclose(impact.at_event, BASELINE_AT_EVENT) + + def test_default_config_is_identity(self, triplet, config_factory): + measure = Measure.from_config(config_factory()) + assert _aai(measure, triplet) == pytest.approx(BASELINE_AAI) + + +class TestConfigModifiers: + @pytest.mark.parametrize( + "modifiers, expected_aai", + [ + ({"hazard_mod": {"haz_int_mult": 0.5}}, 9.0), + ({"hazard_mod": {"haz_freq_mult": 2.0}}, 36.0), + ( + {"hazard_mod": {"haz_freq_add": -0.005}}, + 0.5, + ), # Only event 3 with 0.001 freq + ({"hazard_mod": {"haz_freq_add": -0.01}}, 0.0), # clipped at zero + ({"hazard_mod": {"impact_rp_cutoff": 200}}, 15.0), # event 3 removed + ({"impfset": {"impf_mdd_mult": 0.5}}, 9.0), + ({"impfset": {"impf_paa_mult": 0.5}}, 9.0), + ({"impfset": {"impf_int_mult": 0.5}}, 9.0), # inverse intensity axis + ({"exposures_mod": {"set_to_zero": [1]}}, 14.0), + ( + { + "hazard_mod": {"haz_int_mult": 0.5}, + "impfset": {"impf_mdd_mult": 0.5}, + }, + 4.5, + ), + ], + ids=[ + "haz_int_mult", + "haz_freq_mult", + "haz_freq_add", + "haz_freq_add_clipped", + "rp_cutoff", + "mdd_mult", + "paa_mult", + "impf_int_mult", + "set_to_zero", + "haz_and_mdd", + ], + ) + def test_aai_after_modifier(self, triplet, config_factory, modifiers, expected_aai): + measure = Measure.from_config(config_factory(**modifiers)) + assert _aai(measure, triplet) == pytest.approx(expected_aai) + + def test_rp_cutoff_keeps_event_below_threshold(self, triplet, config_factory): + measure = Measure.from_config( + config_factory(hazard_mod={"impact_rp_cutoff": 200}) ) - m.apply(exposures, impfset, hazard, enforce_copy=False) - - assert "base_exposures" in received - assert "base_impfset" in received - assert "base_hazard" in received - + impact = measure.calc_impact(*triplet) + np.testing.assert_allclose(impact.at_event, [0.0, 0.0, 0.0, 3750.0, 15000.0]) -# =========================================================================== -# Exposures transformations -# =========================================================================== - - -class TestExposuresTransformations: - """Measures that modify Exposures.""" - - def test_scale_values(self, exposures, impfset, hazard): - """Scaling exposure values by 0.5 should halve every entry.""" - - def scale_half(exp, **kw): - exp.gdf["value"] *= 0.5 - return exp + def test_rp_cutoff_keeps_event_below_threshold_edge(self, triplet, config_factory): + measure = Measure.from_config( + config_factory(hazard_mod={"impact_rp_cutoff": 250}) + ) + impact = measure.calc_impact(*triplet) + np.testing.assert_allclose(impact.at_event, [0.0, 0.0, 0.0, 0.0, 15000.0]) - m = Measure("scale_half", exposures_changes=scale_half) - new_exp, _, _ = m.apply(exposures, impfset, hazard) - np.testing.assert_allclose( - new_exp.gdf["value"].values, exposures.gdf["value"].values * 0.5 +class TestImpfIds: + @pytest.mark.parametrize("impf_ids", [None, "all", 1, [1]]) + def test_targeted_function_is_modified(self, triplet, config_factory, impf_ids): + measure = Measure.from_config( + config_factory(impfset={"impf_mdd_mult": 0.5, "impf_ids": impf_ids}) ) + assert _aai(measure, triplet) == pytest.approx(9.0) - def test_zero_all_values(self, exposures, impfset, hazard): - """Zeroing all exposures should produce zero impacts downstream.""" - - def zero_exp(exp, **kw): - exp.gdf["value"] = 0.0 - return exp + def test_other_function_leaves_impact_unchanged(self, triplet, config_factory): + measure = Measure.from_config( + config_factory(impfset={"impf_mdd_mult": 0.5, "impf_ids": [2]}) + ) + assert _aai(measure, triplet) == pytest.approx(BASELINE_AAI) - m = Measure("zero_exp", exposures_changes=zero_exp) - new_exp, _, _ = m.apply(exposures, impfset, hazard) + def test_invalid_type_raises_value_error(self, triplet, config_factory): + measure = Measure.from_config(config_factory(impfset={"impf_ids": 2.5})) + with pytest.raises(ValueError, match="invalid"): + measure.apply_impfset_changes(triplet[1]) - assert new_exp.gdf["value"].sum() == 0.0 - def test_exposures_change_uses_base_hazard_kwarg( - self, exposures, impfset, hazard_factory +class TestReplacementFromDisk: + def test_hazard_replacement( + self, triplet, config_factory, hazard_factory, tmp_path ): - """Change function can inspect base_hazard via the default kwargs.""" - captured_haz = {} + path = tmp_path / "hazard.hdf5" + hazard_factory(intensity_scale=0.5).write_hdf5(str(path)) - def fn(exp, base_hazard=None, **kw): - captured_haz["haz"] = base_hazard - return exp - - haz = hazard_factory() - m = Measure("haz_aware", exposures_changes=fn) - m.apply(exposures, impfset, haz, enforce_copy=False) - - assert captured_haz["haz"] is haz - - -# =========================================================================== -# Hazard transformations -# =========================================================================== - - -class TestHazardTransformations: - """Measures that modify the Hazard.""" + measure = Measure.from_config( + config_factory(hazard_mod={"new_hazard_path": str(path)}) + ) + assert _aai(measure, triplet) == pytest.approx(9.0) - def test_zero_intensity_eliminates_impacts( - self, exposures, impfset, hazard_factory + def test_hazard_replacement_then_modifier( + self, triplet, config_factory, hazard_factory, tmp_path ): - """Setting hazard intensity to zero should yield zero impacts.""" + path = tmp_path / "hazard.hdf5" + hazard_factory(intensity_scale=0.5).write_hdf5(str(path)) - def zero_intensity(haz, **kw): - haz.intensity = csr_matrix(haz.intensity.shape) - return haz + with pytest.warns(UserWarning, match="Both new hazard"): + config = config_factory( + hazard_mod={"new_hazard_path": str(path), "haz_int_mult": 0.5} + ) + assert _aai(Measure.from_config(config), triplet) == pytest.approx(4.5) - haz = hazard_factory() - m = Measure("zero_haz", hazard_changes=zero_intensity) - new_exp, new_impfset, new_haz = m.apply(exposures, impfset, haz) - - assert new_haz.intensity.nnz == 0 - - def test_scale_intensity(self, exposures, impfset, hazard_factory): - """Halving hazard intensity should halve non-zero entries.""" - original_data = hazard_factory().intensity.data.copy() + def test_impfset_replacement( + self, triplet, config_factory, impfset_factory, tmp_path + ): + path = tmp_path / "impfset.xlsx" + impfset_factory(paa_scale=0.5).write_excel(str(path)) - def half_intensity(haz, **kw): - haz.intensity = haz.intensity * 0.5 - return haz + measure = Measure.from_config( + config_factory(impfset={"new_impfset_path": str(path)}) + ) + assert _aai(measure, triplet) == pytest.approx(9.0) - m = Measure("half_haz", hazard_changes=half_intensity) - _, _, new_haz = m.apply(exposures, impfset, hazard_factory()) + def test_impfset_replacement_then_modifier( + self, triplet, config_factory, impfset_factory, tmp_path + ): + path = tmp_path / "impfset.xlsx" + impfset_factory(paa_scale=0.5).write_excel(str(path)) - np.testing.assert_allclose(new_haz.intensity.data, original_data * 0.5) + with pytest.warns(UserWarning, match="Both new impfset"): + config = config_factory( + impfset={"new_impfset_path": str(path), "impf_mdd_mult": 0.5} + ) + assert _aai(Measure.from_config(config), triplet) == pytest.approx(4.5) + def test_exposures_replacement( + self, triplet, config_factory, exposures_factory, tmp_path + ): + path = tmp_path / "exposures.hdf5" + exposures_factory(categories=CATEGORIES, value_factor=0.5).write_hdf5(str(path)) -# =========================================================================== -# ImpactFuncSet transformations -# =========================================================================== + measure = Measure.from_config( + config_factory(exposures_mod={"new_exposures_path": str(path)}) + ) + assert _aai(measure, triplet) == pytest.approx(9.0) + + @pytest.mark.parametrize( + "override", + [ + {"hazard_mod": {"new_hazard_path": "nil"}}, + {"impfset": {"new_impfset_path": "nil"}}, + {"exposures_mod": {"new_exposures_path": "nil"}}, + ], + ids=["hazard", "impfset", "exposures"], + ) + def test_nil_path_means_no_replacement(self, triplet, config_factory, override): + measure = Measure.from_config(config_factory(**override)) + assert _aai(measure, triplet) == pytest.approx(BASELINE_AAI) + + +class TestCopySemantics: + @pytest.fixture + def measure(self, config_factory): + return Measure.from_config( + config_factory( + hazard_mod={"haz_int_mult": 0.5}, + impfset={"impf_mdd_mult": 0.5}, + exposures_mod={"set_to_zero": [1]}, + ) + ) + def test_enforce_copy_preserves_inputs(self, measure, triplet): + exposures, impfset, hazard = triplet + intensity = hazard.intensity.toarray().copy() + values = exposures.gdf["value"].to_numpy().copy() -class TestImpfsetTransformations: - """Measures that modify the ImpactFuncSet.""" + measure.apply(exposures, impfset, hazard, enforce_copy=True) - def test_scale_paa_to_zero_eliminates_impacts( - self, exposures, impfset_factory, hazard - ): - """Setting PAA to 0 should make all MDD-based impacts vanish.""" + np.testing.assert_array_equal(hazard.intensity.toarray(), intensity) + np.testing.assert_array_equal(exposures.gdf["value"].to_numpy(), values) + assert _aai(Measure("baseline"), triplet) == pytest.approx(BASELINE_AAI) - def zero_paa(ifs, **kw): - for func in ifs.get_func(haz_type=HAZARD_TYPE): - func.paa = np.zeros_like(func.paa) - return ifs + def test_no_copy_modifies_inputs_in_place(self, measure, triplet): + exposures, impfset, hazard = triplet + intensity = hazard.intensity.toarray().copy() - impfset = impfset_factory() - m = Measure("zero_paa", impfset_changes=zero_paa) - _, new_impfset, _ = m.apply(exposures, impfset, hazard) + measure.apply(exposures, impfset, hazard, enforce_copy=False) - for func in new_impfset.get_func(haz_type=HAZARD_TYPE): - np.testing.assert_array_equal(func.paa, 0) + np.testing.assert_allclose(hazard.intensity.toarray(), 0.5 * intensity) + assert exposures.gdf["value"].iloc[1] == 0 -# =========================================================================== -# Combined / multi-component transformations -# =========================================================================== +class TestCalcImpact: + def test_matches_manual_apply_and_impact_calc(self, triplet, config_factory): + measure = Measure.from_config( + config_factory( + hazard_mod={"haz_int_mult": 0.5}, + impfset={"impf_paa_mult": 0.5}, + ) + ) + new_exp, new_impfset, new_haz = measure.apply(*triplet) + manual = ImpactCalc(new_exp, new_impfset, new_haz).impact(save_mat=False) + impact = measure.calc_impact(*triplet) -class TestCombinedTransformations: - """Measures that simultaneously affect more than one risk component.""" + assert impact.aai_agg == pytest.approx(manual.aai_agg) + np.testing.assert_allclose(impact.at_event, manual.at_event) - def test_combined_exposure_and_hazard_change( - self, exposures, impfset, hazard_factory + def test_warns_and_assigns_missing_centroids( + self, exposures_factory, impfset, hazard ): - """Both exposure and hazard transforms should be applied independently.""" + exposures = exposures_factory(categories=CATEGORIES) + with patch(LOGGER) as mock_logger: + impact = Measure("baseline").calc_impact(exposures, impfset, hazard) - def double_values(exp, **kw): - exp.gdf["value"] *= 2 - return exp + mock_logger.warning.assert_called_once() + assert hazard.centr_exp_col in exposures.gdf.columns + assert impact.aai_agg == pytest.approx(BASELINE_AAI) - def half_intensity(haz, **kw): - haz.intensity = haz.intensity * 0.5 - return haz + def test_no_warning_when_centroids_assigned(self, triplet): + with patch(LOGGER) as mock_logger: + Measure("baseline").calc_impact(*triplet) - haz = hazard_factory() - original_intensity_data = haz.intensity.data.copy() + mock_logger.warning.assert_not_called() - m = Measure( - "combined", - exposures_changes=double_values, - hazard_changes=half_intensity, - ) - new_exp, _, new_haz = m.apply(exposures, impfset, haz) - np.testing.assert_allclose( - new_exp.gdf["value"].values, exposures.gdf["value"].values * 2 - ) - np.testing.assert_allclose( - new_haz.intensity.data, original_intensity_data * 0.5 +class TestCustomFunction: + def test_custom_measure(self, measure_on_exposures, triplet): + impact = measure_on_exposures.calc_impact( + *triplet, kwargs_exposures={"reduction_factor": 0.1} ) + assert impact.aai_agg == pytest.approx(BASELINE_AAI * 0.9) - def test_kwargs_exposures_and_kwargs_hazard_routed_correctly( - self, exposures, impfset, hazard - ): - """kwargs_exposures and kwargs_hazard should reach the right function only.""" - exp_extra, haz_extra = {}, {} - - def track_exp(exp, my_exp_param=None, **kw): - exp_extra["val"] = my_exp_param - return exp - - def track_haz(haz, my_haz_param=None, **kw): - haz_extra["val"] = my_haz_param - return haz - - m = Measure("routed", exposures_changes=track_exp, hazard_changes=track_haz) - m.apply( - exposures, - impfset, - hazard, - enforce_copy=False, - kwargs_exposures={"my_exp_param": "exp_value"}, - kwargs_hazard={"my_haz_param": "haz_value"}, - ) + def test_custom_measure_missing_kwarg(self, measure_on_exposures, triplet): + with pytest.raises(TypeError, match="kwargs_exposures"): + measure_on_exposures.calc_impact(*triplet) - assert exp_extra["val"] == "exp_value" - assert haz_extra["val"] == "haz_value" + def test_wrong_custom_measure(self, triplet): + def wrong_change(arg1): + return "wrong return type" + meas = Measure(name="", exposures_changes=wrong_change) + with pytest.raises(TypeError, match="did not return an Exposures"): + meas.apply(*triplet) -# =========================================================================== -# CostIncome integration -# =========================================================================== + meas = Measure(name="", hazard_changes=wrong_change) + with pytest.raises(TypeError, match="did not return an Hazard"): + meas.apply(*triplet) - -class TestCostIncomeIntegration: - """Verify that CostIncome financial data survives through Measure construction.""" - - def test_cost_income_attached(self, exposures, impfset, hazard): - ci = CostIncome(mkt_price_year=2020, init_cost=10_000, periodic_income=500) - m = Measure("with_ci", cost_income=ci) - assert m.cost_income is ci - - def test_total_cost_calculable_after_apply(self, exposures, impfset, hazard): - ci = CostIncome( - mkt_price_year=2020, - init_cost=10_000, - periodic_cost=1_000, - periodic_income=2_000, - ) - m = Measure("ci_measure", cost_income=ci) - m.apply(exposures, impfset, hazard) - - total_net, total_cost, total_inc = m.cost_income.calc_total( - impl_date="2020-01-01", - start_date="2020-01-01", - end_date="2030-01-01", - ) - # Over 10 years: init_cost once + 9 periodic_costs + 10 periodic_incomes - assert total_cost < 0 - assert total_inc > 0 - assert isinstance(total_net, float) + meas = Measure(name="", impfset_changes=wrong_change) + with pytest.raises(TypeError, match="did not return an ImpactFuncSet"): + meas.apply(*triplet)