From 7da50f163d051498b1bb423e4d23dcce4e94abc0 Mon Sep 17 00:00:00 2001 From: luseverin Date: Mon, 8 Dec 2025 15:10:52 +0100 Subject: [PATCH 01/19] Return impactForecast object in _return_impact --- climada/engine/impact_calc.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/climada/engine/impact_calc.py b/climada/engine/impact_calc.py index 0586166173..63023ce13d 100644 --- a/climada/engine/impact_calc.py +++ b/climada/engine/impact_calc.py @@ -29,6 +29,8 @@ from climada import CONFIG from climada.engine.impact import Impact +from climada.engine.impact_forecast import ImpactForecast +from climada.hazard.forecast import HazardForecast LOGGER = logging.getLogger(__name__) @@ -217,7 +219,7 @@ def _return_impact(self, imp_mat_gen, save_mat): Returns ------- - Impact + Impact or ImpactForecast Impact Object initialize from the impact matrix See Also @@ -233,9 +235,18 @@ def _return_impact(self, imp_mat_gen, save_mat): else: imp_mat = None at_event, eai_exp, aai_agg = self.stitch_risk_metrics(imp_mat_gen) - return Impact.from_eih( + + impact = Impact.from_eih( self.exposures, self.hazard, at_event, eai_exp, aai_agg, imp_mat ) + if isinstance(self.hazard, HazardForecast): + return ImpactForecast().from_impact( + impact, self.hazard.ensemble_member, self.hazard.lead_time + ) # return ImpactForecast object + else: + return ( + impact # return normal impact object if hazard is not a HazardForecast + ) def _return_empty(self, save_mat): """ From bd3502f7751db10aa89f645683626a3687c4ed87 Mon Sep 17 00:00:00 2001 From: luseverin Date: Mon, 8 Dec 2025 15:12:48 +0100 Subject: [PATCH 02/19] Return impactForecast object in _return_empty --- climada/engine/impact_calc.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/climada/engine/impact_calc.py b/climada/engine/impact_calc.py index 63023ce13d..b680ae03e1 100644 --- a/climada/engine/impact_calc.py +++ b/climada/engine/impact_calc.py @@ -259,7 +259,7 @@ def _return_empty(self, save_mat): Returns ------- - Impact + Impact or ImpactForecast Empty impact object with correct array sizes. """ at_event = np.zeros(self.n_events) @@ -271,9 +271,17 @@ def _return_empty(self, save_mat): ) else: imp_mat = None - return Impact.from_eih( + impact = Impact.from_eih( self.exposures, self.hazard, at_event, eai_exp, aai_agg, imp_mat ) + if isinstance(self.hazard, HazardForecast): + return ImpactForecast().from_impact( + impact, self.hazard.ensemble_member, self.hazard.lead_time + ) # return ImpactForecast object + else: + return ( + impact # return normal impact object if hazard is not a HazardForecast + ) def minimal_exp_gdf( self, impf_col, assign_centroids, ignore_cover, ignore_deductible From dfa0198c49d36c76140a6c0ebb10cb42ccde5ff4 Mon Sep 17 00:00:00 2001 From: luseverin Date: Mon, 8 Dec 2025 16:01:00 +0100 Subject: [PATCH 03/19] Add full impactcalc test for impactForecast --- climada/engine/test/test_impact_calc.py | 88 ++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/climada/engine/test/test_impact_calc.py b/climada/engine/test/test_impact_calc.py index bd606c6e19..e95a39355d 100644 --- a/climada/engine/test/test_impact_calc.py +++ b/climada/engine/test/test_impact_calc.py @@ -26,14 +26,17 @@ import geopandas as gpd import numpy as np +import pandas as pd from scipy import sparse from climada import CONFIG from climada.engine import Impact, ImpactCalc from climada.engine.impact_calc import LOGGER as ILOG +from climada.engine.impact_forecast import ImpactForecast from climada.entity import Exposures, ImpactFunc, ImpactFuncSet, ImpfTropCyclone from climada.entity.entity_def import Entity from climada.hazard.base import Centroids, Hazard +from climada.hazard.forecast import HazardForecast from climada.test import get_test_file from climada.util.api_client import Client from climada.util.config import Config @@ -47,7 +50,7 @@ def check_impact(self, imp, haz, exp, aai_agg, eai_exp, at_event, imp_mat_array=None): - """Test properties of imapcts""" + """Test properties of impacts""" self.assertEqual(len(haz.event_id), len(imp.at_event)) self.assertIsInstance(imp, Impact) np.testing.assert_allclose(imp.coord_exp[:, 0], exp.latitude) @@ -302,6 +305,89 @@ def test_calc_impact_RF_pass(self): # fmt: on check_impact(self, impact, haz, exp, aai_agg, eai_exp, at_event, imp_mat_array) + def test_impactForecast(self): + """Test that ImpactForecast is returned correctly""" + lead_time = pd.timedelta_range("1h", periods=6).to_numpy() + member = np.arange(6) + _haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) + haz_fc = HazardForecast.from_hazard(_haz, lead_time=lead_time, member=member) + + exp = Exposures.from_hdf5( + get_test_file("test_exposure_US_flood_random_locations") + ) + impf_set = ImpactFuncSet.from_excel( + Path(__file__).parent / "data" / "flood_imp_func_set.xls" + ) + icalc = ImpactCalc(exp, impf_set, haz_fc) + impact = icalc.impact(assign_centroids=False) + aai_agg = 161436.05112960344 + eai_exp = np.array( + [ + 1.61159701e05, + 1.33742847e02, + 0.00000000e00, + 4.21352988e-01, + 1.42185609e02, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + ] + ) + at_event = np.array( + [ + 0.00000000e00, + 0.00000000e00, + 9.85233619e04, + 3.41245461e04, + 7.73566566e07, + 0.00000000e00, + 0.00000000e00, + ] + ) + # fmt: off + imp_mat_array = np.array( + [ + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 6.41965663e04, 0.00000000e00, 2.02249434e02, + 3.41245461e04, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 3.41245461e04, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 7.73566566e07, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + ] + ) + # fmt: on + check_impact( + self, impact, haz_fc, exp, aai_agg, eai_exp, at_event, imp_mat_array + ) + + # additional test to check that impact is indeed ImpactForecast + self.assertIsInstance(impact, ImpactForecast) + np.testing.assert_array_equal(impact.lead_time, lead_time) + self.assertIs(impact.lead_time.dtype, lead_time.dtype) + np.testing.assert_array_equal(impact.member, member) + def test_empty_impact(self): """Check that empty impact is returned if no centroids match the exposures""" exp = ENT.exposures.copy() From 59c0e5b4f94087d60363125f7812b17fe513835c Mon Sep 17 00:00:00 2001 From: luseverin Date: Mon, 8 Dec 2025 16:01:31 +0100 Subject: [PATCH 04/19] Correct mistakes in _return_empty and _return_impact --- climada/engine/impact_calc.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/climada/engine/impact_calc.py b/climada/engine/impact_calc.py index b680ae03e1..2e787460b9 100644 --- a/climada/engine/impact_calc.py +++ b/climada/engine/impact_calc.py @@ -240,8 +240,8 @@ def _return_impact(self, imp_mat_gen, save_mat): self.exposures, self.hazard, at_event, eai_exp, aai_agg, imp_mat ) if isinstance(self.hazard, HazardForecast): - return ImpactForecast().from_impact( - impact, self.hazard.ensemble_member, self.hazard.lead_time + return ImpactForecast.from_impact( + impact, self.hazard.lead_time, self.hazard.member ) # return ImpactForecast object else: return ( @@ -275,8 +275,8 @@ def _return_empty(self, save_mat): self.exposures, self.hazard, at_event, eai_exp, aai_agg, imp_mat ) if isinstance(self.hazard, HazardForecast): - return ImpactForecast().from_impact( - impact, self.hazard.ensemble_member, self.hazard.lead_time + return ImpactForecast.from_impact( + impact, self.hazard.lead_time, self.hazard.member ) # return ImpactForecast object else: return ( From 4b5ae95a51b9f91efd14a9d6b1b3833d1960b37b Mon Sep 17 00:00:00 2001 From: luseverin Date: Mon, 8 Dec 2025 16:29:26 +0100 Subject: [PATCH 05/19] Raise value error when computing impact with impact forecast without saving impact matrix --- climada/engine/impact_calc.py | 8 ++++++++ climada/engine/test/test_impact_calc.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/climada/engine/impact_calc.py b/climada/engine/impact_calc.py index 2e787460b9..c09fde4aec 100644 --- a/climada/engine/impact_calc.py +++ b/climada/engine/impact_calc.py @@ -233,6 +233,10 @@ def _return_impact(self, imp_mat_gen, save_mat): imp_mat, self.hazard.frequency ) else: + if isinstance(self.hazard, HazardForecast): + raise ValueError( + "Saving impact matrix is required when using HazardForecast." + ) imp_mat = None at_event, eai_exp, aai_agg = self.stitch_risk_metrics(imp_mat_gen) @@ -270,6 +274,10 @@ def _return_empty(self, save_mat): (self.n_events, self.n_exp_pnt), dtype=np.float64 ) else: + if isinstance(self.hazard, HazardForecast): + raise ValueError( + "Saving impact matrix is required when using HazardForecast." + ) imp_mat = None impact = Impact.from_eih( self.exposures, self.hazard, at_event, eai_exp, aai_agg, imp_mat diff --git a/climada/engine/test/test_impact_calc.py b/climada/engine/test/test_impact_calc.py index e95a39355d..7c26e55b4a 100644 --- a/climada/engine/test/test_impact_calc.py +++ b/climada/engine/test/test_impact_calc.py @@ -388,6 +388,30 @@ def test_impactForecast(self): self.assertIs(impact.lead_time.dtype, lead_time.dtype) np.testing.assert_array_equal(impact.member, member) + def test_impact_forecast_empty_impmat_error(self): + """Test that error is raised when trying to compute impact forecast + without saving impact matrix + """ + lead_time = pd.timedelta_range("1h", periods=6).to_numpy() + member = np.arange(6) + _haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) + haz_fc = HazardForecast.from_hazard(_haz, lead_time=lead_time, member=member) + + exp = Exposures.from_hdf5( + get_test_file("test_exposure_US_flood_random_locations") + ) + impf_set = ImpactFuncSet.from_excel( + Path(__file__).parent / "data" / "flood_imp_func_set.xls" + ) + icalc = ImpactCalc(exp, impf_set, haz_fc) + with self.assertRaises(ValueError) as cm: + icalc.impact(assign_centroids=False, save_mat=False) + the_exception = cm.exception + self.assertEqual( + the_exception.args[0], + "Saving impact matrix is required when using HazardForecast.", + ) + def test_empty_impact(self): """Check that empty impact is returned if no centroids match the exposures""" exp = ENT.exposures.copy() From 9a516e1616eed05af3eacb15b5ae43fd4fd026ef Mon Sep 17 00:00:00 2001 From: Chahan Kropf Date: Mon, 8 Dec 2025 17:24:12 +0100 Subject: [PATCH 06/19] Cosmetics: Improve error message, move test to own class --- climada/engine/impact_calc.py | 14 +- climada/engine/test/test_impact_calc.py | 220 ++++++++++++------------ 2 files changed, 119 insertions(+), 115 deletions(-) diff --git a/climada/engine/impact_calc.py b/climada/engine/impact_calc.py index c09fde4aec..2a5f3e35be 100644 --- a/climada/engine/impact_calc.py +++ b/climada/engine/impact_calc.py @@ -236,6 +236,7 @@ def _return_impact(self, imp_mat_gen, save_mat): if isinstance(self.hazard, HazardForecast): raise ValueError( "Saving impact matrix is required when using HazardForecast." + "Please set save_mat=True." ) imp_mat = None at_event, eai_exp, aai_agg = self.stitch_risk_metrics(imp_mat_gen) @@ -246,11 +247,9 @@ def _return_impact(self, imp_mat_gen, save_mat): if isinstance(self.hazard, HazardForecast): return ImpactForecast.from_impact( impact, self.hazard.lead_time, self.hazard.member - ) # return ImpactForecast object - else: - return ( - impact # return normal impact object if hazard is not a HazardForecast ) + else: + return impact def _return_empty(self, save_mat): """ @@ -277,6 +276,7 @@ def _return_empty(self, save_mat): if isinstance(self.hazard, HazardForecast): raise ValueError( "Saving impact matrix is required when using HazardForecast." + "Please set save_mat=True." ) imp_mat = None impact = Impact.from_eih( @@ -285,11 +285,9 @@ def _return_empty(self, save_mat): if isinstance(self.hazard, HazardForecast): return ImpactForecast.from_impact( impact, self.hazard.lead_time, self.hazard.member - ) # return ImpactForecast object - else: - return ( - impact # return normal impact object if hazard is not a HazardForecast ) + else: + return impact def minimal_exp_gdf( self, impf_col, assign_centroids, ignore_cover, ignore_deductible diff --git a/climada/engine/test/test_impact_calc.py b/climada/engine/test/test_impact_calc.py index 7c26e55b4a..c97f2a1f9d 100644 --- a/climada/engine/test/test_impact_calc.py +++ b/climada/engine/test/test_impact_calc.py @@ -305,113 +305,6 @@ def test_calc_impact_RF_pass(self): # fmt: on check_impact(self, impact, haz, exp, aai_agg, eai_exp, at_event, imp_mat_array) - def test_impactForecast(self): - """Test that ImpactForecast is returned correctly""" - lead_time = pd.timedelta_range("1h", periods=6).to_numpy() - member = np.arange(6) - _haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) - haz_fc = HazardForecast.from_hazard(_haz, lead_time=lead_time, member=member) - - exp = Exposures.from_hdf5( - get_test_file("test_exposure_US_flood_random_locations") - ) - impf_set = ImpactFuncSet.from_excel( - Path(__file__).parent / "data" / "flood_imp_func_set.xls" - ) - icalc = ImpactCalc(exp, impf_set, haz_fc) - impact = icalc.impact(assign_centroids=False) - aai_agg = 161436.05112960344 - eai_exp = np.array( - [ - 1.61159701e05, - 1.33742847e02, - 0.00000000e00, - 4.21352988e-01, - 1.42185609e02, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - ] - ) - at_event = np.array( - [ - 0.00000000e00, - 0.00000000e00, - 9.85233619e04, - 3.41245461e04, - 7.73566566e07, - 0.00000000e00, - 0.00000000e00, - ] - ) - # fmt: off - imp_mat_array = np.array( - [ - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 6.41965663e04, 0.00000000e00, 2.02249434e02, - 3.41245461e04, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 3.41245461e04, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 7.73566566e07, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - ] - ) - # fmt: on - check_impact( - self, impact, haz_fc, exp, aai_agg, eai_exp, at_event, imp_mat_array - ) - - # additional test to check that impact is indeed ImpactForecast - self.assertIsInstance(impact, ImpactForecast) - np.testing.assert_array_equal(impact.lead_time, lead_time) - self.assertIs(impact.lead_time.dtype, lead_time.dtype) - np.testing.assert_array_equal(impact.member, member) - - def test_impact_forecast_empty_impmat_error(self): - """Test that error is raised when trying to compute impact forecast - without saving impact matrix - """ - lead_time = pd.timedelta_range("1h", periods=6).to_numpy() - member = np.arange(6) - _haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) - haz_fc = HazardForecast.from_hazard(_haz, lead_time=lead_time, member=member) - - exp = Exposures.from_hdf5( - get_test_file("test_exposure_US_flood_random_locations") - ) - impf_set = ImpactFuncSet.from_excel( - Path(__file__).parent / "data" / "flood_imp_func_set.xls" - ) - icalc = ImpactCalc(exp, impf_set, haz_fc) - with self.assertRaises(ValueError) as cm: - icalc.impact(assign_centroids=False, save_mat=False) - the_exception = cm.exception - self.assertEqual( - the_exception.args[0], - "Saving impact matrix is required when using HazardForecast.", - ) - def test_empty_impact(self): """Check that empty impact is returned if no centroids match the exposures""" exp = ENT.exposures.copy() @@ -716,6 +609,118 @@ def test_single_exp_zero_mdr(self): check_impact(self, imp, haz, exp, aai_agg, eai_exp, at_event, at_event) +class TestImpactCalcForecast(unittest.TestCase): + """Test impact calc for forecast hazard""" + + def test_impactForecast(self): + """Test that ImpactForecast is returned correctly""" + lead_time = pd.timedelta_range("1h", periods=6).to_numpy() + member = np.arange(6) + _haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) + haz_fc = HazardForecast.from_hazard(_haz, lead_time=lead_time, member=member) + + exp = Exposures.from_hdf5( + get_test_file("test_exposure_US_flood_random_locations") + ) + impf_set = ImpactFuncSet.from_excel( + Path(__file__).parent / "data" / "flood_imp_func_set.xls" + ) + icalc = ImpactCalc(exp, impf_set, haz_fc) + impact = icalc.impact(assign_centroids=False) + aai_agg = 161436.05112960344 + eai_exp = np.array( + [ + 1.61159701e05, + 1.33742847e02, + 0.00000000e00, + 4.21352988e-01, + 1.42185609e02, + 0.00000000e00, + 0.00000000e00, + 0.00000000e00, + ] + ) + at_event = np.array( + [ + 0.00000000e00, + 0.00000000e00, + 9.85233619e04, + 3.41245461e04, + 7.73566566e07, + 0.00000000e00, + 0.00000000e00, + ] + ) + # fmt: off + imp_mat_array = np.array( + [ + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 6.41965663e04, 0.00000000e00, 2.02249434e02, + 3.41245461e04, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 3.41245461e04, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 7.73566566e07, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + [ + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, + ], + ] + ) + # fmt: on + check_impact( + self, impact, haz_fc, exp, aai_agg, eai_exp, at_event, imp_mat_array + ) + + # additional test to check that impact is indeed ImpactForecast + self.assertIsInstance(impact, ImpactForecast) + np.testing.assert_array_equal(impact.lead_time, lead_time) + self.assertIs(impact.lead_time.dtype, lead_time.dtype) + np.testing.assert_array_equal(impact.member, member) + + def test_impact_forecast_empty_impmat_error(self): + """Test that error is raised when trying to compute impact forecast + without saving impact matrix + """ + lead_time = pd.timedelta_range("1h", periods=6).to_numpy() + member = np.arange(6) + _haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) + haz_fc = HazardForecast.from_hazard(_haz, lead_time=lead_time, member=member) + + exp = Exposures.from_hdf5( + get_test_file("test_exposure_US_flood_random_locations") + ) + impf_set = ImpactFuncSet.from_excel( + Path(__file__).parent / "data" / "flood_imp_func_set.xls" + ) + icalc = ImpactCalc(exp, impf_set, haz_fc) + with self.assertRaises(ValueError) as cm: + icalc.impact(assign_centroids=False, save_mat=False) + no_impmat_exception = cm.exception + self.assertEqual( + no_impmat_exception.args[0], + "Saving impact matrix is required when using HazardForecast." + "Please set save_mat=True.", + ) + + class TestImpactMatrixCalc(unittest.TestCase): """Verify the computation of the impact matrix""" @@ -1011,6 +1016,7 @@ def test_skip_mat(self, from_eih_mock): # Execute Tests if __name__ == "__main__": TESTS = unittest.TestLoader().loadTestsFromTestCase(TestImpactCalc) + TESTS.addTests(unittest.TestLoader().loadTestsFromTestCase(TestImpactCalcForecast)) TESTS.addTests(unittest.TestLoader().loadTestsFromTestCase(TestReturnImpact)) TESTS.addTests(unittest.TestLoader().loadTestsFromTestCase(TestImpactMatrix)) TESTS.addTests(unittest.TestLoader().loadTestsFromTestCase(TestImpactMatrixCalc)) From 899d8f01c09c66eadeba7cf0249dd430dc66c551 Mon Sep 17 00:00:00 2001 From: luseverin Date: Tue, 9 Dec 2025 11:29:35 +0100 Subject: [PATCH 07/19] add test to check that eai_exp and aai_agg are nan for forecasts --- climada/engine/test/test_impact_calc.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/climada/engine/test/test_impact_calc.py b/climada/engine/test/test_impact_calc.py index c97f2a1f9d..4ed45c17aa 100644 --- a/climada/engine/test/test_impact_calc.py +++ b/climada/engine/test/test_impact_calc.py @@ -720,6 +720,26 @@ def test_impact_forecast_empty_impmat_error(self): "Please set save_mat=True.", ) + def test_impact_forecast_blocked_nonsense_attrs(self): + """Test that nonsense attributes are blocked when computing impact forecast""" + lead_time = pd.timedelta_range("1h", periods=6).to_numpy() + member = np.arange(6) + haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) + haz_fc = HazardForecast.from_hazard(haz, lead_time=lead_time, member=member) + + exp = Exposures.from_hdf5( + get_test_file("test_exposure_US_flood_random_locations") + ) + impf_set = ImpactFuncSet.from_excel( + Path(__file__).parent / "data" / "flood_imp_func_set.xls" + ) + impact = ImpactCalc(exp, impf_set, haz_fc).impact( + assign_centroids=False, save_mat=True + ) + assert np.isnan(impact.aai_agg) + assert np.all(np.isnan(impact.eai_exp)) + assert impact.eai_exp.shape == (len(exp.gdf),) + class TestImpactMatrixCalc(unittest.TestCase): """Verify the computation of the impact matrix""" From db32170e2d54b7ea9dba9a310344ff4997a086af Mon Sep 17 00:00:00 2001 From: luseverin Date: Tue, 9 Dec 2025 11:30:10 +0100 Subject: [PATCH 08/19] Write nans for eai_exp and aai_agg when forecast is used --- climada/engine/impact_calc.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/climada/engine/impact_calc.py b/climada/engine/impact_calc.py index 2a5f3e35be..9e524a4d03 100644 --- a/climada/engine/impact_calc.py +++ b/climada/engine/impact_calc.py @@ -232,6 +232,14 @@ def _return_impact(self, imp_mat_gen, save_mat): at_event, eai_exp, aai_agg = self.risk_metrics( imp_mat, self.hazard.frequency ) + if isinstance(self.hazard, HazardForecast): + eai_exp = np.nan * np.ones(eai_exp.shape, dtype=eai_exp.dtype) + aai_agg = np.nan * np.ones(aai_agg.shape, dtype=aai_agg.dtype) + LOGGER.warning( + "eai_exp and aai_agg are undefined with forecasts. " + "Setting them to empty arrays." + ) + else: if isinstance(self.hazard, HazardForecast): raise ValueError( From d2f035f4a870cbdf4eb5fbbc2776779bc6ead20a Mon Sep 17 00:00:00 2001 From: luseverin Date: Tue, 9 Dec 2025 12:17:02 +0100 Subject: [PATCH 09/19] update tests using pytest --- climada/engine/test/test_impact_calc.py | 217 ++++++++++++------------ 1 file changed, 110 insertions(+), 107 deletions(-) diff --git a/climada/engine/test/test_impact_calc.py b/climada/engine/test/test_impact_calc.py index 4ed45c17aa..5957925e47 100644 --- a/climada/engine/test/test_impact_calc.py +++ b/climada/engine/test/test_impact_calc.py @@ -27,6 +27,7 @@ import geopandas as gpd import numpy as np import pandas as pd +import pytest from scipy import sparse from climada import CONFIG @@ -49,6 +50,88 @@ DATA_FOLDER.mkdir(exist_ok=True) +@pytest.fixture(autouse=True) +def exposure_fixture(): + n_exp = 50 + lats = np.linspace(-10, 10, n_exp) + lons = np.linspace(-10, 10, n_exp) + data = gpd.GeoDataFrame( + { + "impf_TC": 1, + "value": 1, + }, + index=range(n_exp), + geometry=gpd.points_from_xy(lons, lats), + crs="EPSG:4326", + ) + exposures = Exposures(data=data) + return exposures + + +@pytest.fixture(autouse=True) +def hazard_fixture(exposure_fixture): + n_events = 10 + centroids = Centroids( + lat=exposure_fixture.gdf.geometry.x, + lon=exposure_fixture.gdf.geometry.y, + ) + intensity = ( + np.ones((n_events, exposure_fixture.gdf.shape[0])) * 50 + ) # uniform intensity + haz = Hazard() + haz.haz_type = "TC" + haz.centroids = centroids + haz.intensity = intensity + haz.frequency = 1 / 10 * np.ones(n_events) # uniform frequency (10 n_events) + return haz + + +@pytest.fixture(autouse=True) +def hazard_forecast_fixture(hazard_fixture): + n_events = hazard_fixture.size + lead_time = pd.timedelta_range("1h", periods=n_events).to_numpy() + member = np.arange(10) + haz_fc = HazardForecast.from_hazard( + hazard=hazard_fixture, + lead_time=lead_time, + member=member, + ) + return haz_fc + + +@pytest.fixture(autouse=True) +def impact_func_set_fixture(exposure_fixture, hazard_fixture): + step_impf = ImpactFunc() + step_impf.id = exposure_fixture.data[f"impf_{hazard_fixture.haz_type}"].unique()[0] + step_impf.haz_type = hazard_fixture.haz_type + step_impf.name = "fixture step function" + step_impf.intensity_unit = "" + step_impf.intensity = np.array([0, 0.495, 0.4955, 0.5, 1, 10]) + step_impf.mdd = np.array([0, 0, 0, 1, 1, 1]) + step_impf.paa = np.sort(np.linspace(1, 1, num=6)) + return ImpactFuncSet([step_impf]) + + +@pytest.fixture(autouse=True) +def impact_calc_fixture(exposure_fixture, hazard_fixture, impact_func_set_fixture): + imp_mat = np.ones( + ( + len(hazard_fixture.event_id), + exposure_fixture.gdf.shape[0], + exposure_fixture.gdf.shape[0], + ) + ) + aai_agg = np.sum(exposure_fixture.gdf["value"]) * hazard_fixture.frequency[0] + eai_exp = np.ones(exposure_fixture.gdf.shape[0]) * hazard_fixture.frequency[0] + at_event = np.ones(hazard_fixture.size) * np.sum(exposure_fixture.gdf["value"]) + return { + "imp_mat": imp_mat, + "aai_agg": aai_agg, + "eai_exp": eai_exp, + "at_event": at_event, + } + + def check_impact(self, imp, haz, exp, aai_agg, eai_exp, at_event, imp_mat_array=None): """Test properties of impacts""" self.assertEqual(len(haz.event_id), len(imp.at_event)) @@ -609,108 +692,34 @@ def test_single_exp_zero_mdr(self): check_impact(self, imp, haz, exp, aai_agg, eai_exp, at_event, at_event) -class TestImpactCalcForecast(unittest.TestCase): +class TestImpactCalcForecast: """Test impact calc for forecast hazard""" - def test_impactForecast(self): + def test_impactForecast_type( + exposure_fixture, + hazard_forecast_fixture, + impact_func_set_fixture, + impact_calc_fixture, + ): """Test that ImpactForecast is returned correctly""" - lead_time = pd.timedelta_range("1h", periods=6).to_numpy() - member = np.arange(6) - _haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) - haz_fc = HazardForecast.from_hazard(_haz, lead_time=lead_time, member=member) - exp = Exposures.from_hdf5( - get_test_file("test_exposure_US_flood_random_locations") - ) - impf_set = ImpactFuncSet.from_excel( - Path(__file__).parent / "data" / "flood_imp_func_set.xls" - ) - icalc = ImpactCalc(exp, impf_set, haz_fc) - impact = icalc.impact(assign_centroids=False) - aai_agg = 161436.05112960344 - eai_exp = np.array( - [ - 1.61159701e05, - 1.33742847e02, - 0.00000000e00, - 4.21352988e-01, - 1.42185609e02, - 0.00000000e00, - 0.00000000e00, - 0.00000000e00, - ] - ) - at_event = np.array( - [ - 0.00000000e00, - 0.00000000e00, - 9.85233619e04, - 3.41245461e04, - 7.73566566e07, - 0.00000000e00, - 0.00000000e00, - ] - ) - # fmt: off - imp_mat_array = np.array( - [ - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 6.41965663e04, 0.00000000e00, 2.02249434e02, - 3.41245461e04, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 3.41245461e04, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 7.73566566e07, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - [ - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - 0.00000000e00, 0.00000000e00, 0.00000000e00, 0.00000000e00, - ], - ] - ) - # fmt: on - check_impact( - self, impact, haz_fc, exp, aai_agg, eai_exp, at_event, imp_mat_array + # check that impact is indeed ImpactForecast + assert isinstance(impact, ImpactForecast) + np.testing.assert_array_equal( + impact.lead_time, hazard_forecast_fixture.lead_time ) + assert impact.lead_time.dtype == hazard_forecast_fixture.lead_time.dtype + np.testing.assert_array_equal(impact.member, hazard_forecast_fixture.member) - # additional test to check that impact is indeed ImpactForecast - self.assertIsInstance(impact, ImpactForecast) - np.testing.assert_array_equal(impact.lead_time, lead_time) - self.assertIs(impact.lead_time.dtype, lead_time.dtype) - np.testing.assert_array_equal(impact.member, member) - - def test_impact_forecast_empty_impmat_error(self): + def test_impact_forecast_empty_impmat_error( + hazard_forecast_fixture, exposure_fixture, impact_func_set_fixture + ): """Test that error is raised when trying to compute impact forecast without saving impact matrix """ - lead_time = pd.timedelta_range("1h", periods=6).to_numpy() - member = np.arange(6) - _haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) - haz_fc = HazardForecast.from_hazard(_haz, lead_time=lead_time, member=member) - - exp = Exposures.from_hdf5( - get_test_file("test_exposure_US_flood_random_locations") - ) - impf_set = ImpactFuncSet.from_excel( - Path(__file__).parent / "data" / "flood_imp_func_set.xls" + icalc = ImpactCalc( + exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture ) - icalc = ImpactCalc(exp, impf_set, haz_fc) with self.assertRaises(ValueError) as cm: icalc.impact(assign_centroids=False, save_mat=False) no_impmat_exception = cm.exception @@ -720,25 +729,19 @@ def test_impact_forecast_empty_impmat_error(self): "Please set save_mat=True.", ) - def test_impact_forecast_blocked_nonsense_attrs(self): + def test_impact_forecast_blocked_nonsense_attrs( + hazard_forecast_fixture, exposure_fixture, impact_func_set_fixture + ): """Test that nonsense attributes are blocked when computing impact forecast""" - lead_time = pd.timedelta_range("1h", periods=6).to_numpy() - member = np.arange(6) - haz = Hazard.from_hdf5(get_test_file("test_hazard_US_flood_random_locations")) - haz_fc = HazardForecast.from_hazard(haz, lead_time=lead_time, member=member) + lead_time = hazard_fixture.lead_time + member = hazard_fixture.member - exp = Exposures.from_hdf5( - get_test_file("test_exposure_US_flood_random_locations") - ) - impf_set = ImpactFuncSet.from_excel( - Path(__file__).parent / "data" / "flood_imp_func_set.xls" - ) - impact = ImpactCalc(exp, impf_set, haz_fc).impact( - assign_centroids=False, save_mat=True - ) + impact = ImpactCalc( + exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture + ).impact(assign_centroids=True, save_mat=True) assert np.isnan(impact.aai_agg) assert np.all(np.isnan(impact.eai_exp)) - assert impact.eai_exp.shape == (len(exp.gdf),) + assert impact.eai_exp.shape == (len(exposure_fixture.gdf),) class TestImpactMatrixCalc(unittest.TestCase): From c10a4b349f6bfaa8c07131f8863cfeb66e212503 Mon Sep 17 00:00:00 2001 From: luseverin Date: Tue, 9 Dec 2025 13:52:48 +0100 Subject: [PATCH 10/19] Fix error in test fixtures --- climada/engine/test/test_impact_calc.py | 30 +++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/climada/engine/test/test_impact_calc.py b/climada/engine/test/test_impact_calc.py index 5957925e47..73651d219d 100644 --- a/climada/engine/test/test_impact_calc.py +++ b/climada/engine/test/test_impact_calc.py @@ -75,11 +75,15 @@ def hazard_fixture(exposure_fixture): lat=exposure_fixture.gdf.geometry.x, lon=exposure_fixture.gdf.geometry.y, ) - intensity = ( + intensity = sparse.csr_matrix( np.ones((n_events, exposure_fixture.gdf.shape[0])) * 50 ) # uniform intensity haz = Hazard() + haz.event_id = np.arange(n_events) + haz.event_name = haz.event_id haz.haz_type = "TC" + haz.date = haz.event_id + haz.frequency_unit = "m/s" haz.centroids = centroids haz.intensity = intensity haz.frequency = 1 / 10 * np.ones(n_events) # uniform frequency (10 n_events) @@ -696,13 +700,16 @@ class TestImpactCalcForecast: """Test impact calc for forecast hazard""" def test_impactForecast_type( + self, exposure_fixture, hazard_forecast_fixture, impact_func_set_fixture, impact_calc_fixture, ): """Test that ImpactForecast is returned correctly""" - + impact = ImpactCalc( + exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture + ).impact(assign_centroids=True, save_mat=True) # check that impact is indeed ImpactForecast assert isinstance(impact, ImpactForecast) np.testing.assert_array_equal( @@ -712,7 +719,7 @@ def test_impactForecast_type( np.testing.assert_array_equal(impact.member, hazard_forecast_fixture.member) def test_impact_forecast_empty_impmat_error( - hazard_forecast_fixture, exposure_fixture, impact_func_set_fixture + self, hazard_forecast_fixture, exposure_fixture, impact_func_set_fixture ): """Test that error is raised when trying to compute impact forecast without saving impact matrix @@ -720,21 +727,20 @@ def test_impact_forecast_empty_impmat_error( icalc = ImpactCalc( exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture ) - with self.assertRaises(ValueError) as cm: - icalc.impact(assign_centroids=False, save_mat=False) - no_impmat_exception = cm.exception - self.assertEqual( - no_impmat_exception.args[0], + no_impmat_exception = ( "Saving impact matrix is required when using HazardForecast." - "Please set save_mat=True.", + "Please set save_mat=True." ) + with pytest.raises(ValueError) as cm: + icalc.impact(assign_centroids=True, save_mat=False) + assert no_impmat_exception == str(cm.value) def test_impact_forecast_blocked_nonsense_attrs( - hazard_forecast_fixture, exposure_fixture, impact_func_set_fixture + self, hazard_forecast_fixture, exposure_fixture, impact_func_set_fixture ): """Test that nonsense attributes are blocked when computing impact forecast""" - lead_time = hazard_fixture.lead_time - member = hazard_fixture.member + lead_time = hazard_forecast_fixture.lead_time + member = hazard_forecast_fixture.member impact = ImpactCalc( exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture From d3a56429dca1c8eed6c4def5d9faef3b89fb8b3c Mon Sep 17 00:00:00 2001 From: luseverin Date: Tue, 9 Dec 2025 14:02:55 +0100 Subject: [PATCH 11/19] Returns nans for eai_exp and aai_agg when exposures is empty --- climada/engine/impact_calc.py | 8 ++++++-- climada/engine/test/test_impact_calc.py | 12 ++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/climada/engine/impact_calc.py b/climada/engine/impact_calc.py index 9e524a4d03..e8ca9c0c4c 100644 --- a/climada/engine/impact_calc.py +++ b/climada/engine/impact_calc.py @@ -273,9 +273,13 @@ def _return_empty(self, save_mat): Impact or ImpactForecast Empty impact object with correct array sizes. """ + if isinstance(self.hazard, HazardForecast): + eai_exp = np.nan * np.ones(self.n_exp_pnt) + aai_agg = np.nan + else: + eai_exp = np.zeros(self.n_exp_pnt) + aai_agg = 0.0 at_event = np.zeros(self.n_events) - eai_exp = np.zeros(self.n_exp_pnt) - aai_agg = 0.0 if save_mat: imp_mat = sparse.csr_matrix( (self.n_events, self.n_exp_pnt), dtype=np.float64 diff --git a/climada/engine/test/test_impact_calc.py b/climada/engine/test/test_impact_calc.py index 73651d219d..27dd18439d 100644 --- a/climada/engine/test/test_impact_calc.py +++ b/climada/engine/test/test_impact_calc.py @@ -51,8 +51,7 @@ @pytest.fixture(autouse=True) -def exposure_fixture(): - n_exp = 50 +def exposure_fixture(n_exp=50): lats = np.linspace(-10, 10, n_exp) lons = np.linspace(-10, 10, n_exp) data = gpd.GeoDataFrame( @@ -749,6 +748,15 @@ def test_impact_forecast_blocked_nonsense_attrs( assert np.all(np.isnan(impact.eai_exp)) assert impact.eai_exp.shape == (len(exposure_fixture.gdf),) + # test that aai_agg and eai_exp are also nan when 0-size exp + empty_exp = exposure_fixture(n_exp=0) + impact_empty = ImpactCalc( + exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture + ).impact(assign_centroids=True, save_mat=True) + assert np.isnan(impact_empty.aai_agg) + assert np.all(np.isnan(impact_empty.eai_exp)) + assert impact_empty.eai_exp.shape == (len(empty_exp.gdf),) + class TestImpactMatrixCalc(unittest.TestCase): """Verify the computation of the impact matrix""" From d43a46c0810fec0e19376d12d12952c5ee6d505d Mon Sep 17 00:00:00 2001 From: luseverin Date: Tue, 9 Dec 2025 14:34:54 +0100 Subject: [PATCH 12/19] add warning when at_event is used with forecast --- climada/engine/impact_forecast.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/climada/engine/impact_forecast.py b/climada/engine/impact_forecast.py index d4afc551d4..d494882af5 100644 --- a/climada/engine/impact_forecast.py +++ b/climada/engine/impact_forecast.py @@ -88,3 +88,13 @@ def from_impact( imp_mat=impact.imp_mat, haz_type=impact.haz_type, ) + + @property + def at_event(self): + return self._at_event + + @at_event.setter + def at_event(self, value): + """Set the total exposure value close to a hazard""" + LOGGER.warning("at_event for forecasts is not yet implemented.") + self._at_event = value From e1975664348c95d9b87538671a43a8361c4fee9b Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:51:23 +0100 Subject: [PATCH 13/19] Update ImpactCalc tests for forecasts --- climada/engine/test/test_impact_calc.py | 149 ++++++++++-------------- 1 file changed, 63 insertions(+), 86 deletions(-) diff --git a/climada/engine/test/test_impact_calc.py b/climada/engine/test/test_impact_calc.py index 27dd18439d..f7dd2ecb75 100644 --- a/climada/engine/test/test_impact_calc.py +++ b/climada/engine/test/test_impact_calc.py @@ -50,8 +50,9 @@ DATA_FOLDER.mkdir(exist_ok=True) -@pytest.fixture(autouse=True) -def exposure_fixture(n_exp=50): +@pytest.fixture(params=[50, 1, 0]) +def exposure(request): + n_exp = request.param lats = np.linspace(-10, 10, n_exp) lons = np.linspace(-10, 10, n_exp) data = gpd.GeoDataFrame( @@ -67,19 +68,19 @@ def exposure_fixture(n_exp=50): return exposures -@pytest.fixture(autouse=True) -def hazard_fixture(exposure_fixture): +@pytest.fixture +def hazard(exposure): n_events = 10 centroids = Centroids( - lat=exposure_fixture.gdf.geometry.x, - lon=exposure_fixture.gdf.geometry.y, + lat=exposure.gdf.geometry.x, + lon=exposure.gdf.geometry.y, ) intensity = sparse.csr_matrix( - np.ones((n_events, exposure_fixture.gdf.shape[0])) * 50 + np.ones((n_events, exposure.gdf.shape[0])) * 50 ) # uniform intensity haz = Hazard() haz.event_id = np.arange(n_events) - haz.event_name = haz.event_id + haz.event_name = haz.event_id.tolist() haz.haz_type = "TC" haz.date = haz.event_id haz.frequency_unit = "m/s" @@ -89,24 +90,28 @@ def hazard_fixture(exposure_fixture): return haz -@pytest.fixture(autouse=True) -def hazard_forecast_fixture(hazard_fixture): - n_events = hazard_fixture.size +@pytest.fixture +def hazard_forecast(hazard): + n_events = hazard.size lead_time = pd.timedelta_range("1h", periods=n_events).to_numpy() - member = np.arange(10) + member = np.arange(n_events) haz_fc = HazardForecast.from_hazard( - hazard=hazard_fixture, + hazard=hazard, lead_time=lead_time, member=member, ) return haz_fc -@pytest.fixture(autouse=True) -def impact_func_set_fixture(exposure_fixture, hazard_fixture): +@pytest.fixture +def impact_func_set(exposure, hazard): step_impf = ImpactFunc() - step_impf.id = exposure_fixture.data[f"impf_{hazard_fixture.haz_type}"].unique()[0] - step_impf.haz_type = hazard_fixture.haz_type + step_impf.id = 1 + try: + step_impf.id = exposure.data[f"impf_{hazard.haz_type}"].unique()[0] + except IndexError: + pass + step_impf.haz_type = hazard.haz_type step_impf.name = "fixture step function" step_impf.intensity_unit = "" step_impf.intensity = np.array([0, 0.495, 0.4955, 0.5, 1, 10]) @@ -115,18 +120,12 @@ def impact_func_set_fixture(exposure_fixture, hazard_fixture): return ImpactFuncSet([step_impf]) -@pytest.fixture(autouse=True) -def impact_calc_fixture(exposure_fixture, hazard_fixture, impact_func_set_fixture): - imp_mat = np.ones( - ( - len(hazard_fixture.event_id), - exposure_fixture.gdf.shape[0], - exposure_fixture.gdf.shape[0], - ) - ) - aai_agg = np.sum(exposure_fixture.gdf["value"]) * hazard_fixture.frequency[0] - eai_exp = np.ones(exposure_fixture.gdf.shape[0]) * hazard_fixture.frequency[0] - at_event = np.ones(hazard_fixture.size) * np.sum(exposure_fixture.gdf["value"]) +@pytest.fixture +def impact_calc(exposure, hazard): + imp_mat = np.ones((len(hazard.event_id), exposure.gdf.shape[0])) + aai_agg = np.sum(exposure.gdf["value"]) * hazard.frequency[0] + eai_exp = np.ones(exposure.gdf.shape[0]) * hazard.frequency[0] + at_event = np.ones(hazard.size) * np.sum(exposure.gdf["value"]) return { "imp_mat": imp_mat, "aai_agg": aai_agg, @@ -135,17 +134,18 @@ def impact_calc_fixture(exposure_fixture, hazard_fixture, impact_func_set_fixtur } -def check_impact(self, imp, haz, exp, aai_agg, eai_exp, at_event, imp_mat_array=None): +def check_impact(imp, haz, exp, aai_agg, eai_exp, at_event, imp_mat_array=None): """Test properties of impacts""" - self.assertEqual(len(haz.event_id), len(imp.at_event)) - self.assertIsInstance(imp, Impact) + # NOTE: Correctly compares NaNs! + assert len(haz.event_id) == len(imp.at_event) + assert isinstance(imp, Impact) np.testing.assert_allclose(imp.coord_exp[:, 0], exp.latitude) np.testing.assert_allclose(imp.coord_exp[:, 1], exp.longitude) - self.assertAlmostEqual(imp.aai_agg, aai_agg, 3) + np.testing.assert_allclose(imp.aai_agg, aai_agg, rtol=1e-3) np.testing.assert_allclose(imp.eai_exp, eai_exp, rtol=1e-5) np.testing.assert_allclose(imp.at_event, at_event, rtol=1e-5) if imp_mat_array is not None: - np.testing.assert_allclose(imp.imp_mat.toarray().ravel(), imp_mat_array.ravel()) + np.testing.assert_allclose(imp.imp_mat.todense(), imp_mat_array) class TestImpactCalc(unittest.TestCase): @@ -389,7 +389,7 @@ def test_calc_impact_RF_pass(self): ] ) # fmt: on - check_impact(self, impact, haz, exp, aai_agg, eai_exp, at_event, imp_mat_array) + check_impact(impact, haz, exp, aai_agg, eai_exp, at_event, imp_mat_array) def test_empty_impact(self): """Check that empty impact is returned if no centroids match the exposures""" @@ -400,11 +400,11 @@ def test_empty_impact(self): aai_agg = 0.0 eai_exp = np.zeros(len(exp.gdf)) at_event = np.zeros(HAZ.size) - check_impact(self, impact, HAZ, exp, aai_agg, eai_exp, at_event, None) + check_impact(impact, HAZ, exp, aai_agg, eai_exp, at_event, None) impact = icalc.impact(save_mat=True, assign_centroids=False) imp_mat_array = sparse.csr_matrix((HAZ.size, len(exp.gdf))).toarray() - check_impact(self, impact, HAZ, exp, aai_agg, eai_exp, at_event, imp_mat_array) + check_impact(impact, HAZ, exp, aai_agg, eai_exp, at_event, imp_mat_array) def test_single_event_impact(self): """Check impact for single event""" @@ -414,11 +414,11 @@ def test_single_event_impact(self): aai_agg = 0.0 eai_exp = np.zeros(len(ENT.exposures.gdf)) at_event = np.array([0]) - check_impact(self, impact, haz, ENT.exposures, aai_agg, eai_exp, at_event, None) + check_impact(impact, haz, ENT.exposures, aai_agg, eai_exp, at_event, None) impact = icalc.impact(save_mat=True, assign_centroids=False) imp_mat_array = sparse.csr_matrix((haz.size, len(ENT.exposures.gdf))).toarray() check_impact( - self, impact, haz, ENT.exposures, aai_agg, eai_exp, at_event, imp_mat_array + impact, haz, ENT.exposures, aai_agg, eai_exp, at_event, imp_mat_array ) def test_calc_impact_save_mat_pass(self): @@ -692,70 +692,47 @@ def test_single_exp_zero_mdr(self): imp = ImpactCalc(exp, impf_set, haz).impact( assign_centroids=False, save_mat=True ) - check_impact(self, imp, haz, exp, aai_agg, eai_exp, at_event, at_event) + check_impact(imp, haz, exp, aai_agg, eai_exp, at_event, at_event) class TestImpactCalcForecast: """Test impact calc for forecast hazard""" - def test_impactForecast_type( + @pytest.fixture + def impact_calc_forecast(self, impact_calc): + """Write NaNs to attributes that are not used""" + impact_calc["aai_agg"] = np.full_like(impact_calc["aai_agg"], np.nan) + impact_calc["eai_exp"] = np.full_like(impact_calc["eai_exp"], np.nan) + + def test_impact_forecast( self, - exposure_fixture, - hazard_forecast_fixture, - impact_func_set_fixture, - impact_calc_fixture, + exposure, + hazard_forecast, + impact_func_set, + impact_calc, + impact_calc_forecast, ): """Test that ImpactForecast is returned correctly""" - impact = ImpactCalc( - exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture - ).impact(assign_centroids=True, save_mat=True) + impact = ImpactCalc(exposure, impact_func_set, hazard_forecast).impact( + assign_centroids=True, save_mat=True + ) # check that impact is indeed ImpactForecast + impact_calc["imp_mat_array"] = impact_calc.pop("imp_mat") + check_impact(imp=impact, haz=hazard_forecast, exp=exposure, **impact_calc) assert isinstance(impact, ImpactForecast) - np.testing.assert_array_equal( - impact.lead_time, hazard_forecast_fixture.lead_time - ) - assert impact.lead_time.dtype == hazard_forecast_fixture.lead_time.dtype - np.testing.assert_array_equal(impact.member, hazard_forecast_fixture.member) + np.testing.assert_array_equal(impact.lead_time, hazard_forecast.lead_time) + assert impact.lead_time.dtype == hazard_forecast.lead_time.dtype + np.testing.assert_array_equal(impact.member, hazard_forecast.member) def test_impact_forecast_empty_impmat_error( - self, hazard_forecast_fixture, exposure_fixture, impact_func_set_fixture + self, hazard_forecast, exposure, impact_func_set ): """Test that error is raised when trying to compute impact forecast without saving impact matrix """ - icalc = ImpactCalc( - exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture - ) - no_impmat_exception = ( - "Saving impact matrix is required when using HazardForecast." - "Please set save_mat=True." - ) - with pytest.raises(ValueError) as cm: + icalc = ImpactCalc(exposure, impact_func_set, hazard_forecast) + with pytest.raises(ValueError, match="Saving impact matrix is required"): icalc.impact(assign_centroids=True, save_mat=False) - assert no_impmat_exception == str(cm.value) - - def test_impact_forecast_blocked_nonsense_attrs( - self, hazard_forecast_fixture, exposure_fixture, impact_func_set_fixture - ): - """Test that nonsense attributes are blocked when computing impact forecast""" - lead_time = hazard_forecast_fixture.lead_time - member = hazard_forecast_fixture.member - - impact = ImpactCalc( - exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture - ).impact(assign_centroids=True, save_mat=True) - assert np.isnan(impact.aai_agg) - assert np.all(np.isnan(impact.eai_exp)) - assert impact.eai_exp.shape == (len(exposure_fixture.gdf),) - - # test that aai_agg and eai_exp are also nan when 0-size exp - empty_exp = exposure_fixture(n_exp=0) - impact_empty = ImpactCalc( - exposure_fixture, impact_func_set_fixture, hazard_forecast_fixture - ).impact(assign_centroids=True, save_mat=True) - assert np.isnan(impact_empty.aai_agg) - assert np.all(np.isnan(impact_empty.eai_exp)) - assert impact_empty.eai_exp.shape == (len(empty_exp.gdf),) class TestImpactMatrixCalc(unittest.TestCase): From 554cfc8cb56b9397fd80fb4ee0a7456f2e731ec9 Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:58:12 +0100 Subject: [PATCH 14/19] Review ImpactCalc forecast handling --- climada/engine/impact_calc.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/climada/engine/impact_calc.py b/climada/engine/impact_calc.py index e8ca9c0c4c..f58316bf56 100644 --- a/climada/engine/impact_calc.py +++ b/climada/engine/impact_calc.py @@ -233,11 +233,11 @@ def _return_impact(self, imp_mat_gen, save_mat): imp_mat, self.hazard.frequency ) if isinstance(self.hazard, HazardForecast): - eai_exp = np.nan * np.ones(eai_exp.shape, dtype=eai_exp.dtype) - aai_agg = np.nan * np.ones(aai_agg.shape, dtype=aai_agg.dtype) + eai_exp = np.full_like(eai_exp, np.nan, dtype=eai_exp.dtype) + aai_agg = np.full_like(aai_agg, np.nan, dtype=aai_agg.dtype) LOGGER.warning( "eai_exp and aai_agg are undefined with forecasts. " - "Setting them to empty arrays." + "Setting them to NaN arrays." ) else: @@ -256,8 +256,7 @@ def _return_impact(self, imp_mat_gen, save_mat): return ImpactForecast.from_impact( impact, self.hazard.lead_time, self.hazard.member ) - else: - return impact + return impact def _return_empty(self, save_mat): """ @@ -273,13 +272,14 @@ def _return_empty(self, save_mat): Impact or ImpactForecast Empty impact object with correct array sizes. """ + at_event = np.zeros(self.n_events) if isinstance(self.hazard, HazardForecast): - eai_exp = np.nan * np.ones(self.n_exp_pnt) + eai_exp = np.full(self.n_exp_pnt, np.nan) aai_agg = np.nan else: eai_exp = np.zeros(self.n_exp_pnt) aai_agg = 0.0 - at_event = np.zeros(self.n_events) + if save_mat: imp_mat = sparse.csr_matrix( (self.n_events, self.n_exp_pnt), dtype=np.float64 @@ -287,10 +287,11 @@ def _return_empty(self, save_mat): else: if isinstance(self.hazard, HazardForecast): raise ValueError( - "Saving impact matrix is required when using HazardForecast." + "Saving impact matrix is required when using HazardForecast. " "Please set save_mat=True." ) imp_mat = None + impact = Impact.from_eih( self.exposures, self.hazard, at_event, eai_exp, aai_agg, imp_mat ) @@ -298,8 +299,7 @@ def _return_empty(self, save_mat): return ImpactForecast.from_impact( impact, self.hazard.lead_time, self.hazard.member ) - else: - return impact + return impact def minimal_exp_gdf( self, impf_col, assign_centroids, ignore_cover, ignore_deductible From 30ed14d41a832a1794ff763d175db394d0ba6b76 Mon Sep 17 00:00:00 2001 From: luseverin Date: Tue, 9 Dec 2025 15:00:35 +0100 Subject: [PATCH 15/19] Block local_exceedance_impact --- climada/engine/impact_forecast.py | 21 +++++++++++++++++++++ climada/engine/test/test_impact_forecast.py | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/climada/engine/impact_forecast.py b/climada/engine/impact_forecast.py index d494882af5..e748840f21 100644 --- a/climada/engine/impact_forecast.py +++ b/climada/engine/impact_forecast.py @@ -98,3 +98,24 @@ def at_event(self, value): """Set the total exposure value close to a hazard""" LOGGER.warning("at_event for forecasts is not yet implemented.") self._at_event = value + + def local_exceedance_impact( + self, + return_periods=(25, 50, 100, 250), + method="interpolate", + min_impact=0, + log_frequency=True, + log_impact=True, + bin_decimals=None, + ): + """Compution of local exceedance impact for given return periods is not + implemented for ImpactForecast. See climada.engine.impact.Impact for details. + Returns + ------- + NotImplementedError + """ + + LOGGER.error("local_exceedance_impact is not defined for ImpactForecast") + raise NotImplementedError( + "local_exceedance_impact is not defined for ImpactForecast" + ) diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 0d421152c2..1657ff8a50 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -103,3 +103,9 @@ def test_impact_forecast_concat(impact_forecast, member): [impact_forecast, impact_forecast], reset_event_ids=True ) npt.assert_array_equal(impact_fc.member, np.concatenate([member, member])) + + +def test_impact_forecast_exceedance_freq_curve_error(impact_forecast): + """Check if ImpactForecast.exceedance_freq_curve raises NotImplementedError""" + with pytest.raises(NotImplementedError): + impact_forecast.local_exceedance_impact(np.array([10, 50, 100])) From f3ab44af752d43e1fb3df717e7cef3aa88ba7bad Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:14:27 +0100 Subject: [PATCH 16/19] Fix bug in test --- climada/engine/test/test_impact_calc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/engine/test/test_impact_calc.py b/climada/engine/test/test_impact_calc.py index f7dd2ecb75..dd69de7249 100644 --- a/climada/engine/test/test_impact_calc.py +++ b/climada/engine/test/test_impact_calc.py @@ -692,7 +692,7 @@ def test_single_exp_zero_mdr(self): imp = ImpactCalc(exp, impf_set, haz).impact( assign_centroids=False, save_mat=True ) - check_impact(imp, haz, exp, aai_agg, eai_exp, at_event, at_event) + check_impact(imp, haz, exp, aai_agg, eai_exp, at_event, np.array([at_event]).T) class TestImpactCalcForecast: From 9e4f2bb6b02645b747cf575de3d5186c58064b34 Mon Sep 17 00:00:00 2001 From: luseverin Date: Tue, 9 Dec 2025 15:47:15 +0100 Subject: [PATCH 17/19] Block return_period and exceedance_freq_curve --- climada/engine/impact_forecast.py | 32 +++++++++++++++++++++ climada/engine/test/test_impact_forecast.py | 8 +++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/climada/engine/impact_forecast.py b/climada/engine/impact_forecast.py index e748840f21..3d4dd337b8 100644 --- a/climada/engine/impact_forecast.py +++ b/climada/engine/impact_forecast.py @@ -119,3 +119,35 @@ def local_exceedance_impact( raise NotImplementedError( "local_exceedance_impact is not defined for ImpactForecast" ) + + def local_return_period( + self, + threshold_impact=(1000.0, 10000.0), + method="interpolate", + min_impact=0, + log_frequency=True, + log_impact=True, + bin_decimals=None, + ): + """Compution of local return period for given impact thresholds is not + implemented for ImpactForecast. See climada.engine.impact.Impact for details. + Returns + ------- + NotImplementedError + """ + + LOGGER.error("local_return_period is not defined for ImpactForecast") + raise NotImplementedError( + "local_return_period is not defined for ImpactForecast" + ) + + def calc_freq_curve(self, return_per=None): + """Computation of the impact exceedance frequency curve is not + implemented for ImpactForecast. See climada.engine.impact.Impact for details. + Returns + ------- + NotImplementedError + """ + + LOGGER.error("calc_freq_curve is not defined for ImpactForecast") + raise NotImplementedError("calc_freq_curve is not defined for ImpactForecast") diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 1657ff8a50..5d3902b5ae 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -105,7 +105,13 @@ def test_impact_forecast_concat(impact_forecast, member): npt.assert_array_equal(impact_fc.member, np.concatenate([member, member])) -def test_impact_forecast_exceedance_freq_curve_error(impact_forecast): +def test_impact_forecast_blocked_methods(impact_forecast): """Check if ImpactForecast.exceedance_freq_curve raises NotImplementedError""" with pytest.raises(NotImplementedError): impact_forecast.local_exceedance_impact(np.array([10, 50, 100])) + + with pytest.raises(NotImplementedError): + impact_forecast.local_return_period(np.array([10, 50, 100])) + + with pytest.raises(NotImplementedError): + impact_forecast.calc_freq_curve(np.array([10, 50, 100])) From 727357eda52390f74e5c46ba84054bab49204c7d Mon Sep 17 00:00:00 2001 From: luseverin Date: Tue, 9 Dec 2025 15:49:38 +0100 Subject: [PATCH 18/19] Log warning for at_event getter --- climada/engine/impact_forecast.py | 1 + 1 file changed, 1 insertion(+) diff --git a/climada/engine/impact_forecast.py b/climada/engine/impact_forecast.py index 3d4dd337b8..ead93fe38f 100644 --- a/climada/engine/impact_forecast.py +++ b/climada/engine/impact_forecast.py @@ -91,6 +91,7 @@ def from_impact( @property def at_event(self): + LOGGER.warning("at_event for forecasts is not yet implemented.") return self._at_event @at_event.setter From 31f0ca756ff92c536a78f5603d79b5ee5ae63c68 Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:03:40 +0100 Subject: [PATCH 19/19] Update docstrings --- climada/engine/impact_forecast.py | 40 +++++++++++++++------ climada/engine/test/test_impact_forecast.py | 2 +- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/climada/engine/impact_forecast.py b/climada/engine/impact_forecast.py index ead93fe38f..24b318d3d7 100644 --- a/climada/engine/impact_forecast.py +++ b/climada/engine/impact_forecast.py @@ -91,13 +91,16 @@ def from_impact( @property def at_event(self): - LOGGER.warning("at_event for forecasts is not yet implemented.") + """Get the total impact for each member/lead_time combination.""" + LOGGER.warning( + "at_event gives the total impact for one specific combination of member and " + "lead_time." + ) return self._at_event @at_event.setter def at_event(self, value): - """Set the total exposure value close to a hazard""" - LOGGER.warning("at_event for forecasts is not yet implemented.") + """Set the total impact for each member/lead_time combination.""" self._at_event = value def local_exceedance_impact( @@ -110,9 +113,14 @@ def local_exceedance_impact( bin_decimals=None, ): """Compution of local exceedance impact for given return periods is not - implemented for ImpactForecast. See climada.engine.impact.Impact for details. - Returns - ------- + implemented for ImpactForecast. + + See Also + -------- + See :py:meth:`~climada.engine.impact.Impact.local_exceedance_impact` + + Raises + ------ NotImplementedError """ @@ -131,8 +139,13 @@ def local_return_period( bin_decimals=None, ): """Compution of local return period for given impact thresholds is not - implemented for ImpactForecast. See climada.engine.impact.Impact for details. - Returns + implemented for ImpactForecast. + + See Also + -------- + See :py:meth:`~climada.engine.impact.Impact.local_return_period` + + Raises ------- NotImplementedError """ @@ -144,9 +157,14 @@ def local_return_period( def calc_freq_curve(self, return_per=None): """Computation of the impact exceedance frequency curve is not - implemented for ImpactForecast. See climada.engine.impact.Impact for details. - Returns - ------- + implemented for ImpactForecast. + + See Also + -------- + See :py:meth:`~climada.engine.impact.Impact.calc_freq_curve` + + Raises + ------ NotImplementedError """ diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 5d3902b5ae..e0e87927e1 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -106,7 +106,7 @@ def test_impact_forecast_concat(impact_forecast, member): def test_impact_forecast_blocked_methods(impact_forecast): - """Check if ImpactForecast.exceedance_freq_curve raises NotImplementedError""" + """Check if blocked methods raise NotImplementedError""" with pytest.raises(NotImplementedError): impact_forecast.local_exceedance_impact(np.array([10, 50, 100]))