diff --git a/src/geophires_x/Economics.py b/src/geophires_x/Economics.py index 613cf7be0..19209bb96 100644 --- a/src/geophires_x/Economics.py +++ b/src/geophires_x/Economics.py @@ -16,7 +16,8 @@ project_payback_period_parameter, inflation_cost_during_construction_output_parameter, \ interest_during_construction_output_parameter, total_capex_parameter_output_parameter, \ overnight_capital_cost_output_parameter, CONSTRUCTION_CAPEX_SCHEDULE_PARAMETER_NAME, \ - _YEAR_INDEX_VALUE_EXPLANATION_SNIPPET, investment_tax_credit_output_parameter, lcoh_output_parameter, lcoc_output_parameter + _YEAR_INDEX_VALUE_EXPLANATION_SNIPPET, investment_tax_credit_output_parameter, lcoh_output_parameter, \ + lcoc_output_parameter, CALCULATED_PARAMETER_PLACEHOLDER_VALUE from geophires_x.ParameterUtils import expand_schedule_dsl from geophires_x.GeoPHIRESUtils import quantity from geophires_x.OptionList import Configuration, WellDrillingCostCorrelation, EconomicModel, EndUseOptions, PlantType, \ @@ -652,6 +653,8 @@ def __init__(self, model: Model): f'For traditional hydrothermal reservoirs, this parameter should be set to $0.' ) + before_stim_modifiers_note = f'before adjustment factor, indirect costs, and contingency' + max_stimulation_cost_per_well_MUSD = 100 self.stimulation_cost_per_injection_well = \ self.ParameterDict[self.stimulation_cost_per_injection_well.Name] = floatParameter( @@ -663,7 +666,7 @@ def __init__(self, model: Model): PreferredUnits=CurrencyUnit.MDOLLARS, CurrentUnits=CurrencyUnit.MDOLLARS, Provided=False, - ToolTipText='Reservoir stimulation capital cost per injection well before indirect costs and contingency' + ToolTipText=f'Reservoir stimulation capital cost per injection well {before_stim_modifiers_note}' ) stimulation_cost_per_production_well_default_value_MUSD = 0 @@ -674,15 +677,31 @@ def __init__(self, model: Model): self.ParameterDict[self.stimulation_cost_per_production_well.Name] = floatParameter( 'Reservoir Stimulation Capital Cost per Production Well', DefaultValue=stimulation_cost_per_production_well_default_value_MUSD, - Min=0, + Min=-1, Max=max_stimulation_cost_per_well_MUSD, UnitType=Units.CURRENCY, PreferredUnits=CurrencyUnit.MDOLLARS, CurrentUnits=CurrencyUnit.MDOLLARS, - ToolTipText=f'Reservoir stimulation capital cost per production well before indirect costs and contingency' + ToolTipText=f'Reservoir stimulation capital cost per production well {before_stim_modifiers_note}' f'{stimulation_cost_per_production_well_default_value_note}' ) + self.stimulation_cost_per_fracture_surface_area = \ + self.ParameterDict[self.stimulation_cost_per_fracture_surface_area.Name] = floatParameter( + 'Reservoir Stimulation Capital Cost per Fracture Surface Area', + Min=0, + Max=1000, + DefaultValue=0.9, + UnitType=Units.COSTPERAREA, + PreferredUnits=CostPerAreaUnit.DOLLARSPERMETERS2, + CurrentUnits=CostPerAreaUnit.DOLLARSPERMETERS2, + # TODO/WIP... + ToolTipText=f'Reservoir stimulation capital cost per fracture surface area {before_stim_modifiers_note}. ' + f'Provide {self.stimulation_cost_per_production_well.Name} = ' + f'{CALCULATED_PARAMETER_PLACEHOLDER_VALUE} to indicate that production wells are stimulated.' + ) + + # noinspection SpellCheckingInspection self.ccstimadjfactor = self.ParameterDict[self.ccstimadjfactor.Name] = floatParameter( "Reservoir Stimulation Capital Cost Adjustment Factor", DefaultValue=1.0, @@ -2822,6 +2841,8 @@ def _warn(_msg: str) -> None: if sam_em_only_param.Provided: raise NotImplementedError(f'{sam_em_only_param.Name} is only supported for SAM Economic Models') + self._validate_read_stimulation_cost_parameters(model) + else: model.logger.info("No parameters read because no content provided") @@ -2865,6 +2886,34 @@ def _set_ratio(frac: float) -> None: model.logger.info(f'complete {__class__!s}: {sys._getframe().f_code.co_name}') + def _validate_read_stimulation_cost_parameters(self, model: Model) -> None: + def _raise_mutually_exclusive_error(param1: Parameter, param2: Parameter) -> None: + raise ValueError(f'Cannot provide both {param1.Name} and {param2.Name} parameters. ') + + if self.stimulation_cost_per_fracture_surface_area.Provided: + if self.ccstimfixed.Provided: + _raise_mutually_exclusive_error( + self.ccstimfixed, + self.stimulation_cost_per_fracture_surface_area + ) + if self.stimulation_cost_per_injection_well.Provided: + _raise_mutually_exclusive_error( + self.stimulation_cost_per_injection_well, + self.stimulation_cost_per_fracture_surface_area + ) + if self.stimulation_cost_per_production_well.Provided and \ + self.stimulation_cost_per_production_well.value != \ + CALCULATED_PARAMETER_PLACEHOLDER_VALUE: # Placeholder indicates production wells are stimulated + _raise_mutually_exclusive_error( + self.stimulation_cost_per_production_well, + self.stimulation_cost_per_fracture_surface_area + ) + elif self.stimulation_cost_per_production_well.Provided and self.stimulation_cost_per_production_well.value < 0: + raise ValueError(f'{self.stimulation_cost_per_production_well.Name} must be positive') + + # TODO validate fixed stimulation cost param mutual exclusivity with stim cost per well params (warn instead of + # raising error for backwards compatibility where applicable) + def sync_interest_rate(self, model): def discount_rate_display() -> str: return str(self.discountrate.quantity()).replace(' dimensionless', '') @@ -3099,6 +3148,7 @@ def calculate_wellfield_costs(self, model: Model) -> None: def calculate_stimulation_costs(self, model: Model) -> PlainQuantity: production_wells_stimulated: bool = self.stimulation_cost_per_production_well.Provided + if self.ccstimfixed.Valid: stimulation_costs_cstim_u = self.ccstimfixed.quantity().to(self.Cstim.CurrentUnits).magnitude @@ -3122,6 +3172,16 @@ def calculate_stimulation_costs(self, model: Model) -> PlainQuantity: ret = quantity(stimulation_costs_cstim_u, self.Cstim.CurrentUnits) else: + if self.stimulation_cost_per_fracture_surface_area.Provided: + total_fracture_surface_area_q = (model.reserv.fracareacalc.quantity() * model.reserv.fracnumbcalc.value).to( + self.stimulation_cost_per_fracture_surface_area.CurrentUnits.get_area_unit_str()) + direct_stim_cost_q = (total_fracture_surface_area_q * + self.stimulation_cost_per_fracture_surface_area.quantity()) + inj_to_prod_cost_ratio = 1 if not production_wells_stimulated else \ + model.wellbores.ninj.value / (model.wellbores.nprod.value + model.wellbores.ninj.value) + self.stimulation_cost_per_injection_well.value = quantity(inj_to_prod_cost_ratio * direct_stim_cost_q / model.wellbores.ninj.value, self.stimulation_cost_per_fracture_surface_area.CurrentUnits.get_currency_unit_str()).to(self.stimulation_cost_per_injection_well.CurrentUnits).magnitude + self.stimulation_cost_per_production_well.value = quantity((1 - inj_to_prod_cost_ratio) * direct_stim_cost_q / model.wellbores.nprod.value, self.stimulation_cost_per_fracture_surface_area.CurrentUnits.get_currency_unit_str()).to(self.stimulation_cost_per_production_well.CurrentUnits).magnitude + direct_stim_cost_per_injection_well_cstim_u = self.stimulation_cost_per_injection_well.quantity().to( self.Cstim.CurrentUnits).magnitude direct_stim_cost_per_production_well_cstim_u = self.stimulation_cost_per_production_well.quantity().to( diff --git a/src/geophires_x/EconomicsUtils.py b/src/geophires_x/EconomicsUtils.py index 1498b97ea..db52d99ff 100644 --- a/src/geophires_x/EconomicsUtils.py +++ b/src/geophires_x/EconomicsUtils.py @@ -17,6 +17,8 @@ f'The value is specified as a project year index corresponding to the ' f'Year row in the cash flow profile' ) +CALCULATED_PARAMETER_PLACEHOLDER_VALUE = -1 + def BuildPricingModel( plantlifetime: int, diff --git a/src/geophires_x/Units.py b/src/geophires_x/Units.py index b09884d0e..0ab73c338 100644 --- a/src/geophires_x/Units.py +++ b/src/geophires_x/Units.py @@ -88,6 +88,7 @@ class Units(IntEnum): DECAY_RATE = auto() INFLATION_RATE = auto() DYNAMIC_VISCOSITY = auto() + COSTPERAREA = auto() class AngleUnit(str, Enum): @@ -243,6 +244,15 @@ class CostPerDistanceUnit(str, Enum): DOLLARSPERM = "USD/m" +class CostPerAreaUnit(str, Enum): + DOLLARSPERMETERS2 = "USD/m**2" + + def get_currency_unit_str(self) -> str: + return self.value.split('/')[0] + + def get_area_unit_str(self) -> str: + return self.value.split('/')[1] + class PressureUnit(str, Enum): """Pressure Units""" MPASCAL = "MPa" diff --git a/src/geophires_x_schema_generator/geophires-request.json b/src/geophires_x_schema_generator/geophires-request.json index eca0525c7..cc9c2de83 100644 --- a/src/geophires_x_schema_generator/geophires-request.json +++ b/src/geophires_x_schema_generator/geophires-request.json @@ -1488,7 +1488,7 @@ "maximum": 1000 }, "Reservoir Stimulation Capital Cost per Injection Well": { - "description": "Reservoir stimulation capital cost per injection well before indirect costs and contingency", + "description": "Reservoir stimulation capital cost per injection well before adjustment factor, indirect costs, and contingency", "type": "number", "units": "MUSD", "category": "Economics", @@ -1497,14 +1497,23 @@ "maximum": 100 }, "Reservoir Stimulation Capital Cost per Production Well": { - "description": "Reservoir stimulation capital cost per production well before indirect costs and contingency. By default, only the injection wells are assumed to be stimulated unless this parameter is provided.", + "description": "Reservoir stimulation capital cost per production well before adjustment factor, indirect costs, and contingency. By default, only the injection wells are assumed to be stimulated unless this parameter is provided.", "type": "number", "units": "MUSD", "category": "Economics", "default": 0, - "minimum": 0, + "minimum": -1, "maximum": 100 }, + "Reservoir Stimulation Capital Cost per Fracture Surface Area": { + "description": "Reservoir stimulation capital cost per fracture surface area before adjustment factor, indirect costs, and contingency. Provide Reservoir Stimulation Capital Cost per Production Well = -1 to indicate that production wells are stimulated.", + "type": "number", + "units": "USD/m**2", + "category": "Economics", + "default": 0.9, + "minimum": 0, + "maximum": 1000 + }, "Reservoir Stimulation Capital Cost Adjustment Factor": { "description": "Multiplier for reservoir stimulation capital cost correlation", "type": "number", diff --git a/tests/geophires_x_tests/generic-egs-case-5_no-stim-costs-specified.txt b/tests/geophires_x_tests/generic-egs-case-5_no-stim-costs-specified.txt new file mode 100644 index 000000000..81e8b8240 --- /dev/null +++ b/tests/geophires_x_tests/generic-egs-case-5_no-stim-costs-specified.txt @@ -0,0 +1,121 @@ +# Adapted from https://github.com/softwareengineerprogrammer/GEOPHIRES/blob/90fd881105029ede94fc0275f9dc247d47a7dd26/tests/examples/Fervo_Project_Cape-5.txt + +# *** ECONOMIC/FINANCIAL PARAMETERS *** +# ************************************* +Economic Model, 5 +Inflation Rate, .027 + +Starting Electricity Sale Price, 0.095 +Electricity Escalation Rate Per Year, 0.00057 +Ending Electricity Sale Price, 1 +Electricity Escalation Start Year, 1 + +Fraction of Investment in Bonds, .7 +Discount Rate, 0.12 +Inflated Bond Interest Rate, .07 + +Inflated Bond Interest Rate During Construction, 0.105 +Bond Financing Start Year, -2 + +Construction Years, 5 + +# ATB advanced scenario +# Construction CAPEX Schedule + +# DOE scenario (alternative) +# Construction CAPEX Schedule + +# DOE-ATB hybrid scenario +Construction CAPEX Schedule, 0.014,0.027,0.139,0.431,0.389 + +Investment Tax Credit Rate, 0.3 +Combined Income Tax Rate, .2555 +Property Tax Rate, 0.0022 + +Capital Cost for Power Plant for Electricity Generation, 1900 +Exploration Capital Cost, 30 + +Well Drilling Cost Correlation, 3 +Well Drilling and Completion Capital Cost Adjustment Factor, 0.9 + +Field Gathering System Capital Cost Adjustment Factor, 0.54 + +Royalty Rate, 0.0175 +Royalty Rate Escalation Start Year, 11 +Royalty Rate Escalation, 0.0175 +Royalty Rate Maximum, 0.035 + + +# *** SURFACE & SUBSURFACE TECHNICAL PARAMETERS *** +# ************************************************* +End-Use Option, 1 +Power Plant Type, 2 +Plant Lifetime, 30 + +Surface Temperature, 13 + +Number of Segments, 3 +Gradient 1, 74 +Thickness 1, 2.5 +Gradient 2, 41 +Thickness 2, 0.5 +Gradient 3, 39.1 + +Reservoir Depth, 2.68 + +Reservoir Density, 2800 +Reservoir Heat Capacity, 790 +Reservoir Thermal Conductivity, 3.05 +Reservoir Porosity, 0.0118 + +Reservoir Model, 1 +Reservoir Volume Option, 1 + +Number of Fractures per Stimulated Well, 150 +Fracture Separation, 9.8255 + +Fracture Shape, 4 +Fracture Width, 305 +Fracture Height, 100 + +Water Loss Fraction, 0.01 +Water Cost Adjustment Factor, 2 + +Ambient Temperature, 11.17 + +Utilization Factor, .913 +Plant Outlet Pressure, 2000 psi +Circulation Pump Efficiency, 0.80 + +# *** Well Bores Parameters *** + +Number of Production Wells, 56 +Number of Injection Wells per Production Well, 0.666 + +Nonvertical Length per Multilateral Section, 5000 feet +Number of Multilateral Sections, 0 + +Production Flow Rate per Well, 107 +# The ATB Advanced Scenario models sustained flow rates of 110 kg/s (NREL + +Production Well Diameter, 8.535 +Injection Well Diameter, 8.535 + +Production Wellhead Pressure, 303 psi + +Injectivity Index, 1.38 +Productivity Index, 1.13 + +Ramey Production Wellbore Model, True +Injection Temperature, 53.6 +Injection Wellbore Temperature Gain, 3 + +Maximum Drawdown, 0.0025 + +# *** SIMULATION PARAMETERS *** +# ***************************** +Maximum Temperature, 500 +Time steps per year, 12 + +Project Latitude, 38.506196 +Project Longitude, -112.918155 diff --git a/tests/geophires_x_tests/test_economics.py b/tests/geophires_x_tests/test_economics.py index 99e373fab..003d6433a 100644 --- a/tests/geophires_x_tests/test_economics.py +++ b/tests/geophires_x_tests/test_economics.py @@ -14,6 +14,7 @@ GeophiresXResult, GeophiresXClient, GeophiresInputParameters, + ImmutableGeophiresInputParameters, ) from tests.base_test_case import BaseTestCase @@ -135,6 +136,27 @@ def _lcoh_pbc(r: GeophiresXResult) -> tuple[float, float]: self.assertLess(lcoh, 13.19) self.assertEqual(0, peaking_boiler_cost) + def test_stimulation_cost_per_fracture_surface_area(self): + def _get_result() -> GeophiresXResult: + return GeophiresXClient().get_geophires_result( + ImmutableGeophiresInputParameters( + from_file_path=self._get_test_file_path('generic-egs-case-5_no-stim-costs-specified.txt'), + params={ + 'Reservoir Stimulation Capital Cost per Production Well': -1, + 'Reservoir Stimulation Capital Cost per Fracture Surface Area': 0.89773, + 'Print Output to Console': True, + }, + ) + ) + + r: GeophiresXResult = _get_result() + # FIXME WIP - should match FPC5 more closely + # (probably related to adjustment factor/indirect costs/contingency...) + cap_costs = r.result['CAPITAL COSTS (M$)'] + self.assertAlmostEqual(454.02, cap_costs['Stimulation costs']['value'], delta=13) + + self.assertAlmostEqual(4.83, cap_costs['Stimulation costs per well']['value'], delta=0.14) + # noinspection PyMethodMayBeStatic def _new_model( self, input_file: Path | None = None, additional_params: dict[str, Any] | None = None, read_and_calculate=False