Skip to content

Commit 8f537cf

Browse files
committed
Merge branch 'feature/snapshots' into feature/interpolated-trajectories
2 parents fa3ae24 + 77b76c4 commit 8f537cf

2 files changed

Lines changed: 155 additions & 108 deletions

File tree

climada/trajectories/snapshot.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import copy
2727
import datetime
2828
import logging
29+
import warnings
2930

3031
from climada.entity.exposures import Exposures
3132
from climada.entity.impact_funcs import ImpactFuncSet
@@ -83,7 +84,15 @@ def __init__(
8384
measure: Measure | None,
8485
date: int | datetime.date | str,
8586
ref_only: bool = False,
87+
_from_factory: bool = False,
8688
) -> None:
89+
if not _from_factory:
90+
warnings.warn(
91+
"Direct instantiation of 'Snapshot' is discouraged. "
92+
"Use 'Snapshot.from_triplet()' instead.",
93+
UserWarning,
94+
stacklevel=2,
95+
)
8796
self._exposure = exposure if ref_only else copy.deepcopy(exposure)
8897
self._hazard = hazard if ref_only else copy.deepcopy(hazard)
8998
self._impfset = impfset if ref_only else copy.deepcopy(impfset)
@@ -137,6 +146,7 @@ def from_triplet(
137146
measure=None,
138147
date=date,
139148
ref_only=ref_only,
149+
_from_factory=True,
140150
)
141151

142152
@property
@@ -191,7 +201,7 @@ def _convert_to_date(date_arg) -> datetime.date:
191201

192202
raise TypeError("date_arg must be an int, str, or datetime.date")
193203

