From a0553263cea372772d80dcf2b398f9f80680805d Mon Sep 17 00:00:00 2001 From: MatthiasHauthDeltares <113418841+MatthiasHauthDeltares@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:12:07 +0100 Subject: [PATCH] fix bug for adaptation option with no construction cost (#627) --- .../analysis/adaptation/adaptation_option.py | 53 ++++++++--------- tests/analysis/adaptation/conftest.py | 7 +-- tests/analysis/adaptation/test_adaptation.py | 2 +- .../adaptation/test_adaptation_option.py | 58 ++++++++++++++----- .../test_adaptation_option_collection.py | 5 +- 5 files changed, 76 insertions(+), 49 deletions(-) diff --git a/ra2ce/analysis/adaptation/adaptation_option.py b/ra2ce/analysis/adaptation/adaptation_option.py index 607b8b40c..5f0c68a75 100644 --- a/ra2ce/analysis/adaptation/adaptation_option.py +++ b/ra2ce/analysis/adaptation/adaptation_option.py @@ -108,36 +108,29 @@ def calculate_unit_cost(self, time_horizon: float, discount_rate: float) -> floa float: The net present value unit cost of the adaptation option. """ - def calc_years(from_year: float, to_year: float, interval: float) -> range: - return range( - round(from_year), - round(min(to_year, time_horizon)), - round(interval), - ) - - def calc_cost(cost: float, year: float) -> float: - return cost * (1 - discount_rate) ** year - - _constr_years = calc_years( - 0, - time_horizon, - self.construction_interval, - ) - _lifetime_cost = 0.0 - for _constr_year in _constr_years: - # Calculate the present value of the construction cost - _lifetime_cost += calc_cost(self.construction_cost, _constr_year) - - # Calculate the present value of the maintenance cost - _maint_years = calc_years( - _constr_year + self.maintenance_interval, - _constr_year + self.construction_interval, - self.maintenance_interval, - ) - for _maint_year in _maint_years: - _lifetime_cost += calc_cost(self.maintenance_cost, _maint_year) - - return _lifetime_cost + def is_constr_year(year: float) -> bool: + if self.construction_interval == 0: + return False + return (round(year) % round(self.construction_interval)) == 0 + + def is_maint_year(year: float) -> bool: + if self.maintenance_interval == 0: + return False + if self.construction_interval > 0: + # Take year relative to last construction year + year = round(year) % round(self.construction_interval) + return (year % round(self.maintenance_interval)) == 0 + + def calculate_cost(year) -> float: + if is_constr_year(year): + _cost = self.construction_cost + elif is_maint_year(year): + _cost = self.maintenance_cost + else: + return 0.0 + return _cost / (1 + discount_rate) ** year + + return sum(calculate_cost(_year) for _year in range(0, round(time_horizon), 1)) def calculate_impact(self, benefit_graph: GeoDataFrame) -> GeoDataFrame: """ diff --git a/tests/analysis/adaptation/conftest.py b/tests/analysis/adaptation/conftest.py index ff312a607..02c739442 100644 --- a/tests/analysis/adaptation/conftest.py +++ b/tests/analysis/adaptation/conftest.py @@ -64,10 +64,9 @@ class AdaptationOptionCases: maintenance_interval=3.0, ), ] - unit_cost: list[float] = [0.0, 2693.684211, 5231.908660] - total_cost: list[float] = [0.0, 97411702.122141, 189201512.873560] - cases: list[tuple[AnalysisSectionAdaptationOption, float, float]] = list( - zip(config_cases, unit_cost, total_cost) + total_cost: list[float] = [0.0, 97800589.027952, 189253296.099491] + cases: list[tuple[AnalysisSectionAdaptationOption, float]] = list( + zip(config_cases, total_cost) ) diff --git a/tests/analysis/adaptation/test_adaptation.py b/tests/analysis/adaptation/test_adaptation.py index 96c72c269..8978f714b 100644 --- a/tests/analysis/adaptation/test_adaptation.py +++ b/tests/analysis/adaptation/test_adaptation.py @@ -36,7 +36,7 @@ def test_run_cost_returns_gdf( f"{_option.id}_cost" in _cost_gdf.columns for _option in _adaptation.adaptation_collection.adaptation_options ) - for _option, _, _total_cost in AdaptationOptionCases.cases[1:]: + for _option, _total_cost in AdaptationOptionCases.cases[1:]: assert _cost_gdf[f"{_option.id}_cost"].sum(axis=0) == pytest.approx( _total_cost ) diff --git a/tests/analysis/adaptation/test_adaptation_option.py b/tests/analysis/adaptation/test_adaptation_option.py index f79089930..d8fb6fc91 100644 --- a/tests/analysis/adaptation/test_adaptation_option.py +++ b/tests/analysis/adaptation/test_adaptation_option.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + import pytest from ra2ce.analysis.adaptation.adaptation_option import AdaptationOption @@ -12,7 +14,6 @@ from ra2ce.analysis.damages.damages import Damages from ra2ce.analysis.losses.multi_link_losses import MultiLinkLosses from ra2ce.analysis.losses.single_link_losses import SingleLinkLosses -from tests.analysis.adaptation.conftest import AdaptationOptionCases class TestAdaptationOption: @@ -65,25 +66,56 @@ def test_from_config_no_damages_losses_raises(self): ) @pytest.mark.parametrize( - "adaptation_option", - AdaptationOptionCases.cases, + "constr_cost, constr_interval, maint_cost, maint_interval, net_unit_cost", + [ + pytest.param(0.0, 0.0, 0.0, 0.0, 0.0, id="Zero costs and intervals"), + pytest.param( + 1000.0, 10.0, 200.0, 3.0, 2704.437935, id="Cheap constr, exp maint" + ), + pytest.param( + 5000.0, 100.0, 50.0, 3.0, 5233.340599, id="Exp constr, cheap maint" + ), + pytest.param( + 0.0, 0.0, 1100.0, 1.0, 17576.780477, id="Zero constr cost and interval" + ), + pytest.param( + 1000.0, 100.0, 0.0, 0.0, 1000.0, id="Zero maint cost and interval" + ), + pytest.param( + 1000.0, 8.0, 100.0, 2.0, 3053.742434, id="Coinciding intervals" + ), + ], ) def test_calculate_unit_cost_returns_float( self, - valid_adaptation_config: tuple[AnalysisInputWrapper, AnalysisConfigWrapper], - adaptation_option: tuple[AnalysisSectionAdaptation, float, float], + constr_cost: float, + constr_interval: float, + maint_cost: float, + maint_interval: float, + net_unit_cost: float, ): + # Mock to avoid complex setup. + @dataclass + class MockAdaptationOption(AdaptationOption): + id: str + # 1. Define test data. - _option = AdaptationOption.from_config( - analysis_config=valid_adaptation_config[1], - adaptation_option=adaptation_option[0], + _option = MockAdaptationOption( + id="AnOption", + name=None, + construction_cost=constr_cost, + construction_interval=constr_interval, + maintenance_cost=maint_cost, + maintenance_interval=maint_interval, + analyses=[], + analysis_config=None, ) - _time_horizon = valid_adaptation_config[1].config_data.adaptation.time_horizon - _discount_rate = valid_adaptation_config[1].config_data.adaptation.discount_rate + _time_horizon = 20.0 + _discount_rate = 0.025 # 2. Run test. - _cost = _option.calculate_unit_cost(_time_horizon, _discount_rate) + _result = _option.calculate_unit_cost(_time_horizon, _discount_rate) # 3. Verify expectations. - assert isinstance(_cost, float) - assert _cost == pytest.approx(adaptation_option[1]) + assert isinstance(_result, float) + assert _result == pytest.approx(net_unit_cost) diff --git a/tests/analysis/adaptation/test_adaptation_option_collection.py b/tests/analysis/adaptation/test_adaptation_option_collection.py index b6aa381dc..2f1c0f18f 100644 --- a/tests/analysis/adaptation/test_adaptation_option_collection.py +++ b/tests/analysis/adaptation/test_adaptation_option_collection.py @@ -29,7 +29,10 @@ def test_from_config( assert isinstance(_collection.reference_option, AdaptationOption) assert _collection.reference_option.id == "AO0" - assert len(_collection.adaptation_options) == 2 + assert len(_collection.all_options) == len( + valid_adaptation_config[1].config_data.adaptation.adaptation_options + ) + assert all( isinstance(x, AdaptationOption) for x in _collection.adaptation_options )