diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f1873e0e..87cf4b6a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.9.34 +current_version = 3.9.35 commit = True tag = True diff --git a/.cookiecutterrc b/.cookiecutterrc index 2fc1b059..8551f96d 100644 --- a/.cookiecutterrc +++ b/.cookiecutterrc @@ -54,7 +54,7 @@ default_context: sphinx_doctest: "no" sphinx_theme: "sphinx-py3doc-enhanced-theme" test_matrix_separate_coverage: "no" - version: 3.9.34 + version: 3.9.35 version_manager: "bump2version" website: "https://github.com/NREL" year_from: "2023" diff --git a/README.rst b/README.rst index 179525fd..123c073d 100644 --- a/README.rst +++ b/README.rst @@ -58,9 +58,9 @@ Free software: `MIT license `__ :alt: Supported implementations :target: https://pypi.org/project/geophires-x -.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.34.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/softwareengineerprogrammer/GEOPHIRES-X/v3.9.35.svg :alt: Commits since latest release - :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.34...main + :target: https://github.com/softwareengineerprogrammer/GEOPHIRES-X/compare/v3.9.35...main .. |docs| image:: https://readthedocs.org/projects/GEOPHIRES-X/badge/?style=flat :target: https://nrel.github.io/GEOPHIRES-X diff --git a/docs/conf.py b/docs/conf.py index 8bf57f05..78f86b8b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ year = '2025' author = 'NREL' copyright = f'{year}, {author}' -version = release = '3.9.34' +version = release = '3.9.35' pygments_style = 'trac' templates_path = ['./templates'] diff --git a/setup.py b/setup.py index c87a3d1e..c1a69134 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name='geophires-x', - version='3.9.34', + version='3.9.35', license='MIT', description='GEOPHIRES is a free and open-source geothermal techno-economic simulator.', long_description='{}\n{}'.format( diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 47128415..1571c13c 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -586,7 +586,7 @@ def __init__(self, model: Model): CurrentUnits=CurrencyUnit.MDOLLARS, Provided=False, Valid=False, - ToolTipText="Total reservoir stimulation capital cost, including contingency and indirect costs." + ToolTipText="Total reservoir stimulation capital cost, including indirect costs and contingency." ) max_stimulation_cost_per_well_MUSD = 100 @@ -1044,6 +1044,21 @@ def __init__(self, model: Model): ErrMessage="assume default inflation rate during construction (0)", ToolTipText='For SAM Economic Models, this value is treated as an indirect EPC capital cost percentage.' ) + + self.contingency_percentage = self.ParameterDict[self.contingency_percentage.Name] = floatParameter( + 'Contingency Percentage', + DefaultValue=15., + Min=0., + Max=100., + UnitType=Units.PERCENT, + PreferredUnits=PercentUnit.PERCENT, + CurrentUnits=PercentUnit.PERCENT, + ToolTipText='The contingency percentage applied to the direct capital costs for stimulation, ' + 'field gathering system, exploration, and surface plant. ' + '(Note: well drilling and completion costs do not have contingency applied and are not ' + 'affected by this parameter.)' + ) + self.wellcorrelation = self.ParameterDict[self.wellcorrelation.Name] = intParameter( "Well Drilling Cost Correlation", DefaultValue=WellDrillingCostCorrelation.VERTICAL_LARGE_INT1.int_value, @@ -2358,45 +2373,7 @@ def Calculate(self, model: Model) -> None: # capital costs self.calculate_wellfield_costs(model) self.Cstim.value = self.calculate_stimulation_costs(model).to(self.Cstim.CurrentUnits).magnitude - - # field gathering system costs (M$) - if self.ccgathfixed.Valid: - self.Cgath.value = self.ccgathfixed.value - else: - self.Cgath.value = self.ccgathadjfactor.value * 50 - 6 * np.max( - model.surfaceplant.HeatExtracted.value) * 1000. # (GEOPHIRES v1 correlation) - if model.wellbores.impedancemodelused.value: - pumphp = np.max(model.wellbores.PumpingPower.value) * 1341 - numberofpumps = np.ceil(pumphp / 2000) # pump can be maximum 2,000 hp - if numberofpumps == 0: - self.Cpumps = 0.0 - else: - pumphpcorrected = pumphp / numberofpumps - self.Cpumps = numberofpumps * 1.5 * ( - (1750 * pumphpcorrected ** 0.7) * 3 * pumphpcorrected ** (-0.11)) - else: - if model.wellbores.productionwellpumping.value: - prodpumphp = np.max(model.wellbores.PumpingPowerProd.value) / model.wellbores.nprod.value * 1341 - Cpumpsprod = model.wellbores.nprod.value * 1.5 * (1750 * prodpumphp ** 0.7 + 5750 * - prodpumphp ** 0.2 + 10000 + np.max( - model.wellbores.pumpdepth.value) * 50 * 3.281) # see page 46 in user's manual assuming rental of rig for 1 day. - else: - Cpumpsprod = 0 - - injpumphp = np.max(model.wellbores.PumpingPowerInj.value) * 1341 - numberofinjpumps = np.ceil(injpumphp / 2000) # pump can be maximum 2,000 hp - if numberofinjpumps == 0: - Cpumpsinj = 0 - else: - injpumphpcorrected = injpumphp / numberofinjpumps - Cpumpsinj = numberofinjpumps * 1.5 * ( - 1750 * injpumphpcorrected ** 0.7) * 3 * injpumphpcorrected ** (-0.11) - self.Cpumps = Cpumpsinj + Cpumpsprod - - # Based on GETEM 2016: 1.15 for 15% contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 - self.Cgath.value = 1.15 * self.ccgathadjfactor.value * self._indirect_cost_factor * ( - (model.wellbores.nprod.value + model.wellbores.ninj.value) * 750 * 500. + self.Cpumps) / 1E6 - + self.calculate_field_gathering_costs(model) self.calculate_plant_costs(model) if not self.totalcapcost.Valid: @@ -2404,8 +2381,8 @@ def Calculate(self, model: Model) -> None: if self.ccexplfixed.Valid: self.Cexpl.value = self.ccexplfixed.value else: - self.Cexpl.value = 1.15 * self.ccexpladjfactor.value * self._indirect_cost_factor * ( - 1. + self.cost_one_production_well.value * 0.6) # 1.15 for 15% contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 + self.Cexpl.value = self._contingency_factor * self.ccexpladjfactor.value * self._indirect_cost_factor * ( + 1. + self.cost_one_production_well.value * 0.6) # Surface Piping Length Costs (M$) #assumed $750k/km self.Cpiping.value = 750 / 1000 * model.surfaceplant.piping_length.value @@ -2673,6 +2650,10 @@ def _wellfield_indirect_cost_factor(self) -> float: def _stimulation_indirect_cost_factor(self) -> float: return 1 + self.stimulation_indirect_capital_cost_percentage.quantity().to('dimensionless').magnitude + @property + def _contingency_factor(self) -> float: + return 1 + self.contingency_percentage.quantity().to('dimensionless').magnitude + def calculate_wellfield_costs(self, model: Model) -> None: if self.per_production_well_cost.Valid: self.cost_one_production_well.value = self.per_production_well_cost.value @@ -2765,11 +2746,49 @@ def calculate_stimulation_costs(self, model: Model) -> PlainQuantity: ) * self.ccstimadjfactor.value * self._stimulation_indirect_cost_factor - * 1.15 # 15% contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 + * self._contingency_factor ) return quantity(stimulation_costs, self.Cstim.CurrentUnits) + def calculate_field_gathering_costs(self, model: Model) -> None: + if self.ccgathfixed.Valid: + self.Cgath.value = self.ccgathfixed.value + else: + self.Cgath.value = self.ccgathadjfactor.value * 50 - 6 * np.max( + model.surfaceplant.HeatExtracted.value) * 1000. # (GEOPHIRES v1 correlation) + if model.wellbores.impedancemodelused.value: + pumphp = np.max(model.wellbores.PumpingPower.value) * 1341 + numberofpumps = np.ceil(pumphp / 2000) # pump can be maximum 2,000 hp + if numberofpumps == 0: + self.Cpumps = 0.0 + else: + pumphpcorrected = pumphp / numberofpumps + self.Cpumps = numberofpumps * 1.5 * ( + (1750 * pumphpcorrected ** 0.7) * 3 * pumphpcorrected ** (-0.11)) + else: + if model.wellbores.productionwellpumping.value: + prodpumphp = np.max(model.wellbores.PumpingPowerProd.value) / model.wellbores.nprod.value * 1341 + Cpumpsprod = model.wellbores.nprod.value * 1.5 * (1750 * prodpumphp ** 0.7 + 5750 * + prodpumphp ** 0.2 + 10000 + np.max( + model.wellbores.pumpdepth.value) * 50 * 3.281) # see page 46 in user's manual assuming rental of rig for 1 day. + else: + Cpumpsprod = 0 + + injpumphp = np.max(model.wellbores.PumpingPowerInj.value) * 1341 + numberofinjpumps = np.ceil(injpumphp / 2000) # pump can be maximum 2,000 hp + if numberofinjpumps == 0: + Cpumpsinj = 0 + else: + injpumphpcorrected = injpumphp / numberofinjpumps + Cpumpsinj = numberofinjpumps * 1.5 * ( + 1750 * injpumphpcorrected ** 0.7) * 3 * injpumphpcorrected ** (-0.11) + self.Cpumps = Cpumpsinj + Cpumpsprod + + # Based on GETEM 2016 + self.Cgath.value = self._contingency_factor * self.ccgathadjfactor.value * self._indirect_cost_factor * ( + (model.wellbores.nprod.value + model.wellbores.ninj.value) * 750 * 500. + self.Cpumps) / 1E6 + def calculate_plant_costs(self, model: Model) -> None: # plant costs if (model.surfaceplant.enduse_option.value == EndUseOptions.HEAT @@ -2777,9 +2796,12 @@ def calculate_plant_costs(self, model: Model) -> None: if self.ccplantfixed.Valid: self.Cplant.value = self.ccplantfixed.value else: - # 1.15 for 15% contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 - self.Cplant.value = self._indirect_cost_factor * 1.15 * self.ccplantadjfactor.value * 250E-6 * np.max( - model.surfaceplant.HeatExtracted.value) * 1000. + self.Cplant.value = (self._indirect_cost_factor + * self._contingency_factor + * self.ccplantadjfactor.value + * 250E-6 + * np.max(model.surfaceplant.HeatExtracted.value) + * 1000.) # absorption chiller elif model.surfaceplant.enduse_option.value == EndUseOptions.HEAT and model.surfaceplant.plant_type.value == PlantType.ABSORPTION_CHILLER: # absorption chiller @@ -2787,11 +2809,19 @@ def calculate_plant_costs(self, model: Model) -> None: self.Cplant.value = self.ccplantfixed.value else: # this is for the direct-use part all the way up to the absorption chiller - self.Cplant.value = self._indirect_cost_factor * 1.15 * self.ccplantadjfactor.value * 250E-6 * np.max( - model.surfaceplant.HeatExtracted.value) * 1000. # 1.15 for 15% contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 + self.Cplant.value = (self._indirect_cost_factor + * self._contingency_factor + * self.ccplantadjfactor.value + * 250E-6 + * np.max(model.surfaceplant.HeatExtracted.value) + * 1000.) if self.chillercapex.value == -1: # no value provided by user, use built-in correlation ($2500/ton) - self.chillercapex.value = self._indirect_cost_factor * 1.15 * np.max( - model.surfaceplant.cooling_produced.value) * 1000 / 3.517 * 2500 / 1e6 # $2,500/ton of cooling. 1.15 for 15% contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 + self.chillercapex.value = ( + self._indirect_cost_factor + * self._contingency_factor + * np.max(model.surfaceplant.cooling_produced.value) + * 1000 / 3.517 * 2500 / 1e6 # $2,500/ton of cooling. + ) # now add chiller cost to surface plant cost self.Cplant.value += self.chillercapex.value @@ -2802,11 +2832,11 @@ def calculate_plant_costs(self, model: Model) -> None: self.Cplant.value = self.ccplantfixed.value else: # this is for the direct-use part all the way up to the heat pump - self.Cplant.value = self._indirect_cost_factor * 1.15 * self.ccplantadjfactor.value * 250E-6 * np.max( - model.surfaceplant.HeatExtracted.value) * 1000. # 1.15 for 15% contingency + self.Cplant.value = self._indirect_cost_factor * self._contingency_factor * self.ccplantadjfactor.value * 250E-6 * np.max( + model.surfaceplant.HeatExtracted.value) * 1000. if self.heatpumpcapex.value == -1: # no value provided by user, use built-in correlation ($150/kWth) - self.heatpumpcapex.value = self._indirect_cost_factor * 1.15 * np.max( - model.surfaceplant.HeatProduced.value) * 1000 * 150 / 1e6 # $150/kW. 1.15 for 15% contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 + self.heatpumpcapex.value = self._indirect_cost_factor * self._contingency_factor * np.max( + model.surfaceplant.HeatProduced.value) * 1000 * 150 / 1e6 # $150/kW - TODO parameterize # now add heat pump cost to surface plant cost self.Cplant.value += self.heatpumpcapex.value @@ -2816,8 +2846,7 @@ def calculate_plant_costs(self, model: Model) -> None: if self.ccplantfixed.Valid: self.Cplant.value = self.ccplantfixed.value else: - # 1.15 for 15% contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 - self.Cplant.value = self._indirect_cost_factor * 1.15 * self.ccplantadjfactor.value * 250E-6 * np.max( + self.Cplant.value = self._indirect_cost_factor * self._contingency_factor * self.ccplantadjfactor.value * 250E-6 * np.max( model.surfaceplant.HeatExtracted.value) * 1000. # add 65$/KW for peaking boiler @@ -2986,23 +3015,28 @@ def calculate_plant_costs(self, model: Model) -> None: # factor 1.10 to convert from 2016 to 2022 direct_plant_cost_MUSD = self.ccplantadjfactor.value * self.Cplantcorrelation * 1.02 * 1.10 - # factor 1.15 for 15% contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 - self.Cplant.value = self._indirect_cost_factor * 1.15 * direct_plant_cost_MUSD + self.Cplant.value = self._indirect_cost_factor * self._contingency_factor * direct_plant_cost_MUSD self.CAPEX_cost_electricity_plant = self.Cplant.value # add direct-use plant cost of co-gen system to Cplant (only of no total Cplant was provided) - if not self.ccplantfixed.Valid: # 1.15 below for contingency TODO https://github.com/NREL/GEOPHIRES-X/issues/383 + if not self.ccplantfixed.Valid: if model.surfaceplant.enduse_option.value in [EndUseOptions.COGENERATION_TOPPING_EXTRA_ELECTRICITY, EndUseOptions.COGENERATION_TOPPING_EXTRA_HEAT]: # enduse_option = 3: cogen topping cycle - self.CAPEX_cost_heat_plant = self._indirect_cost_factor * 1.15 * self.ccplantadjfactor.value * 250E-6 * np.max( - model.surfaceplant.HeatProduced.value / model.surfaceplant.enduse_efficiency_factor.value) * 1000. + self.CAPEX_cost_heat_plant = ( + self._indirect_cost_factor + * self._contingency_factor + * self.ccplantadjfactor.value + * 250E-6 + * np.max(model.surfaceplant.HeatProduced.value / model.surfaceplant.enduse_efficiency_factor.value) + * 1000. + ) elif model.surfaceplant.enduse_option.value in [EndUseOptions.COGENERATION_BOTTOMING_EXTRA_HEAT, EndUseOptions.COGENERATION_BOTTOMING_EXTRA_ELECTRICITY]: # enduse_option = 4: cogen bottoming cycle - self.CAPEX_cost_heat_plant = self._indirect_cost_factor * 1.15 * self.ccplantadjfactor.value * 250E-6 * np.max( + self.CAPEX_cost_heat_plant = self._indirect_cost_factor * self._contingency_factor * self.ccplantadjfactor.value * 250E-6 * np.max( model.surfaceplant.HeatProduced.value / model.surfaceplant.enduse_efficiency_factor.value) * 1000. elif model.surfaceplant.enduse_option.value in [EndUseOptions.COGENERATION_PARALLEL_EXTRA_ELECTRICITY, EndUseOptions.COGENERATION_PARALLEL_EXTRA_HEAT]: # cogen parallel cycle - self.CAPEX_cost_heat_plant = self._indirect_cost_factor * 1.15 * self.ccplantadjfactor.value * 250E-6 * np.max( + self.CAPEX_cost_heat_plant = self._indirect_cost_factor * self._contingency_factor * self.ccplantadjfactor.value * 250E-6 * np.max( model.surfaceplant.HeatProduced.value / model.surfaceplant.enduse_efficiency_factor.value) * 1000. self.Cplant.value = self.Cplant.value + self.CAPEX_cost_heat_plant diff --git a/src/geophires_x/__init__.py b/src/geophires_x/__init__.py index cc39f5fa..8bf33651 100644 --- a/src/geophires_x/__init__.py +++ b/src/geophires_x/__init__.py @@ -1 +1 @@ -__version__ = '3.9.34' +__version__ = '3.9.35' diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index a3a48964..afadf390 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1387,7 +1387,7 @@ ] }, "Reservoir Stimulation Capital Cost": { - "description": "Total reservoir stimulation capital cost, including contingency and indirect costs.", + "description": "Total reservoir stimulation capital cost, including indirect costs and contingency.", "type": "number", "units": "MUSD", "category": "Economics", @@ -1719,6 +1719,15 @@ "minimum": 0.0, "maximum": 1.0 }, + "Contingency Percentage": { + "description": "The contingency percentage applied to the direct capital costs for stimulation, field gathering system, exploration, and surface plant. (Note: well drilling and completion costs do not have contingency applied and are not affected by this parameter.)", + "type": "number", + "units": "%", + "category": "Economics", + "default": 15.0, + "minimum": 0.0, + "maximum": 100.0 + }, "Well Drilling Cost Correlation": { "description": "Select the built-in well drilling and completion cost correlation: 1: vertical small diameter, baseline; 2: deviated small diameter, baseline; 3: vertical large diameter, baseline; 4: deviated large diameter, baseline; 5: Simple (per-meter cost); 6: vertical small diameter, intermediate1; 7: vertical small diameter, intermediate2; 8: deviated small diameter, intermediate1; 9: deviated small diameter, intermediate2; 10: vertical large diameter, intermediate1; 11: vertical large diameter, intermediate2; 12: deviated large diameter, intermediate1; 13: deviated large diameter, intermediate2; 14: vertical open-hole, small diameter, ideal; 15: deviated liner, small diameter, ideal; 16: vertical open-hole, large diameter, ideal; 17: deviated liner, large diameter, ideal. Baseline correlations (1-4) are from NREL's 2025 cost curve update. Intermediate and ideal correlations (6-17) are from GeoVision.", "type": "integer", diff --git a/tests/base_test_case.py b/tests/base_test_case.py index 36bf53ba..c500ce3c 100644 --- a/tests/base_test_case.py +++ b/tests/base_test_case.py @@ -27,7 +27,11 @@ def assertAlmostEqualWithinPercentage(self, expected, actual, msg: str | None = raise ValueError(f'msg must be a string (you may have meant to pass percent={msg})') if isinstance(expected, numbers.Real): - self.assertAlmostEqual(expected, actual, msg=msg, delta=abs(percent / 100.0 * expected)) + try: + self.assertAlmostEqual(expected, actual, msg=msg, delta=abs(percent / 100.0 * expected)) + except AssertionError as ae: + difference_percent = abs(100.0 * (actual - expected) / expected) + raise AssertionError(f'{actual} != {expected} within {percent}% ({difference_percent:.2f}%)') from ae else: if isinstance(expected, list) and isinstance(actual, list): suggest = f'self.assertListAlmostEqual({expected}, {actual}, msg={msg}, percent={percent})' diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py index 17b3594f..e89674fa 100644 --- a/tests/test_geophires_x.py +++ b/tests/test_geophires_x.py @@ -1092,3 +1092,108 @@ def wellfield_cost(result_cap_costs): # Note this is not necessarily true for all cases, but generally would be expected, # and is true for Fervo_Project_Cape-4 specifically. ) + + def test_contingency(self): + def _get_result( + contingency_percentage: Optional[int] = None, + input_file_path: str = 'geophires_x_tests/generic-egs-case.txt', + ) -> float: + p = {} + + if contingency_percentage is not None: + p['Contingency Percentage'] = contingency_percentage + + return ( + GeophiresXClient() + .get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=self._get_test_file_path(input_file_path), + params=p, + ) + ) + .result['CAPITAL COSTS (M$)'] + ) + + def capex(result_cap_costs): + if result_cap_costs.get('Total CAPEX') is not None: + return result_cap_costs['Total CAPEX']['value'] + + return result_cap_costs['Total capital costs']['value'] + + default_contingency_percent = 15 + + for contingency_percent in range(5, 35, 5): + if contingency_percent == default_contingency_percent: + continue + + for input_file_path_ in [ + 'geophires_x_tests/generic-egs-case.txt', + 'examples/example10_HP.txt', + 'examples/example11_AC.txt', + ]: + with self.subTest(msg=f'contingency={contingency_percent}, input_file_path={input_file_path_}'): + result_default = _get_result(input_file_path=input_file_path_) + + self.assertEqual( + # Test assumption check, update default_contingency_percent + # if GEOPHIRES default value is changed. + capex(result_default), + capex( + _get_result( + contingency_percentage=default_contingency_percent, + input_file_path=input_file_path_, + ) + ), + ) + + result_different_contingency = _get_result( + contingency_percentage=contingency_percent, input_file_path=input_file_path_ + ) + + if contingency_percent > default_contingency_percent: + self.assertGreater( + capex(result_different_contingency), + capex(result_default), + ) + else: + self.assertLess( + capex(result_different_contingency), + capex(result_default), + ) + + self.assertEqual( + # Contingency is not applied to drilling costs + result_default['Drilling and completion costs']['value'], + result_different_contingency['Drilling and completion costs']['value'], + ) + + for cost_category in [ + 'Stimulation costs', + 'Surface power plant costs', + 'Field gathering system costs', + 'Total surface equipment costs', + 'Exploration costs', + ]: + default_contingency_factor = 1.0 + (default_contingency_percent / 100.0) + different_contingency_factor = 1.0 + (contingency_percent / 100.0) + + expected = ( + result_default[cost_category]['value'] + / default_contingency_factor + * different_contingency_factor + ) + + actual = result_different_contingency[cost_category]['value'] + + # Rounding throws off by a few percent + max_allowed_delta_percent = max( + # TODO to audit more thoroughly and avoid usage of these tuned constants + 2.5 if contingency_percent > default_contingency_percent else 5.4, + (contingency_percent - default_contingency_percent) / 2.0, + ) + + self.assertAlmostEqualWithinPercentage( + expected, + actual, + percent=max_allowed_delta_percent, + )