194-
def apply_measure(self, measure: Measure, ref_only: bool = False) -> "Snapshot":
204+
def apply_measure(self, measure: Measure) -> "Snapshot":
195205
"""Create a new snapshot by applying a Measure object.
196206
197207
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":
216226
impfset=impfset,
217227
date=self.date,
218228
measure=measure,
219-
ref_only=ref_only,
229+
ref_only=True, # Avoid unecessary copies of new objects
230+
_from_factory=True,
220231
)
221232
return snap
Lines changed: 142 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import datetime
2-
import unittest
32
from unittest.mock import MagicMock
43

54
import numpy as np
65
import pandas as pd
6+
import pytest
77

88
from climada.entity.exposures import Exposures
99
from climada.entity.impact_funcs import ImpactFunc, ImpactFuncSet
@@ -12,121 +12,157 @@
1212
from climada.trajectories.snapshot import Snapshot
1313
from climada.util.constants import EXP_DEMO_H5, HAZ_DEMO_H5
1414

15+
# --- Fixtures ---
16+
17+
18+
@pytest.fixture(scope="module")
19+
def shared_data():
20+
"""Load heavy HDF5 data once per module to speed up tests."""
21+
exposure = Exposures.from_hdf5(EXP_DEMO_H5)
22+
hazard = Hazard.from_hdf5(HAZ_DEMO_H5)
23+
impfset = ImpactFuncSet(
24+
[
25+
ImpactFunc(
26+
"TC",
27+
3,
28+
intensity=np.array([0, 20]),
29+
mdd=np.array([0, 0.5]),
30+
paa=np.array([0, 1]),
31+
)
32+
]
33+
)
34+
return exposure, hazard, impfset
1535

16-
class TestSnapshot(unittest.TestCase):
17-
18-
def setUp(self):
19-
# Create mock objects for testing
20-
self.mock_exposure = Exposures.from_hdf5(EXP_DEMO_H5)
21-
self.mock_hazard = Hazard.from_hdf5(HAZ_DEMO_H5)
22-
self.mock_impfset = ImpactFuncSet(
23-
[
24-
ImpactFunc(
25-
"TC",
26-
3,
27-
intensity=np.array([0, 20]),
28-
mdd=np.array([0, 0.5]),
29-
paa=np.array([0, 1]),
30-
)
31-
]
32-
)
33-
self.mock_measure = MagicMock(spec=Measure)
34-
self.mock_measure.name = "Test Measure"
35-
36-
# Setup mock return values for measure.apply
37-
self.mock_modified_exposure = MagicMock(spec=Exposures)
38-
self.mock_modified_hazard = MagicMock(spec=Hazard)
39-
self.mock_modified_impfset = MagicMock(spec=ImpactFuncSet)
40-
self.mock_measure.apply.return_value = (
41-
self.mock_modified_exposure,
42-
self.mock_modified_impfset,
43-
self.mock_modified_hazard,
44-
)
4536

46-
def test_init_with_int_date(self):
47-
snapshot = Snapshot(
48-
exposure=self.mock_exposure,
49-
hazard=self.mock_hazard,
50-
impfset=self.mock_impfset,
51-
date=2023,
52-
)
53-
self.assertEqual(snapshot.date, datetime.date(2023, 1, 1))
54-
55-
def test_init_with_str_date(self):
56-
snapshot = Snapshot(
57-
exposure=self.mock_exposure,
58-
hazard=self.mock_hazard,
59-
impfset=self.mock_impfset,
60-
date="2023-01-01",
61-
)
62-
self.assertEqual(snapshot.date, datetime.date(2023, 1, 1))
63-
64-
def test_init_with_date_object(self):
65-
date_obj = datetime.date(2023, 1, 1)
66-
snapshot = Snapshot(
67-
exposure=self.mock_exposure,
68-
hazard=self.mock_hazard,
69-
impfset=self.mock_impfset,
70-
date=date_obj,
71-
)
72-
self.assertEqual(snapshot.date, date_obj)
73-
74-
def test_init_with_invalid_date(self):
75-
with self.assertRaises(ValueError):
76-
Snapshot(
77-
exposure=self.mock_exposure,
78-
hazard=self.mock_hazard,
79-
impfset=self.mock_impfset,
80-
date="invalid-date",
81-
)
37+
@pytest.fixture
38+
def mock_context(shared_data):
39+
"""Provides the exposure/hazard/impfset and a pre-configured mock measure."""
40+
exp, haz, impf = shared_data
8241

83-
def test_init_with_invalid_type(self):
84-
with self.assertRaises(TypeError):
85-
Snapshot(
86-
exposure=self.mock_exposure,
87-
hazard=self.mock_hazard,
88-
impfset=self.mock_impfset,
89-
date=2023.5, # type: ignore
90-
)
42+
# Setup Mock Measure
43+
mock_measure = MagicMock(spec=Measure)
44+
mock_measure.name = "Test Measure"
9145

92-
def test_properties(self):
93-
snapshot = Snapshot(
94-
exposure=self.mock_exposure,
95-
hazard=self.mock_hazard,
96-
impfset=self.mock_impfset,
97-
date=2023,
98-
)
46+
modified_exp = MagicMock(spec=Exposures)
47+
modified_haz = MagicMock(spec=Hazard)
48+
modified_imp = MagicMock(spec=ImpactFuncSet)
9949

100-
# We want a new reference
101-
self.assertIsNot(snapshot.exposure, self.mock_exposure)
102-
self.assertIsNot(snapshot.hazard, self.mock_hazard)
103-
self.assertIsNot(snapshot.impfset, self.mock_impfset)
50+
mock_measure.apply.return_value = (modified_exp, modified_imp, modified_haz)
10451

105-
# But we want equality
106-
pd.testing.assert_frame_equal(snapshot.exposure.gdf, self.mock_exposure.gdf)
52+
return {
53+
"exp": exp,
54+
"haz": haz,
55+
"imp": impf,
56+
"measure": mock_measure,
57+
"mod_exp": modified_exp,
58+
"mod_haz": modified_haz,
59+
"mod_imp": modified_imp,
60+
}
10761

108-
self.assertEqual(snapshot.hazard.haz_type, self.mock_hazard.haz_type)
109-
self.assertEqual(snapshot.hazard.intensity.nnz, self.mock_hazard.intensity.nnz)
110-
self.assertEqual(snapshot.hazard.size, self.mock_hazard.size)
11162

112-
self.assertEqual(snapshot.impfset, self.mock_impfset)
63+
# --- Tests ---
11364

114-
def test_apply_measure(self):
115-
snapshot = Snapshot(
116-
exposure=self.mock_exposure,
117-
hazard=self.mock_hazard,
118-
impfset=self.mock_impfset,
119-
date=2023,
65+
66+
def test_not_from_factory_warning(mock_context):
67+
"""Test that direct __init__ call raises a warning"""
68+
with pytest.warns(UserWarning):
69+
Snapshot(
70+
exposure=mock_context["exp"],
71+
hazard=mock_context["haz"],
72+
impfset=mock_context["imp"],
73+
measure=None,
74+
date=2001,
12075
)
121-
new_snapshot = snapshot.apply_measure(self.mock_measure)
12276

123-
self.assertIsNotNone(new_snapshot.measure)
124-
self.assertEqual(new_snapshot.measure.name, "Test Measure") # type: ignore
125-
self.assertEqual(new_snapshot.exposure, self.mock_modified_exposure)
126-
self.assertEqual(new_snapshot.hazard, self.mock_modified_hazard)
127-
self.assertEqual(new_snapshot.impfset, self.mock_modified_impfset)
77+
78+
@pytest.mark.parametrize(
79+
"input_date,expected",
80+
[
81+
(2023, datetime.date(2023, 1, 1)),
82+
("2023-01-01", datetime.date(2023, 1, 1)),
83+
(datetime.date(2023, 1, 1), datetime.date(2023, 1, 1)),
84+
],
85+
)
86+
def test_init_valid_dates(mock_context, input_date, expected):
87+
"""Test various valid date input formats using parametrization."""
88+
snapshot = Snapshot.from_triplet(
89+
exposure=mock_context["exp"],
90+
hazard=mock_context["haz"],
91+
impfset=mock_context["imp"],
92+
date=input_date,
93+
)
94+
assert snapshot.date == expected
95+
96+
97+
def test_init_invalid_date_format(mock_context):
98+
with pytest.raises(ValueError, match="String must be in the format"):
99+
Snapshot.from_triplet(
100+
exposure=mock_context["exp"],
101+
hazard=mock_context["haz"],
102+
impfset=mock_context["imp"],
103+
date="invalid-date",
104+
)
128105

129106

130-
if __name__ == "__main__":
131-
TESTS = unittest.TestLoader().loadTestsFromTestCase(TestSnapshot)
132-
unittest.TextTestRunner(verbosity=2).run(TESTS)
107+
def test_init_invalid_date_type(mock_context):
108+
with pytest.raises(
109+
TypeError, match=r"date_arg must be an int, str, or datetime.date"
110+
):
111+
Snapshot.from_triplet(exposure=mock_context["exp"], hazard=mock_context["haz"], impfset=mock_context["imp"], date=2023.5) # type: ignore
112+
113+
114+
def test_properties(mock_context):
115+
snapshot = Snapshot.from_triplet(
116+
exposure=mock_context["exp"],
117+
hazard=mock_context["haz"],
118+
impfset=mock_context["imp"],
119+
date=2023,
120+
)
121+
122+
# Check that it's a deep copy (new reference)
123+
assert snapshot.exposure is not mock_context["exp"]
124+
assert snapshot.hazard is not mock_context["haz"]
125+
126+
assert snapshot.measure is None
127+
128+
# Check data equality
129+
pd.testing.assert_frame_equal(snapshot.exposure.gdf, mock_context["exp"].gdf)
130+
assert snapshot.hazard.haz_type == mock_context["haz"].haz_type
131+
assert snapshot.impfset == mock_context["imp"]
132+
133+
134+
def test_reference(mock_context):
135+
snapshot = Snapshot.from_triplet(
136+
exposure=mock_context["exp"],
137+
hazard=mock_context["haz"],
138+
impfset=mock_context["imp"],
139+
date=2023,
140+
ref_only=True,
141+
)
142+
143+
# Check that it is a reference
144+
assert snapshot.exposure is mock_context["exp"]
145+
assert snapshot.hazard is mock_context["haz"]
146+
assert snapshot.impfset is mock_context["imp"]
147+
assert snapshot.measure is None
148+
149+
# Check data equality
150+
pd.testing.assert_frame_equal(snapshot.exposure.gdf, mock_context["exp"].gdf)
151+
assert snapshot.hazard.haz_type == mock_context["haz"].haz_type
152+
assert snapshot.impfset == mock_context["imp"]
153+
154+
155+
def test_apply_measure(mock_context):
156+
snapshot = Snapshot.from_triplet(
157+
exposure=mock_context["exp"],
158+
hazard=mock_context["haz"],
159+
impfset=mock_context["imp"],
160+
date=2023,
161+
)
162+
new_snapshot = snapshot.apply_measure(mock_context["measure"])
163+
164+
assert new_snapshot.measure is not None
165+
assert new_snapshot.measure.name == "Test Measure"
166+
assert new_snapshot.exposure == mock_context["mod_exp"]
167+
assert new_snapshot.hazard == mock_context["mod_haz"]
168+
assert new_snapshot.impfset == mock_context["mod_imp"]

0 commit comments

Comments
 (0)