From 5efad8304f2ad84854ace7cb6795111068a747ae Mon Sep 17 00:00:00 2001 From: spjuhel Date: Wed, 17 Dec 2025 17:10:19 +0100 Subject: [PATCH 01/10] 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 bf0026264b5072212ed008637cc3704b5df20aa9 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Thu, 18 Dec 2025 11:07:14 +0100 Subject: [PATCH 02/10] 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 03/10] 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 04/10] 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 05/10] 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 ad2e77450b1faea09cad53eb36941fe61eb28f1a Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 6 Jan 2026 10:29:19 +0100 Subject: [PATCH 06/10] 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 07/10] 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 b8ef41a42991767ea2fa50f6c9b90037dcd1f8f8 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 6 Jan 2026 10:59:20 +0100 Subject: [PATCH 08/10] On The Dangers of Copy Pasting (Juhel 2026) --- climada/trajectories/snapshot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/trajectories/snapshot.py b/climada/trajectories/snapshot.py index 8d9a74ef6f..05d948793f 100644 --- a/climada/trajectories/snapshot.py +++ b/climada/trajectories/snapshot.py @@ -87,7 +87,7 @@ def __init__( 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 = measure if ref_only else copy.deepcopy(impfset) + self._measure = measure if ref_only else copy.deepcopy(measure) self._date = self._convert_to_date(date) @classmethod From 30e2d0efe49716d32f04b11e30b514210c44c889 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 6 Jan 2026 11:29:20 +0100 Subject: [PATCH 09/10] Adds warnings for direct __init__ call --- climada/trajectories/snapshot.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/climada/trajectories/snapshot.py b/climada/trajectories/snapshot.py index 05d948793f..233cc15696 100644 --- a/climada/trajectories/snapshot.py +++ b/climada/trajectories/snapshot.py @@ -26,6 +26,7 @@ import copy import datetime import logging +import warnings from climada.entity.exposures import Exposures from climada.entity.impact_funcs import ImpactFuncSet @@ -83,7 +84,15 @@ def __init__( measure: Measure | None, date: int | datetime.date | str, ref_only: bool = False, + _from_factory: bool = False, ) -> None: + if not _from_factory: + warnings.warn( + "Direct instantiation of 'Snapshot' is discouraged. " + "Use 'Snapshot.from_triplet()' instead.", + UserWarning, + stacklevel=2, + ) 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) @@ -137,6 +146,7 @@ def from_triplet( measure=None, date=date, ref_only=ref_only, + _from_factory=True, ) @property @@ -191,7 +201,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, ref_only: bool = False) -> "Snapshot": + 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 @@ -216,6 +226,7 @@ def apply_measure(self, measure: Measure, ref_only: bool = False) -> "Snapshot": impfset=impfset, date=self.date, measure=measure, - ref_only=ref_only, + ref_only=False, + _from_factory=True, ) return snap From ffbf31eca1086d8a0320a30ec09b3f7e33666561 Mon Sep 17 00:00:00 2001 From: spjuhel Date: Tue, 6 Jan 2026 11:31:13 +0100 Subject: [PATCH 10/10] Shifts tests to pytest format (and updates them) --- climada/trajectories/test/test_snapshot.py | 247 ++++++++++++--------- 1 file changed, 141 insertions(+), 106 deletions(-) diff --git a/climada/trajectories/test/test_snapshot.py b/climada/trajectories/test/test_snapshot.py index 4e3b465d8e..e3c2eb0e9c 100644 --- a/climada/trajectories/test/test_snapshot.py +++ b/climada/trajectories/test/test_snapshot.py @@ -1,9 +1,9 @@ import datetime -import unittest from unittest.mock import MagicMock import numpy as np import pandas as pd +import pytest from climada.entity.exposures import Exposures from climada.entity.impact_funcs import ImpactFunc, ImpactFuncSet @@ -12,121 +12,156 @@ from climada.trajectories.snapshot import Snapshot from climada.util.constants import EXP_DEMO_H5, HAZ_DEMO_H5 +# --- Fixtures --- + + +@pytest.fixture(scope="module") +def shared_data(): + """Load heavy HDF5 data once per module to speed up tests.""" + exposure = Exposures.from_hdf5(EXP_DEMO_H5) + hazard = Hazard.from_hdf5(HAZ_DEMO_H5) + impfset = ImpactFuncSet( + [ + ImpactFunc( + "TC", + 3, + intensity=np.array([0, 20]), + mdd=np.array([0, 0.5]), + paa=np.array([0, 1]), + ) + ] + ) + return exposure, hazard, impfset -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", - ) +@pytest.fixture +def mock_context(shared_data): + """Provides the exposure/hazard/impfset and a pre-configured mock measure.""" + exp, haz, impf = shared_data - 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 - ) + # Setup Mock Measure + mock_measure = MagicMock(spec=Measure) + mock_measure.name = "Test Measure" - def test_properties(self): - snapshot = Snapshot( - exposure=self.mock_exposure, - hazard=self.mock_hazard, - impfset=self.mock_impfset, - date=2023, - ) + modified_exp = MagicMock(spec=Exposures) + modified_haz = MagicMock(spec=Hazard) + modified_imp = MagicMock(spec=ImpactFuncSet) - # 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) + mock_measure.apply.return_value = (modified_exp, modified_imp, modified_haz) - # But we want equality - pd.testing.assert_frame_equal(snapshot.exposure.gdf, self.mock_exposure.gdf) + return { + "exp": exp, + "haz": haz, + "imp": impf, + "measure": mock_measure, + "mod_exp": modified_exp, + "mod_haz": modified_haz, + "mod_imp": modified_imp, + } - 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) +# --- Tests --- - def test_apply_measure(self): - snapshot = Snapshot( - exposure=self.mock_exposure, - hazard=self.mock_hazard, - impfset=self.mock_impfset, - date=2023, + +def test_not_from_factory_warning(mock_context): + """Test that direct __init__ call raises a warning""" + with pytest.warns(UserWarning): + Snapshot( + exposure=mock_context["exp"], + hazard=mock_context["haz"], + impfset=mock_context["imp"], + measure=None, + date=2001, ) - 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) + +@pytest.mark.parametrize( + "input_date,expected", + [ + (2023, datetime.date(2023, 1, 1)), + ("2023-01-01", datetime.date(2023, 1, 1)), + (datetime.date(2023, 1, 1), datetime.date(2023, 1, 1)), + ], +) +def test_init_valid_dates(mock_context, input_date, expected): + """Test various valid date input formats using parametrization.""" + snapshot = Snapshot.from_triplet( + exposure=mock_context["exp"], + hazard=mock_context["haz"], + impfset=mock_context["imp"], + date=input_date, + ) + assert snapshot.date == expected + + +def test_init_invalid_date_format(mock_context): + with pytest.raises(ValueError): + Snapshot.from_triplet( + exposure=mock_context["exp"], + hazard=mock_context["haz"], + impfset=mock_context["imp"], + date="invalid-date", + ) -if __name__ == "__main__": - TESTS = unittest.TestLoader().loadTestsFromTestCase(TestSnapshot) - unittest.TextTestRunner(verbosity=2).run(TESTS) +def test_init_invalid_date_type(mock_context): + with pytest.raises(TypeError): + Snapshot.from_triplet(exposure=mock_context["exp"], hazard=mock_context["haz"], impfset=mock_context["imp"], date=2023.5) # type: ignore + + +def test_properties(mock_context): + snapshot = Snapshot.from_triplet( + exposure=mock_context["exp"], + hazard=mock_context["haz"], + impfset=mock_context["imp"], + date=2023, + ) + + # Check that it's a deep copy (new reference) + assert snapshot.exposure is not mock_context["exp"] + assert snapshot.hazard is not mock_context["haz"] + + assert snapshot.measure is None + + # Check data equality + pd.testing.assert_frame_equal(snapshot.exposure.gdf, mock_context["exp"].gdf) + assert snapshot.hazard.haz_type == mock_context["haz"].haz_type + assert snapshot.impfset == mock_context["imp"] + + +def test_reference(mock_context): + snapshot = Snapshot.from_triplet( + exposure=mock_context["exp"], + hazard=mock_context["haz"], + impfset=mock_context["imp"], + date=2023, + ref_only=True, + ) + + # Check that it is a reference + assert snapshot.exposure is mock_context["exp"] + assert snapshot.hazard is mock_context["haz"] + assert snapshot.impfset is mock_context["imp"] + assert snapshot.measure is None + + # Check data equality + pd.testing.assert_frame_equal(snapshot.exposure.gdf, mock_context["exp"].gdf) + assert snapshot.hazard.haz_type == mock_context["haz"].haz_type + assert snapshot.impfset == mock_context["imp"] + + +def test_apply_measure(mock_context): + snapshot = Snapshot( + exposure=mock_context["exp"], + hazard=mock_context["haz"], + impfset=mock_context["imp"], + measure=None, + date=2023, + ) + new_snapshot = snapshot.apply_measure(mock_context["measure"]) + + assert new_snapshot.measure is not None + assert new_snapshot.measure.name == "Test Measure" + assert new_snapshot.exposure == mock_context["mod_exp"] + assert new_snapshot.hazard == mock_context["mod_haz"] + assert new_snapshot.impfset == mock_context["mod_imp"]