From fad5ec20a4bfb637c4b50d437052717467ea6877 Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:08:59 +0100 Subject: [PATCH 1/3] Add data shape check for ImpactForecast --- climada/engine/impact_forecast.py | 24 ++++++++++++++++++++- climada/engine/test/test_impact_forecast.py | 6 ++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/climada/engine/impact_forecast.py b/climada/engine/impact_forecast.py index d4afc551d4..87846a5831 100644 --- a/climada/engine/impact_forecast.py +++ b/climada/engine/impact_forecast.py @@ -51,8 +51,8 @@ def __init__( impact_kwargs Keyword-arguments passed to ~:py:class`climada.engine.impact.Impact`. """ - # TODO: Maybe assert array lengths? super().__init__(lead_time=lead_time, member=member, **impact_kwargs) + self._check_shapes() @classmethod def from_impact( @@ -88,3 +88,25 @@ def from_impact( imp_mat=impact.imp_mat, haz_type=impact.haz_type, ) + + def _check_shapes(self): + """Check shapes of forecast data vs. impact data. + + Raises + ------ + ValueError + If the shapes of the forecast data do not match the + :py:attr:`~climada.engine.impact.Impact.event_id` + """ + shape_expected = self.event_id.shape + + def check_forecast_attr(attr: str): + shape_actual = getattr(self, attr).shape + if shape_actual != shape_expected: + raise ValueError( + f"Shape mismatch between Forecast.{attr} " + f"{shape_actual} and Impact.event_id {shape_expected}" + ) + + check_forecast_attr("member") + check_forecast_attr("lead_time") diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index 0d421152c2..bb612669db 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -76,6 +76,12 @@ def test_impact_forecast_init(self, impact_kwargs, lead_time, member): npt.assert_array_equal(forecast1.member, member) self.assert_impact_kwargs(forecast1, **impact_kwargs) + def test_impact_forecast_init_error(self, impact_kwargs, lead_time, member): + with pytest.raises(ValueError, match="Forecast.lead_time"): + ImpactForecast(lead_time=lead_time[:-2], member=member, **impact_kwargs) + with pytest.raises(ValueError, match="Forecast.member"): + ImpactForecast(lead_time=lead_time, member=member[1:], **impact_kwargs) + def test_impact_forecast_from_impact( self, impact_forecast, impact_kwargs, lead_time, member ): From 29ae24e76fb37e97375bee7feb657b012e0268df Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:42:27 +0100 Subject: [PATCH 2/3] Start implementing shape check --- climada/util/forecast.py | 19 ++++++++++++++++++ climada/util/test/test_forecast.py | 32 +++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/climada/util/forecast.py b/climada/util/forecast.py index eb7cc7fc14..4b229859a2 100644 --- a/climada/util/forecast.py +++ b/climada/util/forecast.py @@ -19,9 +19,28 @@ Define Forecast base class. """ +from typing import Any + import numpy as np +def check_attribute_shapes(obj_act: Any, attr_act: str, obj_exp: Any, attr_exp: str): + """Compare the shapes of attributes of two objects. + + Raises + ------ + ValueError + If the shapes do not match + """ + shape_actual = getattr(obj_act, attr_act).shape + shape_expected = getattr(obj_exp, attr_exp).shape + if shape_actual != shape_expected: + raise ValueError( + f"Shape mismatch between {type(obj_act).__name__}.{attr_act} " + f"{shape_actual} and {type(obj_exp).__name__}.{attr_exp} {shape_expected}" + ) + + class Forecast: """Mixin class for forecast data. diff --git a/climada/util/test/test_forecast.py b/climada/util/test/test_forecast.py index 54d11e6622..4f4fb393df 100644 --- a/climada/util/test/test_forecast.py +++ b/climada/util/test/test_forecast.py @@ -22,8 +22,9 @@ import numpy as np import numpy.testing as npt import pandas as pd +import pytest -from climada.util.forecast import Forecast +from climada.util.forecast import Forecast, check_attribute_shapes def test_forecast_init(): @@ -50,3 +51,32 @@ def test_forecast_init(): forecast = Forecast(lead_time=lead_times_seconds, member=[1, 2, 3]) npt.assert_array_equal(forecast.lead_time, lead_times_seconds, strict=True) assert forecast.lead_time.dtype == np.dtype("timedelta64[ns]") + + +class A: + foo = np.array([[0, 1], [1, 0]]) + + +class B: + bar = np.array([[1, 1], [1, 1]]) + + +class TestCheckCompareShapes: + @pytest.fixture + def a(self): + return A() + + @pytest.fixture + def b(self): + return B() + + def test_pass(self, a, b): + check_attribute_shapes(a, "foo", b, "bar") + + def test_error(self, a, b): + a.foo = np.array([0, 1]) + with pytest.raises(ValueError, match=r"A.foo \(2\,\)"): + check_attribute_shapes(a, "foo", b, "bar") + b.bar = np.array([0, 1, 2]) + with pytest.raises(ValueError, match=r"B.bar \(3\,\)"): + check_attribute_shapes(a, "foo", b, "bar") From 21f7cd7930decb81a32dc5b8da083c0259c1684d Mon Sep 17 00:00:00 2001 From: Lukas Riedel <34276446+peanutfun@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:46:18 +0100 Subject: [PATCH 3/3] Add shape checks to HazardForecast, ImpactForecast --- climada/engine/impact_forecast.py | 24 ++++++---------- climada/engine/test/test_impact_forecast.py | 14 +++++---- climada/hazard/forecast.py | 19 ++++++++++-- climada/hazard/test/test_forecast.py | 15 +++++++--- climada/util/forecast.py | 19 ------------ climada/util/test/test_forecast.py | 32 +-------------------- 6 files changed, 45 insertions(+), 78 deletions(-) diff --git a/climada/engine/impact_forecast.py b/climada/engine/impact_forecast.py index 87846a5831..6b18c61659 100644 --- a/climada/engine/impact_forecast.py +++ b/climada/engine/impact_forecast.py @@ -24,6 +24,7 @@ import numpy as np from ..util import log_level +from ..util.checker import size from ..util.forecast import Forecast from .impact import Impact @@ -52,7 +53,7 @@ def __init__( Keyword-arguments passed to ~:py:class`climada.engine.impact.Impact`. """ super().__init__(lead_time=lead_time, member=member, **impact_kwargs) - self._check_shapes() + self._check_sizes() @classmethod def from_impact( @@ -89,24 +90,15 @@ def from_impact( haz_type=impact.haz_type, ) - def _check_shapes(self): - """Check shapes of forecast data vs. impact data. + def _check_sizes(self): + """Check sizes of forecast data vs. impact data. Raises ------ ValueError - If the shapes of the forecast data do not match the + If the sizes of the forecast data do not match the :py:attr:`~climada.engine.impact.Impact.event_id` """ - shape_expected = self.event_id.shape - - def check_forecast_attr(attr: str): - shape_actual = getattr(self, attr).shape - if shape_actual != shape_expected: - raise ValueError( - f"Shape mismatch between Forecast.{attr} " - f"{shape_actual} and Impact.event_id {shape_expected}" - ) - - check_forecast_attr("member") - check_forecast_attr("lead_time") + num_entries = len(self.event_id) + size(exp_len=num_entries, var=self.member, var_name="Forecast.member") + size(exp_len=num_entries, var=self.lead_time, var_name="Forecast.lead_time") diff --git a/climada/engine/test/test_impact_forecast.py b/climada/engine/test/test_impact_forecast.py index bb612669db..6ada17777c 100644 --- a/climada/engine/test/test_impact_forecast.py +++ b/climada/engine/test/test_impact_forecast.py @@ -41,13 +41,15 @@ def impact(impact_kwargs): @pytest.fixture -def lead_time(): - return pd.timedelta_range(start="1 day", periods=6).to_numpy() +def lead_time(impact_kwargs): + return pd.timedelta_range( + start="1 day", periods=len(impact_kwargs["event_id"]) + ).to_numpy() @pytest.fixture -def member(): - return np.arange(6) +def member(impact_kwargs): + return np.arange(len(impact_kwargs["event_id"])) @pytest.fixture @@ -76,11 +78,11 @@ def test_impact_forecast_init(self, impact_kwargs, lead_time, member): npt.assert_array_equal(forecast1.member, member) self.assert_impact_kwargs(forecast1, **impact_kwargs) - def test_impact_forecast_init_error(self, impact_kwargs, lead_time, member): + def test_impact_forecast_init_error(self, impact, impact_kwargs, lead_time, member): with pytest.raises(ValueError, match="Forecast.lead_time"): ImpactForecast(lead_time=lead_time[:-2], member=member, **impact_kwargs) with pytest.raises(ValueError, match="Forecast.member"): - ImpactForecast(lead_time=lead_time, member=member[1:], **impact_kwargs) + ImpactForecast.from_impact(impact, lead_time=lead_time, member=member[1:]) def test_impact_forecast_from_impact( self, impact_forecast, impact_kwargs, lead_time, member diff --git a/climada/hazard/forecast.py b/climada/hazard/forecast.py index c2705134a0..5130e66af1 100644 --- a/climada/hazard/forecast.py +++ b/climada/hazard/forecast.py @@ -23,8 +23,9 @@ import numpy as np -from climada.hazard.base import Hazard -from climada.util.forecast import Forecast +from ..util.checker import size +from ..util.forecast import Forecast +from .base import Hazard LOGGER = logging.getLogger(__name__) @@ -52,6 +53,7 @@ def __init__( py:meth`~climada.hazard.base.Hazard.__init__` for details. """ super().__init__(lead_time=lead_time, member=member, **hazard_kwargs) + self._check_sizes() @classmethod def from_hazard(cls, hazard: Hazard, lead_time: np.ndarray, member: np.ndarray): @@ -89,3 +91,16 @@ def from_hazard(cls, hazard: Hazard, lead_time: np.ndarray, member: np.ndarray): intensity=hazard.intensity, fraction=hazard.fraction, ) + + def _check_sizes(self): + """Check sizes of forecast data vs. hazard data. + + Raises + ------ + ValueError + If the sizes of the forecast data do not match the + :py:attr:`~climada.hazard.base.Hazard.event_id` + """ + num_entries = len(self.event_id) + size(exp_len=num_entries, var=self.member, var_name="Forecast.member") + size(exp_len=num_entries, var=self.lead_time, var_name="Forecast.lead_time") diff --git a/climada/hazard/test/test_forecast.py b/climada/hazard/test/test_forecast.py index 646ccaa0cf..54cc37a4e1 100644 --- a/climada/hazard/test/test_forecast.py +++ b/climada/hazard/test/test_forecast.py @@ -41,13 +41,13 @@ def hazard(haz_kwargs): @pytest.fixture -def lead_time(): - return pd.timedelta_range("1h", periods=6).to_numpy() +def lead_time(haz_kwargs): + return pd.timedelta_range("1h", periods=len(haz_kwargs["event_id"])).to_numpy() @pytest.fixture -def member(): - return np.arange(6) +def member(haz_kwargs): + return np.arange(len(haz_kwargs["event_id"])) @pytest.fixture @@ -78,6 +78,13 @@ def test_init_hazard_forecast(haz_fc, member, lead_time, haz_kwargs): assert_hazard_kwargs(haz_fc, **haz_kwargs) +def test_init_hazard_forecast_error(hazard, member, lead_time, haz_kwargs): + with pytest.raises(ValueError, match="Forecast.lead_time"): + HazardForecast(lead_time=lead_time[:-2], member=member, **haz_kwargs) + with pytest.raises(ValueError, match="Forecast.member"): + HazardForecast.from_hazard(hazard, lead_time=lead_time, member=member[1:]) + + def test_from_hazard(lead_time, member, hazard, haz_kwargs): haz_fc_from_haz = HazardForecast.from_hazard( hazard, lead_time=lead_time, member=member diff --git a/climada/util/forecast.py b/climada/util/forecast.py index 4b229859a2..eb7cc7fc14 100644 --- a/climada/util/forecast.py +++ b/climada/util/forecast.py @@ -19,28 +19,9 @@ Define Forecast base class. """ -from typing import Any - import numpy as np -def check_attribute_shapes(obj_act: Any, attr_act: str, obj_exp: Any, attr_exp: str): - """Compare the shapes of attributes of two objects. - - Raises - ------ - ValueError - If the shapes do not match - """ - shape_actual = getattr(obj_act, attr_act).shape - shape_expected = getattr(obj_exp, attr_exp).shape - if shape_actual != shape_expected: - raise ValueError( - f"Shape mismatch between {type(obj_act).__name__}.{attr_act} " - f"{shape_actual} and {type(obj_exp).__name__}.{attr_exp} {shape_expected}" - ) - - class Forecast: """Mixin class for forecast data. diff --git a/climada/util/test/test_forecast.py b/climada/util/test/test_forecast.py index 4f4fb393df..54d11e6622 100644 --- a/climada/util/test/test_forecast.py +++ b/climada/util/test/test_forecast.py @@ -22,9 +22,8 @@ import numpy as np import numpy.testing as npt import pandas as pd -import pytest -from climada.util.forecast import Forecast, check_attribute_shapes +from climada.util.forecast import Forecast def test_forecast_init(): @@ -51,32 +50,3 @@ def test_forecast_init(): forecast = Forecast(lead_time=lead_times_seconds, member=[1, 2, 3]) npt.assert_array_equal(forecast.lead_time, lead_times_seconds, strict=True) assert forecast.lead_time.dtype == np.dtype("timedelta64[ns]") - - -class A: - foo = np.array([[0, 1], [1, 0]]) - - -class B: - bar = np.array([[1, 1], [1, 1]]) - - -class TestCheckCompareShapes: - @pytest.fixture - def a(self): - return A() - - @pytest.fixture - def b(self): - return B() - - def test_pass(self, a, b): - check_attribute_shapes(a, "foo", b, "bar") - - def test_error(self, a, b): - a.foo = np.array([0, 1]) - with pytest.raises(ValueError, match=r"A.foo \(2\,\)"): - check_attribute_shapes(a, "foo", b, "bar") - b.bar = np.array([0, 1, 2]) - with pytest.raises(ValueError, match=r"B.bar \(3\,\)"): - check_attribute_shapes(a, "foo", b, "bar")