From 5efad8304f2ad84854ace7cb6795111068a747ae Mon Sep 17 00:00:00 2001 From: spjuhel Date: Wed, 17 Dec 2025 17:10:19 +0100 Subject: [PATCH 01/12] cherry pick snapshots from feature/risk_trajectories --- climada/trajectories/snapshot.py | 163 +++++++++++++++++++++ climada/trajectories/test/test_snapshot.py | 132 +++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 climada/trajectories/snapshot.py create mode 100644 climada/trajectories/test/test_snapshot.py diff --git a/climada/trajectories/snapshot.py b/climada/trajectories/snapshot.py new file mode 100644 index 0000000000..d8c78c0c20 --- /dev/null +++ b/climada/trajectories/snapshot.py @@ -0,0 +1,163 @@ +""" +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 . + +--- + +This modules implements the Snapshot class. + +Snapshot are used to store a snapshot of Exposure, Hazard and Vulnerability +at a specific date. + +""" + +import copy +import datetime +import logging + +import pandas as pd + +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__) + +__all__ = ["Snapshot"] + + +class Snapshot: + """ + A snapshot of exposure, hazard, and impact function at a specific date. + + Parameters + ---------- + exposure : Exposures + hazard : Hazard + impfset : ImpactFuncSet + date : int | datetime.date | str + The date of the Snapshot, it can be an integer representing a year, + a datetime object or a string representation of a datetime object + with format "YYYY-MM-DD". + + Attributes + ---------- + date : datetime + Date of the snapshot. + measure: Measure | None + The possible measure applied to the snapshot. + + Notes + ----- + + The object creates deep copies of the exposure hazard and impact function set. + + Also note that exposure, hazard and impfset are read-only properties. + Consider snapshot as immutable objects. + + To create a snapshot with a measure, create a snapshot `snap` without + the measure and call `snap.apply_measure(measure)`, which returns a new Snapshot object + with the measure applied to its risk dimensions. + """ + + def __init__( + self, + *, + exposure: Exposures, + hazard: Hazard, + impfset: ImpactFuncSet, + date: int | datetime.date | str, + ) -> None: + self._exposure = copy.deepcopy(exposure) + self._hazard = copy.deepcopy(hazard) + self._impfset = copy.deepcopy(impfset) + self._measure = None + self._date = self._convert_to_date(date) + + @property + def exposure(self) -> Exposures: + """Exposure data for the snapshot.""" + return self._exposure + + @property + def hazard(self) -> Hazard: + """Hazard data for the snapshot.""" + return self._hazard + + @property + def impfset(self) -> ImpactFuncSet: + """Impact function set data for the snapshot.""" + return self._impfset + + @property + def measure(self) -> Measure | None: + """(Adaptation) Measure data for the snapshot.""" + return self._measure + + @property + def date(self) -> datetime.date: + """Date of the snapshot.""" + return self._date + + @property + def impact_calc_data(self) -> dict: + """Convenience function for ImpactCalc class.""" + return { + "exposures": self.exposure, + "hazard": self.hazard, + "impfset": self.impfset, + } + + @staticmethod + def _convert_to_date(date_arg) -> datetime.date: + """Convert date argument of type int or str to a datetime.date object.""" + if isinstance(date_arg, int): + # Assume the integer represents a year + return datetime.date(date_arg, 1, 1) + elif isinstance(date_arg, str): + # Try to parse the string as a date + try: + return datetime.datetime.strptime(date_arg, "%Y-%m-%d").date() + except ValueError: + raise ValueError("String must be in the format 'YYYY-MM-DD'") + elif isinstance(date_arg, datetime.date): + # Already a date object + return date_arg + else: + raise TypeError("date_arg must be an int, str, or datetime.date") + + def apply_measure(self, measure: Measure) -> "Snapshot": + """Create a new snapshot by applying a Measure object. + + This method creates a new `Snapshot` object by applying a measure on + the current one. + + Parameters + ---------- + measure : Measure + The measure to be applied to the snapshot. + + Returns + ------- + The Snapshot with the measure applied. + + """ + + LOGGER.debug(f"Applying measure {measure.name} on snapshot {id(self)}") + exp, impfset, haz = measure.apply(self.exposure, self.impfset, self.hazard) + snap = Snapshot(exposure=exp, hazard=haz, impfset=impfset, date=self.date) + snap._measure = measure + return snap diff --git a/climada/trajectories/test/test_snapshot.py b/climada/trajectories/test/test_snapshot.py new file mode 100644 index 0000000000..4e3b465d8e --- /dev/null +++ b/climada/trajectories/test/test_snapshot.py @@ -0,0 +1,132 @@ +import datetime +import unittest +from unittest.mock import MagicMock + +import numpy as np +import pandas as pd + +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 + + +class TestSnapshot(unittest.TestCase): + + def setUp(self): + # Create mock objects for testing + self.mock_exposure = Exposures.from_hdf5(EXP_DEMO_H5) + self.mock_hazard = Hazard.from_hdf5(HAZ_DEMO_H5) + self.mock_impfset = ImpactFuncSet( + [ + ImpactFunc( + "TC", + 3, + intensity=np.array([0, 20]), + mdd=np.array([0, 0.5]), + paa=np.array([0, 1]), + ) + ] + ) + self.mock_measure = MagicMock(spec=Measure) + self.mock_measure.name = "Test Measure" + + # Setup mock return values for measure.apply + self.mock_modified_exposure = MagicMock(spec=Exposures) + self.mock_modified_hazard = MagicMock(spec=Hazard) + self.mock_modified_impfset = MagicMock(spec=ImpactFuncSet) + self.mock_measure.apply.return_value = ( + self.mock_modified_exposure, + self.mock_modified_impfset, + self.mock_modified_hazard, + ) + + def test_init_with_int_date(self): + snapshot = Snapshot( + exposure=self.mock_exposure, + hazard=self.mock_hazard, + impfset=self.mock_impfset, + date=2023, + ) + self.assertEqual(snapshot.date, datetime.date(2023, 1, 1)) + + def test_init_with_str_date(self): + snapshot = Snapshot( + exposure=self.mock_exposure, + hazard=self.mock_hazard, + impfset=self.mock_impfset, + date="2023-01-01", + ) + self.assertEqual(snapshot.date, datetime.date(2023, 1, 1)) + + def test_init_with_date_object(self): + date_obj = datetime.date(2023, 1, 1) + snapshot = Snapshot( + exposure=self.mock_exposure, + hazard=self.mock_hazard, + impfset=self.mock_impfset, + date=date_obj, + ) + self.assertEqual(snapshot.date, date_obj) + + def test_init_with_invalid_date(self): + with self.assertRaises(ValueError): + Snapshot( + exposure=self.mock_exposure, + hazard=self.mock_hazard, + impfset=self.mock_impfset, + date="invalid-date", + ) + + def test_init_with_invalid_type(self): + with self.assertRaises(TypeError): + Snapshot( + exposure=self.mock_exposure, + hazard=self.mock_hazard, + impfset=self.mock_impfset, + date=2023.5, # type: ignore + ) + + def test_properties(self): + snapshot = Snapshot( + exposure=self.mock_exposure, + hazard=self.mock_hazard, + impfset=self.mock_impfset, + date=2023, + ) + + # We want a new reference + self.assertIsNot(snapshot.exposure, self.mock_exposure) + self.assertIsNot(snapshot.hazard, self.mock_hazard) + self.assertIsNot(snapshot.impfset, self.mock_impfset) + + # But we want equality + pd.testing.assert_frame_equal(snapshot.exposure.gdf, self.mock_exposure.gdf) + + self.assertEqual(snapshot.hazard.haz_type, self.mock_hazard.haz_type) + self.assertEqual(snapshot.hazard.intensity.nnz, self.mock_hazard.intensity.nnz) + self.assertEqual(snapshot.hazard.size, self.mock_hazard.size) + + self.assertEqual(snapshot.impfset, self.mock_impfset) + + def test_apply_measure(self): + snapshot = Snapshot( + exposure=self.mock_exposure, + hazard=self.mock_hazard, + impfset=self.mock_impfset, + date=2023, + ) + new_snapshot = snapshot.apply_measure(self.mock_measure) + + self.assertIsNotNone(new_snapshot.measure) + self.assertEqual(new_snapshot.measure.name, "Test Measure") # type: ignore + self.assertEqual(new_snapshot.exposure, self.mock_modified_exposure) + self.assertEqual(new_snapshot.hazard, self.mock_modified_hazard) + self.assertEqual(new_snapshot.impfset, self.mock_modified_impfset) + + +if __name__ == "__main__": + TESTS = unittest.TestLoader().loadTestsFromTestCase(TestSnapshot) + unittest.TextTestRunner(verbosity=2).run(TESTS) From da997e7a0496917fc0412b2207acf04b0b31cf12 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Wed, 17 Dec 2025 17:52:11 +0100 Subject: [PATCH 02/12] Cherrypick from risk traj --- climada/trajectories/impact_calc_strat.py | 137 ++++++++++++++++++ .../test/test_impact_calc_strat.py | 84 +++++++++++ 2 files changed, 221 insertions(+) create mode 100644 climada/trajectories/impact_calc_strat.py create mode 100644 climada/trajectories/test/test_impact_calc_strat.py diff --git a/climada/trajectories/impact_calc_strat.py b/climada/trajectories/impact_calc_strat.py new file mode 100644 index 0000000000..a58aceeab2 --- /dev/null +++ b/climada/trajectories/impact_calc_strat.py @@ -0,0 +1,137 @@ +""" +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 . + +--- + +This modules implements the impact computation strategy objects for risk +trajectories. + +""" + +from abc import ABC, abstractmethod + +from climada.engine.impact import Impact +from climada.engine.impact_calc import ImpactCalc +from climada.entity.exposures.base import Exposures +from climada.entity.impact_funcs.impact_func_set import ImpactFuncSet +from climada.hazard.base import Hazard + +__all__ = ["ImpactCalcComputation"] + + +class ImpactComputationStrategy(ABC): + """ + Interface for impact computation strategies. + + This abstract class defines the contract for all concrete strategies + responsible for calculating and optionally modifying with a risk transfer, + the impact computation, based on a set of inputs (exposure, hazard, vulnerability). + + It revolves around a `compute_impacts()` method that takes as arguments + the three dimensions of risk (exposure, hazard, vulnerability) and return an + Impact object. + """ + + @abstractmethod + def compute_impacts( + self, + exp: Exposures, + haz: Hazard, + vul: ImpactFuncSet, + ) -> Impact: + """ + Calculates the total impact, including optional risk transfer application. + + Parameters + ---------- + exp : Exposures + The exposure data. + haz : Hazard + The hazard data (e.g., event intensity). + vul : ImpactFuncSet + The set of vulnerability functions. + + Returns + ------- + Impact + An object containing the computed total impact matrix and metrics. + + See Also + -------- + ImpactCalcComputation : The default implementation of this interface. + """ + ... + + +class ImpactCalcComputation(ImpactComputationStrategy): + r""" + Default impact computation strategy using the core engine of climada. + + This strategy first calculates the raw impact using the standard + :class:`ImpactCalc` logic. + + """ + + def compute_impacts( + self, + exp: Exposures, + haz: Hazard, + vul: ImpactFuncSet, + ) -> Impact: + """ + Calculates the impact and applies the "global" risk transfer mechanism. + + Parameters + ---------- + exp : Exposures + The exposure data. + haz : Hazard + The hazard data. + vul : ImpactFuncSet + The set of vulnerability functions. + + Returns + ------- + Impact + The final impact object. + """ + impact = self.compute_impacts_pre_transfer(exp, haz, vul) + return impact + + def compute_impacts_pre_transfer( + self, + exp: Exposures, + haz: Hazard, + vul: ImpactFuncSet, + ) -> Impact: + """ + Calculates the raw impact matrix before any risk transfer is applied. + + Parameters + ---------- + exp : Exposures + The exposure data. + haz : Hazard + The hazard data. + vul : ImpactFuncSet + The set of vulnerability functions. + + Returns + ------- + Impact + An Impact object containing the raw, pre-transfer impact matrix. + """ + return ImpactCalc(exposures=exp, impfset=vul, hazard=haz).impact() diff --git a/climada/trajectories/test/test_impact_calc_strat.py b/climada/trajectories/test/test_impact_calc_strat.py new file mode 100644 index 0000000000..a828ec51e6 --- /dev/null +++ b/climada/trajectories/test/test_impact_calc_strat.py @@ -0,0 +1,84 @@ +""" +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 impact_calc_strat + +""" + +import unittest +from unittest.mock import MagicMock, patch + +from climada.engine import Impact +from climada.entity import ImpactFuncSet +from climada.entity.exposures import Exposures +from climada.hazard import Hazard +from climada.trajectories import Snapshot +from climada.trajectories.impact_calc_strat import ImpactCalcComputation + + +class TestImpactCalcComputation(unittest.TestCase): + def setUp(self): + self.mock_snapshot0 = MagicMock(spec=Snapshot) + self.mock_snapshot0.exposure = MagicMock(spec=Exposures) + self.mock_snapshot0.hazard = MagicMock(spec=Hazard) + self.mock_snapshot0.impfset = MagicMock(spec=ImpactFuncSet) + self.mock_snapshot1 = MagicMock(spec=Snapshot) + self.mock_snapshot1.exposure = MagicMock(spec=Exposures) + self.mock_snapshot1.hazard = MagicMock(spec=Hazard) + self.mock_snapshot1.impfset = MagicMock(spec=ImpactFuncSet) + + self.impact_calc_computation = ImpactCalcComputation() + + @patch.object(ImpactCalcComputation, "compute_impacts_pre_transfer") + def test_compute_impacts(self, mock_calculate_impacts_for_snapshots): + mock_impacts = MagicMock(spec=Impact) + mock_calculate_impacts_for_snapshots.return_value = mock_impacts + + result = self.impact_calc_computation.compute_impacts( + exp=self.mock_snapshot0.exposure, + haz=self.mock_snapshot0.hazard, + vul=self.mock_snapshot0.impfset, + ) + + self.assertEqual(result, mock_impacts) + mock_calculate_impacts_for_snapshots.assert_called_once_with( + self.mock_snapshot0.exposure, + self.mock_snapshot0.hazard, + self.mock_snapshot0.impfset, + ) + + def test_calculate_impacts_for_snapshots(self): + mock_imp_E0H0 = MagicMock(spec=Impact) + + with patch( + "climada.trajectories.impact_calc_strat.ImpactCalc" + ) as mock_impact_calc: + mock_impact_calc.return_value.impact.side_effect = [mock_imp_E0H0] + + result = self.impact_calc_computation.compute_impacts_pre_transfer( + exp=self.mock_snapshot0.exposure, + haz=self.mock_snapshot0.hazard, + vul=self.mock_snapshot0.impfset, + ) + + self.assertEqual(result, mock_imp_E0H0) + + +if __name__ == "__main__": + TESTS = unittest.TestLoader().loadTestsFromTestCase(TestImpactCalcComputation) + unittest.TextTestRunner(verbosity=2).run(TESTS) From bf0026264b5072212ed008637cc3704b5df20aa9 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 18 Dec 2025 11:07:14 +0100 Subject: [PATCH 03/12] adds __init__ --- climada/trajectories/__init__.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 climada/trajectories/__init__.py diff --git a/climada/trajectories/__init__.py b/climada/trajectories/__init__.py new file mode 100644 index 0000000000..91aca62d1c --- /dev/null +++ b/climada/trajectories/__init__.py @@ -0,0 +1,28 @@ +""" +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 . + +--- + +This module implements risk trajectory objects which enable computation and +possibly interpolation of risk metric over multiple dates. + +""" + +from .snapshot import Snapshot + +__all__ = [ + "Snapshot", +] From e12e01461b005550854f833ca2b29b64f2d57fdf Mon Sep 17 00:00:00 2001 From: spjuhel Date: Fri, 19 Dec 2025 15:23:16 +0100 Subject: [PATCH 04/12] Adds option to have references instead of deep copies of members --- climada/trajectories/snapshot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/climada/trajectories/snapshot.py b/climada/trajectories/snapshot.py index d8c78c0c20..cc4a26f871 100644 --- a/climada/trajectories/snapshot.py +++ b/climada/trajectories/snapshot.py @@ -52,6 +52,9 @@ class Snapshot: The date of the Snapshot, it can be an integer representing a year, a datetime object or a string representation of a datetime object with format "YYYY-MM-DD". + ref_only : bool, default False + Should the `Snapshot` contain deep copies of the Exposures, Hazard and Impfset (False) + or references only (True). Attributes ---------- @@ -80,10 +83,11 @@ def __init__( hazard: Hazard, impfset: ImpactFuncSet, date: int | datetime.date | str, + ref_only: bool = False, ) -> None: - self._exposure = copy.deepcopy(exposure) - self._hazard = copy.deepcopy(hazard) - self._impfset = copy.deepcopy(impfset) + self._exposure = exposure if ref_only else copy.deepcopy(exposure) + self._hazard = hazard if ref_only else copy.deepcopy(hazard) + self._impfset = impfset if ref_only else copy.deepcopy(impfset) self._measure = None self._date = self._convert_to_date(date) From b4f05e1f5986248551d29f121098b664157988ad Mon Sep 17 00:00:00 2001 From: spjuhel Date: Mon, 5 Jan 2026 14:24:46 +0100 Subject: [PATCH 05/12] Pylint fix --- climada/trajectories/snapshot.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/climada/trajectories/snapshot.py b/climada/trajectories/snapshot.py index cc4a26f871..ae844305fb 100644 --- a/climada/trajectories/snapshot.py +++ b/climada/trajectories/snapshot.py @@ -27,8 +27,6 @@ import datetime import logging -import pandas as pd - from climada.entity.exposures import Exposures from climada.entity.impact_funcs import ImpactFuncSet from climada.entity.measures.base import Measure From 4a8c770fe0f1ad0d7832566d5dd4f911556551da Mon Sep 17 00:00:00 2001 From: spjuhel Date: Mon, 5 Jan 2026 15:07:58 +0100 Subject: [PATCH 06/12] Fixes pylint --- climada/trajectories/snapshot.py | 71 +++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/climada/trajectories/snapshot.py b/climada/trajectories/snapshot.py index ae844305fb..24a90ca0e1 100644 --- a/climada/trajectories/snapshot.py +++ b/climada/trajectories/snapshot.py @@ -80,15 +80,65 @@ def __init__( exposure: Exposures, hazard: Hazard, impfset: ImpactFuncSet, + measure: Measure | None, date: int | datetime.date | str, ref_only: bool = False, ) -> None: self._exposure = exposure if ref_only else copy.deepcopy(exposure) self._hazard = hazard if ref_only else copy.deepcopy(hazard) self._impfset = impfset if ref_only else copy.deepcopy(impfset) - self._measure = None + self._measure = measure if ref_only else copy.deepcopy(impfset) self._date = self._convert_to_date(date) + @classmethod + def from_triplet( + cls, + *, + exposure: Exposures, + hazard: Hazard, + impfset: ImpactFuncSet, + date: int | datetime.date | str, + ref_only: bool = False, + ) -> "Snapshot": + """Create a Snapshot from exposure, hazard and impact functions set + + This method is the main point of entry for the creation of Snapshot. It + creates a new Snapshot object for the given date with copies of the + hazard, exposure and impact function set given in argument (or + references if ref_only is True) + + Parameters + ---------- + exposure : Exposures + hazard : Hazard + impfset : ImpactFuncSet + date : int | datetime.date | str + ref_only : bool + If true, uses references to the exposure, hazard and impact + function objects. Note that modifying the original objects after + computations using the Snapshot might lead to inconsistencies in + results. + + Returns + ------- + Snapshot + + Notes + ----- + + To create a Snapshot with a measure, first create the Snapshot without + the measure using this method, and use `apply_measure(measure)` afterward. + + """ + return cls( + exposure=exposure, + hazard=hazard, + impfset=impfset, + measure=None, + date=date, + ref_only=ref_only, + ) + @property def exposure(self) -> Exposures: """Exposure data for the snapshot.""" @@ -129,17 +179,17 @@ def _convert_to_date(date_arg) -> datetime.date: if isinstance(date_arg, int): # Assume the integer represents a year return datetime.date(date_arg, 1, 1) - elif isinstance(date_arg, str): + if isinstance(date_arg, str): # Try to parse the string as a date try: return datetime.datetime.strptime(date_arg, "%Y-%m-%d").date() - except ValueError: - raise ValueError("String must be in the format 'YYYY-MM-DD'") - elif isinstance(date_arg, datetime.date): + except ValueError as exc: + raise ValueError("String must be in the format 'YYYY-MM-DD'") from exc + if isinstance(date_arg, datetime.date): # Already a date object return date_arg - else: - raise TypeError("date_arg must be an int, str, or datetime.date") + + raise TypeError("date_arg must be an int, str, or datetime.date") def apply_measure(self, measure: Measure) -> "Snapshot": """Create a new snapshot by applying a Measure object. @@ -158,8 +208,9 @@ def apply_measure(self, measure: Measure) -> "Snapshot": """ - LOGGER.debug(f"Applying measure {measure.name} on snapshot {id(self)}") + LOGGER.debug("Applying measure %s on snapshot %s", measure.name, id(self)) exp, impfset, haz = measure.apply(self.exposure, self.impfset, self.hazard) - snap = Snapshot(exposure=exp, hazard=haz, impfset=impfset, date=self.date) - snap._measure = measure + snap = Snapshot( + exposure=exp, hazard=haz, impfset=impfset, date=self.date, measure=measure + ) return snap From 87332be211ccf32dc660831610c64276d98b05c3 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Mon, 5 Jan 2026 15:22:09 +0100 Subject: [PATCH 07/12] Complies with pylint --- climada/trajectories/impact_calc_strat.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/climada/trajectories/impact_calc_strat.py b/climada/trajectories/impact_calc_strat.py index a58aceeab2..75cf08f545 100644 --- a/climada/trajectories/impact_calc_strat.py +++ b/climada/trajectories/impact_calc_strat.py @@ -32,6 +32,10 @@ __all__ = ["ImpactCalcComputation"] +# The following is acceptable. +# We design a pattern, and currently it requires only to +# define the compute_impacts method. +# pylint: disable=too-few-public-methods class ImpactComputationStrategy(ABC): """ Interface for impact computation strategies. @@ -73,7 +77,6 @@ def compute_impacts( -------- ImpactCalcComputation : The default implementation of this interface. """ - ... class ImpactCalcComputation(ImpactComputationStrategy): From ad2e77450b1faea09cad53eb36941fe61eb28f1a Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 6 Jan 2026 10:29:19 +0100 Subject: [PATCH 08/12] ref only for apply measure --- climada/trajectories/snapshot.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/climada/trajectories/snapshot.py b/climada/trajectories/snapshot.py index 24a90ca0e1..8d9a74ef6f 100644 --- a/climada/trajectories/snapshot.py +++ b/climada/trajectories/snapshot.py @@ -191,7 +191,7 @@ def _convert_to_date(date_arg) -> datetime.date: raise TypeError("date_arg must be an int, str, or datetime.date") - def apply_measure(self, measure: Measure) -> "Snapshot": + def apply_measure(self, measure: Measure, ref_only: bool = False) -> "Snapshot": """Create a new snapshot by applying a Measure object. This method creates a new `Snapshot` object by applying a measure on @@ -211,6 +211,11 @@ def apply_measure(self, measure: Measure) -> "Snapshot": LOGGER.debug("Applying measure %s on snapshot %s", measure.name, id(self)) exp, impfset, haz = measure.apply(self.exposure, self.impfset, self.hazard) snap = Snapshot( - exposure=exp, hazard=haz, impfset=impfset, date=self.date, measure=measure + exposure=exp, + hazard=haz, + impfset=impfset, + date=self.date, + measure=measure, + ref_only=ref_only, ) return snap From 25fbcdaed93965ea0d8c44d5fc6bc21be4af3180 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 6 Jan 2026 10:35:22 +0100 Subject: [PATCH 09/12] adds API references rst files --- doc/api/climada/climada.rst | 1 + doc/api/climada/climada.trajectories.rst | 7 +++++++ doc/api/climada/climada.trajectories.snapshot.rst | 7 +++++++ 3 files changed, 15 insertions(+) create mode 100644 doc/api/climada/climada.trajectories.rst create mode 100644 doc/api/climada/climada.trajectories.snapshot.rst diff --git a/doc/api/climada/climada.rst b/doc/api/climada/climada.rst index 557532912f..2e8d053946 100644 --- a/doc/api/climada/climada.rst +++ b/doc/api/climada/climada.rst @@ -7,4 +7,5 @@ Software documentation per package climada.engine climada.entity climada.hazard + climada.trajectories climada.util diff --git a/doc/api/climada/climada.trajectories.rst b/doc/api/climada/climada.trajectories.rst new file mode 100644 index 0000000000..28c035e20e --- /dev/null +++ b/doc/api/climada/climada.trajectories.rst @@ -0,0 +1,7 @@ + +climada\.trajectories module +============================ + +.. toctree:: + + climada.trajectories.snapshot diff --git a/doc/api/climada/climada.trajectories.snapshot.rst b/doc/api/climada/climada.trajectories.snapshot.rst new file mode 100644 index 0000000000..ba0faf57ac --- /dev/null +++ b/doc/api/climada/climada.trajectories.snapshot.rst @@ -0,0 +1,7 @@ +climada\.trajectories\.snapshot module +---------------------------------------- + +.. automodule:: climada.trajectories.snapshot + :members: + :undoc-members: + :show-inheritance: From cf40e8f3e4d085ab354af90b4b2e04d6d59b8e9b Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 6 Jan 2026 10:41:10 +0100 Subject: [PATCH 10/12] API references files --- doc/api/climada/climada.trajectories.impact_calc_strat.rst | 7 +++++++ doc/api/climada/climada.trajectories.rst | 1 + 2 files changed, 8 insertions(+) create mode 100644 doc/api/climada/climada.trajectories.impact_calc_strat.rst diff --git a/doc/api/climada/climada.trajectories.impact_calc_strat.rst b/doc/api/climada/climada.trajectories.impact_calc_strat.rst new file mode 100644 index 0000000000..1bf211b4c0 --- /dev/null +++ b/doc/api/climada/climada.trajectories.impact_calc_strat.rst @@ -0,0 +1,7 @@ +climada\.trajectories\.impact_calc_strat module +---------------------------------------- + +.. automodule:: climada.trajectories.impact_calc_strat + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/climada/climada.trajectories.rst b/doc/api/climada/climada.trajectories.rst index 28c035e20e..883078074f 100644 --- a/doc/api/climada/climada.trajectories.rst +++ b/doc/api/climada/climada.trajectories.rst @@ -5,3 +5,4 @@ climada\.trajectories module .. toctree:: climada.trajectories.snapshot + climada.trajectories.impact_calc_strat From 2da5952fa12d95bee4a014bf8ff993e696526eb0 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 6 Jan 2026 16:00:06 +0100 Subject: [PATCH 11/12] Removes remaining global risk transfer code --- climada/trajectories/impact_calc_strat.py | 28 +---------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/climada/trajectories/impact_calc_strat.py b/climada/trajectories/impact_calc_strat.py index 75cf08f545..b1bb6eebd3 100644 --- a/climada/trajectories/impact_calc_strat.py +++ b/climada/trajectories/impact_calc_strat.py @@ -95,7 +95,7 @@ def compute_impacts( vul: ImpactFuncSet, ) -> Impact: """ - Calculates the impact and applies the "global" risk transfer mechanism. + Calculates the impact. Parameters ---------- @@ -111,30 +111,4 @@ def compute_impacts( Impact The final impact object. """ - impact = self.compute_impacts_pre_transfer(exp, haz, vul) - return impact - - def compute_impacts_pre_transfer( - self, - exp: Exposures, - haz: Hazard, - vul: ImpactFuncSet, - ) -> Impact: - """ - Calculates the raw impact matrix before any risk transfer is applied. - - Parameters - ---------- - exp : Exposures - The exposure data. - haz : Hazard - The hazard data. - vul : ImpactFuncSet - The set of vulnerability functions. - - Returns - ------- - Impact - An Impact object containing the raw, pre-transfer impact matrix. - """ return ImpactCalc(exposures=exp, impfset=vul, hazard=haz).impact() From a4e0e0c8232964436536709d5d449cb3770d3ca2 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 6 Jan 2026 16:00:32 +0100 Subject: [PATCH 12/12] Shifts test to pytest --- .../test/test_impact_calc_strat.py | 109 ++++++++++-------- 1 file changed, 61 insertions(+), 48 deletions(-) diff --git a/climada/trajectories/test/test_impact_calc_strat.py b/climada/trajectories/test/test_impact_calc_strat.py index a828ec51e6..eb5a53a2c0 100644 --- a/climada/trajectories/test/test_impact_calc_strat.py +++ b/climada/trajectories/test/test_impact_calc_strat.py @@ -20,65 +20,78 @@ """ -import unittest from unittest.mock import MagicMock, patch +import pytest + from climada.engine import Impact from climada.entity import ImpactFuncSet from climada.entity.exposures import Exposures from climada.hazard import Hazard from climada.trajectories import Snapshot -from climada.trajectories.impact_calc_strat import ImpactCalcComputation - - -class TestImpactCalcComputation(unittest.TestCase): - def setUp(self): - self.mock_snapshot0 = MagicMock(spec=Snapshot) - self.mock_snapshot0.exposure = MagicMock(spec=Exposures) - self.mock_snapshot0.hazard = MagicMock(spec=Hazard) - self.mock_snapshot0.impfset = MagicMock(spec=ImpactFuncSet) - self.mock_snapshot1 = MagicMock(spec=Snapshot) - self.mock_snapshot1.exposure = MagicMock(spec=Exposures) - self.mock_snapshot1.hazard = MagicMock(spec=Hazard) - self.mock_snapshot1.impfset = MagicMock(spec=ImpactFuncSet) - - self.impact_calc_computation = ImpactCalcComputation() - - @patch.object(ImpactCalcComputation, "compute_impacts_pre_transfer") - def test_compute_impacts(self, mock_calculate_impacts_for_snapshots): - mock_impacts = MagicMock(spec=Impact) - mock_calculate_impacts_for_snapshots.return_value = mock_impacts - - result = self.impact_calc_computation.compute_impacts( - exp=self.mock_snapshot0.exposure, - haz=self.mock_snapshot0.hazard, - vul=self.mock_snapshot0.impfset, - ) +from climada.trajectories.impact_calc_strat import ( + ImpactCalcComputation, + ImpactComputationStrategy, +) + +# --- Fixtures --- + + +@pytest.fixture +def mock_snapshot(): + """Provides a snapshot with mocked exposure, hazard, and impact functions.""" + snap = MagicMock(spec=Snapshot) + snap.exposure = MagicMock(spec=Exposures) + snap.hazard = MagicMock(spec=Hazard) + snap.impfset = MagicMock(spec=ImpactFuncSet) + return snap - self.assertEqual(result, mock_impacts) - mock_calculate_impacts_for_snapshots.assert_called_once_with( - self.mock_snapshot0.exposure, - self.mock_snapshot0.hazard, - self.mock_snapshot0.impfset, - ) - def test_calculate_impacts_for_snapshots(self): - mock_imp_E0H0 = MagicMock(spec=Impact) +@pytest.fixture +def strategy(): + """Provides an instance of the ImpactCalcComputation strategy.""" + return ImpactCalcComputation() - with patch( - "climada.trajectories.impact_calc_strat.ImpactCalc" - ) as mock_impact_calc: - mock_impact_calc.return_value.impact.side_effect = [mock_imp_E0H0] - result = self.impact_calc_computation.compute_impacts_pre_transfer( - exp=self.mock_snapshot0.exposure, - haz=self.mock_snapshot0.hazard, - vul=self.mock_snapshot0.impfset, - ) +# --- Tests --- +def test_interface_compliance(strategy): + """Ensure the class correctly inherits from the Abstract Base Class.""" + assert isinstance(strategy, ImpactComputationStrategy) + assert isinstance(strategy, ImpactCalcComputation) + + +def test_compute_impacts(strategy, mock_snapshot): + """Test that compute_impacts calls the pre-transfer method correctly.""" + mock_impacts = MagicMock(spec=Impact) + + # We patch the ImpactCalc within trajectories + with patch("climada.trajectories.impact_calc_strat.ImpactCalc") as mock_ImpactCalc: + mock_ImpactCalc.return_value.impact.return_value = mock_impacts + result = strategy.compute_impacts( + exp=mock_snapshot.exposure, + haz=mock_snapshot.hazard, + vul=mock_snapshot.impfset, + ) + mock_ImpactCalc.assert_called_once_with( + exposures=mock_snapshot.exposure, + impfset=mock_snapshot.impfset, + hazard=mock_snapshot.hazard, + ) + mock_ImpactCalc.return_value.impact.assert_called_once() + assert result == mock_impacts + - self.assertEqual(result, mock_imp_E0H0) +def test_cannot_instantiate_abstract_base_class(): + """Ensure ImpactComputationStrategy cannot be instantiated directly.""" + with pytest.raises(TypeError, match="Can't instantiate abstract class"): + ImpactComputationStrategy() # type: ignore -if __name__ == "__main__": - TESTS = unittest.TestLoader().loadTestsFromTestCase(TestImpactCalcComputation) - unittest.TextTestRunner(verbosity=2).run(TESTS) +@pytest.mark.parametrize("invalid_input", [None, 123, "string"]) +def test_compute_impacts_type_errors(strategy, invalid_input): + """ + Smoke test: Ensure that if ImpactCalc raises errors due to bad input, + the strategy correctly propagates them. + """ + with pytest.raises(AttributeError): + strategy.compute_impacts(invalid_input, invalid_input, invalid_input)