From b7b6545c11cd6e624769464457ea145062b188a5 Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:40:17 +0100 Subject: [PATCH 1/8] Add ImpactForecast --- climada/engine/__init__.py | 1 + climada/engine/impact_forecast.py | 70 +++++++++++++++++++++ climada/engine/test/test_impact.py | 40 ++++++------ climada/engine/test/test_impact_forecast.py | 49 +++++++++++++++ 4 files changed, 142 insertions(+), 18 deletions(-) diff --git a/climada/engine/__init__.py b/climada/engine/__init__.py index ef8292f75d..1970f7706b 100755 --- a/climada/engine/__init__.py +++ b/climada/engine/__init__.py @@ -22,3 +22,4 @@ from .cost_benefit import * from .impact import * from .impact_calc import * +from .impact_forecast import ImpactForecast diff --git a/climada/engine/impact_forecast.py b/climada/engine/impact_forecast.py index eb69b01b5d..4ff5279d97 100644 --- a/climada/engine/impact_forecast.py +++ b/climada/engine/impact_forecast.py @@ -18,3 +18,73 @@ Define Forecast variant of Impact. """ + +import logging + +import numpy as np + +from ..util import log_level +from ..util.forecast import Forecast +from .impact import Impact + +LOGGER = logging.getLogger(__name__) + + +class ImpactForecast(Forecast, Impact): + """An impact object with forecast information""" + + def __init__( + self, + *, + lead_time: np.ndarray | None, + member: np.ndarray | None, + **impact_kwargs, + ): + """Initialize the impact forecast. + + Parameters + ---------- + lead_time : np.ndarray, optional + The lead time associated with each event entry + member : np.ndarray, optional + The ensemble member associated with each event entry + impact_kwargs + Keyword-arguments passed to ~:py:class`climada.engine.impact.Impact`. + """ + # TODO: Maybe assert array lengths? + super().__init__(lead_time, member, **impact_kwargs) + + @classmethod + def from_impact( + cls, impact: Impact, lead_time: np.ndarray | None, member: np.ndarray | None + ): + """Create an impact forecast from an impact object and forecast information. + + Parameters + ---------- + impact : climada.engine.impact.Impact + The impact object whose data to use in the forecast object + lead_time : np.ndarray, optional + The lead time associated with each event entry + member : np.ndarray, optional + The ensemble member associated with each event entry + """ + with log_level("WARNING", "climada.engine.impact"): + return cls( + lead_time=lead_time, + member=member, + event_id=impact.event_id, + event_name=impact.event_name, + date=impact.date, + frequency=impact.frequency, + frequency_unit=impact.frequency_unit, + coord_exp=impact.coord_exp, + crs=impact.crs, + eai_exp=impact.eai_exp, + at_event=impact.at_event, + tot_value=impact.tot_value, + aai_agg=impact.aai_agg, + unit=impact.unit, + imp_mat=impact.imp_mat, + haz_type=impact.haz_type, + ) diff --git a/climada/engine/test/test_impact.py b/climada/engine/test/test_impact.py index 38b3def3d8..f91fc2de98 100644 --- a/climada/engine/test/test_impact.py +++ b/climada/engine/test/test_impact.py @@ -47,26 +47,30 @@ STR_DT = h5py.special_dtype(vlen=str) -def dummy_impact(): - """Return an impact object for testing""" - return Impact( - event_id=np.arange(6) + 10, - event_name=[0, 1, "two", "three", 30, 31], - date=np.arange(6), - coord_exp=np.array([[1, 2], [1.5, 2.5]]), - crs=DEF_CRS, - eai_exp=np.array([7.2, 7.2]), - at_event=np.array([0, 2, 4, 6, 60, 62]), - frequency=np.array([1 / 6, 1 / 6, 1, 1, 1 / 30, 1 / 30]), - tot_value=7, - aai_agg=14.4, - unit="USD", - frequency_unit="1/month", - imp_mat=sparse.csr_matrix( +def impact_kwargs(): + return { + "event_id": np.arange(6) + 10, + "event_name": [0, 1, "two", "three", 30, 31], + "date": np.arange(6), + "coord_exp": np.array([[1, 2], [1.5, 2.5]]), + "crs": DEF_CRS, + "eai_exp": np.array([7.2, 7.2]), + "at_event": np.array([0, 2, 4, 6, 60, 62]), + "frequency": np.array([1 / 6, 1 / 6, 1, 1, 1 / 30, 1 / 30]), + "tot_value": 7, + "aai_agg": 14.4, + "unit": "USD", + "frequency_unit": "1/month", + "imp_mat": sparse.csr_matrix( np.array([[0, 0], [1, 1], [2, 2], [3, 3], [30, 30], [31, 31]]) ), - haz_type="TC", - ) + "haz_type": "TC", + } + + +def dummy_impact(): + """Return an impact object for testing""" + return Impact(**impact_kwargs()) def dummy_impact_yearly(): diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 8fe6fa72d4..2ba30d9f94 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -18,3 +18,52 @@ Tests for Impact Forecast. """ + +import numpy as np +import numpy.testing as npt +import pandas as pd +import pytest +from scipy.sparse import csr_matrix + +from climada.engine import Impact, ImpactForecast + +from .test_impact import impact_kwargs + + +@pytest.fixture +def impact(): + return Impact(**impact_kwargs()) + + +def assert_impact_kwargs(impact: Impact, **kwargs): + for key, value in kwargs.items(): + attr = getattr(impact, key) + if isinstance(value, (np.ndarray, list)): + npt.assert_array_equal(attr, value) + elif isinstance(value, csr_matrix): + npt.assert_array_equal(attr.todense(), value.todense()) + else: + assert attr == value + + +class TestImpactForecastInit: + lead_time = pd.date_range("2000-01-01", "2000-01-02", periods=6).to_numpy() + member = np.arange(6) + + def test_impact_forecast_init(self): + forecast1 = ImpactForecast( + lead_time=self.lead_time, + member=self.member, + **impact_kwargs(), + ) + npt.assert_array_equal(forecast1.lead_time, self.lead_time) + npt.assert_array_equal(forecast1.member, self.member) + assert_impact_kwargs(forecast1, **impact_kwargs()) + + def test_impact_forecast_from_impact(self, impact): + forecast = ImpactForecast.from_impact( + impact, lead_time=self.lead_time, member=self.member + ) + npt.assert_array_equal(forecast.lead_time, self.lead_time) + npt.assert_array_equal(forecast.member, self.member) + assert_impact_kwargs(forecast, **impact_kwargs()) From 3b077a0cb0cd6506d399083dd8b56f6f684ff4f1 Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:44:37 +0100 Subject: [PATCH 2/8] Add fixture for impact kwargs --- climada/engine/test/test_impact_forecast.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 2ba30d9f94..20f5420ae3 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -27,12 +27,17 @@ from climada.engine import Impact, ImpactForecast -from .test_impact import impact_kwargs +from .test_impact import impact_kwargs as imp_kwargs @pytest.fixture -def impact(): - return Impact(**impact_kwargs()) +def impact_kwargs(): + return imp_kwargs() + + +@pytest.fixture +def impact(impact_kwargs): + return Impact(**impact_kwargs) def assert_impact_kwargs(impact: Impact, **kwargs): @@ -50,20 +55,20 @@ class TestImpactForecastInit: lead_time = pd.date_range("2000-01-01", "2000-01-02", periods=6).to_numpy() member = np.arange(6) - def test_impact_forecast_init(self): + def test_impact_forecast_init(self, impact_kwargs): forecast1 = ImpactForecast( lead_time=self.lead_time, member=self.member, - **impact_kwargs(), + **impact_kwargs, ) npt.assert_array_equal(forecast1.lead_time, self.lead_time) npt.assert_array_equal(forecast1.member, self.member) - assert_impact_kwargs(forecast1, **impact_kwargs()) + assert_impact_kwargs(forecast1, **impact_kwargs) - def test_impact_forecast_from_impact(self, impact): + def test_impact_forecast_from_impact(self, impact, impact_kwargs): forecast = ImpactForecast.from_impact( impact, lead_time=self.lead_time, member=self.member ) npt.assert_array_equal(forecast.lead_time, self.lead_time) npt.assert_array_equal(forecast.member, self.member) - assert_impact_kwargs(forecast, **impact_kwargs()) + assert_impact_kwargs(forecast, **impact_kwargs) From 44bf09a4ad2316cd7be7ec0ea686f6b3381f284f Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:55:42 +0100 Subject: [PATCH 3/8] Use kwarg assignment in ImpactForecast.__init__ --- climada/engine/impact_forecast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/engine/impact_forecast.py b/climada/engine/impact_forecast.py index 4ff5279d97..d534e32ead 100644 --- a/climada/engine/impact_forecast.py +++ b/climada/engine/impact_forecast.py @@ -52,7 +52,7 @@ def __init__( Keyword-arguments passed to ~:py:class`climada.engine.impact.Impact`. """ # TODO: Maybe assert array lengths? - super().__init__(lead_time, member, **impact_kwargs) + super().__init__(lead_time=lead_time, member=member, **impact_kwargs) @classmethod def from_impact( From 347fcf1877f7e0b38977f6bc498e9cc68c8bb2ee Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:08:40 +0100 Subject: [PATCH 4/8] Add test for ImpactForecast.select --- climada/engine/test/test_impact_forecast.py | 73 +++++++++++++-------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 20f5420ae3..65cb72d389 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -40,35 +40,56 @@ def impact(impact_kwargs): return Impact(**impact_kwargs) -def assert_impact_kwargs(impact: Impact, **kwargs): - for key, value in kwargs.items(): - attr = getattr(impact, key) - if isinstance(value, (np.ndarray, list)): - npt.assert_array_equal(attr, value) - elif isinstance(value, csr_matrix): - npt.assert_array_equal(attr.todense(), value.todense()) - else: - assert attr == value +@pytest.fixture +def lead_time(): + return pd.date_range("2000-01-01", "2000-01-02", periods=6).to_numpy() -class TestImpactForecastInit: - lead_time = pd.date_range("2000-01-01", "2000-01-02", periods=6).to_numpy() - member = np.arange(6) +@pytest.fixture +def member(): + return np.arange(6) + + +@pytest.fixture +def impact_forecast(impact, lead_time, member): + return ImpactForecast.from_impact(impact, lead_time=lead_time, member=member) + - def test_impact_forecast_init(self, impact_kwargs): +class TestImpactForecastInit: + def assert_impact_kwargs(self, impact: Impact, **kwargs): + for key, value in kwargs.items(): + attr = getattr(impact, key) + if isinstance(value, (np.ndarray, list)): + npt.assert_array_equal(attr, value) + elif isinstance(value, csr_matrix): + npt.assert_array_equal(attr.todense(), value.todense()) + else: + assert attr == value + + def test_impact_forecast_init(self, impact_kwargs, lead_time, member): forecast1 = ImpactForecast( - lead_time=self.lead_time, - member=self.member, + lead_time=lead_time, + member=member, **impact_kwargs, ) - npt.assert_array_equal(forecast1.lead_time, self.lead_time) - npt.assert_array_equal(forecast1.member, self.member) - assert_impact_kwargs(forecast1, **impact_kwargs) - - def test_impact_forecast_from_impact(self, impact, impact_kwargs): - forecast = ImpactForecast.from_impact( - impact, lead_time=self.lead_time, member=self.member - ) - npt.assert_array_equal(forecast.lead_time, self.lead_time) - npt.assert_array_equal(forecast.member, self.member) - assert_impact_kwargs(forecast, **impact_kwargs) + npt.assert_array_equal(forecast1.lead_time, lead_time) + npt.assert_array_equal(forecast1.member, member) + self.assert_impact_kwargs(forecast1, **impact_kwargs) + + def test_impact_forecast_from_impact( + self, impact_forecast, impact_kwargs, lead_time, member + ): + npt.assert_array_equal(impact_forecast.lead_time, lead_time) + npt.assert_array_equal(impact_forecast.member, member) + self.assert_impact_kwargs(impact_forecast, **impact_kwargs) + + +def test_impact_forecast_select(impact_forecast, lead_time, member): + """Check if Impact.select works on the derived class""" + impact_fc = impact_forecast.select(event_ids=[12, 10]) + # NOTE: Events keep their original order + npt.assert_array_equal( + impact_fc.event_id, impact_forecast.event_id[np.array([0, 2])] + ) + npt.assert_array_equal(impact_fc.member, member[np.array([0, 2])]) + npt.assert_array_equal(impact_fc.lead_time, lead_time[np.array([0, 2])]) From cf86c860a725f463e42eb77702d5b0cfac29e8c8 Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:53:37 +0100 Subject: [PATCH 5/8] Add test stub for ImpactForecast.concat --- climada/engine/test/test_impact_forecast.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 65cb72d389..d432626e50 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -93,3 +93,12 @@ def test_impact_forecast_select(impact_forecast, lead_time, member): ) npt.assert_array_equal(impact_fc.member, member[np.array([0, 2])]) npt.assert_array_equal(impact_fc.lead_time, lead_time[np.array([0, 2])]) + + +@pytest.skip("Concat from base class does not work") +def test_impact_forecast_concat(impact_forecast, member): + """Check if Impact.concat works on the derived class""" + impact_fc = ImpactForecast.concat( + [impact_forecast, impact_forecast], reset_event_ids=True + ) + npt.assert_array_equal(impact_fc.member, np.concatenate([member, member])) From 3ab6354c2f739312c18e232ceb889ce5c5aecff1 Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:04:50 +0100 Subject: [PATCH 6/8] Use correct command for skipping a test --- climada/engine/test/test_impact_forecast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index d432626e50..25fa39d6b6 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -95,7 +95,7 @@ def test_impact_forecast_select(impact_forecast, lead_time, member): npt.assert_array_equal(impact_fc.lead_time, lead_time[np.array([0, 2])]) -@pytest.skip("Concat from base class does not work") +@pytest.mark.skip("Concat from base class does not work") def test_impact_forecast_concat(impact_forecast, member): """Check if Impact.concat works on the derived class""" impact_fc = ImpactForecast.concat( From 4d36a0de8152971c9868041a87b6fc2683165cff Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:12:22 +0100 Subject: [PATCH 7/8] Fix merge issue --- climada/engine/test/test_impact_forecast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 65cb72d389..754b9b4a90 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -42,7 +42,7 @@ def impact(impact_kwargs): @pytest.fixture def lead_time(): - return pd.date_range("2000-01-01", "2000-01-02", periods=6).to_numpy() + return pd.timedelta_range(start="1 day", periods=6).to_numpy() @pytest.fixture From 85b92ef4f358a98992c678aaaa54da6dbfd4b7bc Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:25:44 +0100 Subject: [PATCH 8/8] Make Impact.concat support ImpactForecast --- climada/engine/impact.py | 9 ++++++--- climada/engine/test/test_impact_forecast.py | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/climada/engine/impact.py b/climada/engine/impact.py index d8c944c7b9..32d3fa3219 100644 --- a/climada/engine/impact.py +++ b/climada/engine/impact.py @@ -2208,9 +2208,12 @@ def stack_attribute(attr_name: str) -> np.ndarray: imp_mat = sparse.vstack(imp_mats) # Concatenate other attributes - kwargs = { - attr: stack_attribute(attr) for attr in ("date", "frequency", "at_event") - } + concat_attrs = { + name.lstrip("_") # Private attributes with getter/setter + for name, value in first_imp.__dict__.items() + if isinstance(value, np.ndarray) + }.difference(("event_id", "coord_exp", "eai_exp", "aai_agg")) + kwargs = {attr: stack_attribute(attr) for attr in concat_attrs} # Get remaining attributes from first impact object in list return cls( diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 94655c8b17..f696d17d6a 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -202,13 +202,23 @@ def test_no_select(self, impact_forecast, impact_kwargs): assert imp_fc_select.imp_mat.shape == (0, num_centroids) -@pytest.mark.skip("Concat from base class does not work") -def test_impact_forecast_concat(impact_forecast, member): +def test_impact_forecast_concat(impact_forecast, member, lead_time): """Check if Impact.concat works on the derived class""" impact_fc = ImpactForecast.concat( [impact_forecast, impact_forecast], reset_event_ids=True ) npt.assert_array_equal(impact_fc.member, np.concatenate([member, member])) + npt.assert_array_equal(impact_fc.lead_time, np.concatenate([lead_time, lead_time])) + npt.assert_array_equal( + impact_fc.event_id, np.arange(impact_fc.imp_mat.shape[0]) + 1 + ) + npt.assert_array_equal(impact_fc.event_name, impact_forecast.event_name * 2) + npt.assert_array_equal( + impact_fc.imp_mat.toarray(), + np.vstack( + (impact_forecast.imp_mat.toarray(), impact_forecast.imp_mat.toarray()) + ), + ) def test_impact_forecast_blocked_methods(impact_forecast):