diff --git a/CHANGELOG.md b/CHANGELOG.md index a374693d1..4ec81a707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,10 @@ - Updated finance parameter organization naming in `plant_config`. - Added cost model base class and removed `plant_config['finance_parameters']['discount_years']['tech']`. Some cost models require user-input cost year (`tech_config[tech]['model_inputs']['cost_parameters']['cost_year']`) others do not. Cost year is output from cost models as a discrete output. - Add ocean alkalinity enhancement technology model. -- Refactored `ProFastComp` and put in a new file (`h2integrate/core/profast_financial.py`). Added flexibility to allow users to specify different financial models. - Added ability to export ProFAST object to yaml file in `ProFastComp` +- Added `natural_gas_performance` and `natural_gas_cost` models, allowing for natural gas power plant modeling. +- Revamped the feedstocks technology group to allow for more precise modeling of feedstock supply chains, including capacity constraints and feedstock amount consumed. +- Refactored `ProFastComp` and put in a new file (`h2integrate/core/profast_financial.py`). Added flexibility to allow users to specify different financial models. - Bugfix on `h2integrate/transporters/power_combiner.py` and enabled usage of multiple electricity producing technologies. - Updated option to pass variables in technology interconnections to allow for different variable names from source to destination in the format `[source_tech, dest_tech, (source_tech_variable, dest_tech_variable)]` - Added `simulation` section under `plant_config['plant']` that has information such as number of timesteps in the simulation, time step interval in seconds, simulation start time, and time zone. diff --git a/docs/_toc.yml b/docs/_toc.yml index 3216fa577..6b197d90c 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -20,8 +20,10 @@ parts: - caption: Technology Models chapters: + - file: technology_models/feedstocks - file: technology_models/run_of_river - file: technology_models/direct_ocean_capture + - file: technology_models/natural_gas - file: technology_models/wombat_electrolyzer_om - file: technology_models/pvwattsv8_solar_pv.md - file: technology_models/atb_costs_pv.md diff --git a/docs/technology_models/feedstocks.md b/docs/technology_models/feedstocks.md new file mode 100644 index 000000000..2ed818f72 --- /dev/null +++ b/docs/technology_models/feedstocks.md @@ -0,0 +1,81 @@ +# Feedstock Models + +Feedstock models in H2Integrate represent any resource input that is consumed by technologies in your plant, such as natural gas, water, electricity from the grid, or any other material input. +The feedstock modeling approach provides a flexible way to track resource consumption and calculate associated costs for any type of input material or energy source. +Please see the example `16_natural_gas` in the `examples` directory for a complete setup using natural gas as a feedstock. + +## How Feedstock Models Work + +### Two-Component Architecture + +Each feedstock type requires two model components: + +1. **Performance Model** (`feedstock_performance`): + - Generates the feedstock supply profile + - Outputs `{feedstock_type}_out` variable + - Located at the beginning of the technology chain + +2. **Cost Model** (`feedstock_cost`): + - Calculates consumption costs based on actual usage + - Takes `{feedstock_type}_consumed` as input + - Located after all consuming technologies in the chain + +### Technology Interconnections + +Feedstocks connect to consuming technologies through the `technology_interconnections` in your plant configuration. The connection pattern is: + +```yaml +technology_interconnections: [ + ["name_of_feedstock_source", "consuming_technology", "feedstock_type", "connection_type"], +] +``` + +Where: +- `name_of_feedstock_source`: Name of your feedstock source +- `consuming_technology`: Technology that uses the feedstock +- `feedstock_type`: Type identifier (e.g., "natural_gas", "water", "electricity") +- `connection_type`: Name for the connection (e.g., "pipe", "cable") + +## Configuration + +To use the feedstock performance and cost models, add an entry to your `tech_config.yaml` like this: + +```yaml +ng_feedstock: + performance_model: + model: "feedstock_performance" + cost_model: + model: "feedstock_cost" + model_inputs: + shared_parameters: + feedstock_type: "natural_gas" + units: "MMBtu" + performance_parameters: + rated_capacity: 100. + cost_parameters: + cost_year: 2023 + price: 4.2 + annual_cost: 0. + start_up_cost: 100000. +``` + +### Performance Model Parameters + +- `feedstock_type` (str): Identifier for the feedstock type (e.g., "natural_gas", "water", "electricity") +- `units` (str): Units for feedstock consumption (e.g., "MMBtu", "kg", "galUS", "MWh") +- `rated_capacity` (float): Maximum feedstock supply rate in `units`/hour + +### Cost Model Parameters + +- `feedstock_type` (str): Must match the performance model identifier +- `units` (str): Must match the performance model units +- `price` (float, int, or list): Cost per unit in USD/`units`. Can be: + - Scalar: Constant price for all timesteps and years + - List: Price per timestep +- `annual_cost` (float, optional): Fixed cost per year in USD/year. Defaults to 0.0 +- `start_up_cost` (float, optional): One-time capital cost in USD. Defaults to 0.0 +- `cost_year` (int): Dollar year for cost inputs + +```{tip} +The `price` parameter is flexible - you can specify constant pricing with a single value or time-varying pricing with an array of values matching the number of simulation timesteps. +``` diff --git a/docs/technology_models/natural_gas.md b/docs/technology_models/natural_gas.md new file mode 100644 index 000000000..8b3b68baf --- /dev/null +++ b/docs/technology_models/natural_gas.md @@ -0,0 +1,39 @@ +# Natural gas power plant model + +The natural gas power plant model simulates electricity generation from natural gas combustion, suitable for both natural gas combustion turbines (NGCT) and natural gas combined cycle (NGCC) plants. The model calculates electricity output based on natural gas input and plant heat rate, along with comprehensive cost modeling that includes capital expenses, operating expenses, and fuel costs. + +To use this model, specify `"natural_gas_performance"` as the performance model and `"natural_gas_cost"` as the cost model. + +## Performance Parameters + +The performance model requires the following parameter: + +- `heat_rate` (required): Heat rate of the natural gas plant in MMBtu/MWh. This represents the amount of fuel energy required to produce one MWh of electricity. Lower values indicate higher efficiency. Typical values: + - **NGCC (Combined Cycle)**: 6-8 MMBtu/MWh (high efficiency) + - **NGCT (Combustion Turbine)**: 10-14 MMBtu/MWh (lower efficiency, faster response) + +The model implements the relationship: + +$$ +\text{Electricity Output (MW)} = \frac{\text{Natural Gas Input (MMBtu/h)}}{\text{Heat Rate (MMBtu/MWh)}} +$$ + +## Cost Parameters + +The cost model calculates capital and operating costs based on the following parameters: + +- `capex` (required): Capital cost per unit capacity in $/kW. This includes all equipment, installation, and construction costs. Typical values: + - **NGCT**: 600-2000 $/kW (lower capital cost) + - **NGCC**: 800-2400 $/kW (higher capital cost) + +- `fopex` (required): Fixed operating expenses per unit capacity in \$/kW/year. This includes fixed O&M costs that don't vary with generation. Typical values: 5-15 \$/kW/year + +- `vopex` (required): Variable operating expenses per unit generation in \$/MWh. This includes variable O&M costs that scale with electricity generation. Typical values: 1-5 \$/MWh + +- `heat_rate` (required): Heat rate in MMBtu/MWh, used for fuel cost calculations. + +- `ng_price` (required): Natural gas price in $/MMBtu. Can be a numeric value for fixed price or `"variable"` string to indicate external price management. + +- `project_life` (optional): Project lifetime in years for cost calculations. Default is 30 years, typical for power plants. + +- `cost_year` (required): Dollar year corresponding to input costs. diff --git a/examples/16_natural_gas/driver_config.yaml b/examples/16_natural_gas/driver_config.yaml new file mode 100644 index 000000000..92ff7626c --- /dev/null +++ b/examples/16_natural_gas/driver_config.yaml @@ -0,0 +1,5 @@ +name: "driver_config" +description: "This analysis runs a natural gas power plant" + +general: + folder_output: outputs diff --git a/examples/16_natural_gas/natgas.yaml b/examples/16_natural_gas/natgas.yaml new file mode 100644 index 000000000..cfc4cb207 --- /dev/null +++ b/examples/16_natural_gas/natgas.yaml @@ -0,0 +1,7 @@ +name: "H2Integrate_config" + +system_summary: "This reference natural gas plant contains a simple natural gas combined cycle (NGCC) power plant" + +driver_config: "driver_config.yaml" +technology_config: "tech_config.yaml" +plant_config: "plant_config.yaml" diff --git a/examples/16_natural_gas/plant_config.yaml b/examples/16_natural_gas/plant_config.yaml new file mode 100644 index 000000000..025ad458a --- /dev/null +++ b/examples/16_natural_gas/plant_config.yaml @@ -0,0 +1,64 @@ +name: "plant_config" +description: "This plant is located in Texas" + +site: + latitude: 32.34 + longitude: -98.27 + elevation_m: 440.0 + time_zone: -6 + + # array of polygons defining boundaries with x/y coords + boundaries: [ + { + x: [0.0, 1000.0, 1000.0, 0.0], + y: [0.0, 0.0, 100.0, 1000.0], + }, + { + x: [2000.0, 2500.0, 2000.0], + y: [2000.0, 2000.0, 2500.0], + } + ] + +# array of arrays containing left-to-right technology +# interconnections; can support bidirectional connections +# with the reverse definition. +# in this example the natural gas combined cycle plant is behind-the-meter +# and not connected to additional technologies. +# hence the empty array. +technology_interconnections: [ + ["ng_feedstock", "natural_gas_plant", "natural_gas", "pipe"], +] + +plant: + plant_life: 30 + grid_connection: False # option, can be turned on or off + ppa_price: 0.027498168 # based off correlations of LBNL PPA data + hybrid_electricity_estimated_cf: 0.492 #should equal 1 if grid_connection = True + simulation: + n_timesteps: 8760 + +finance_parameters: + finance_model: "ProFastComp" + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 + inflation_rate: 0.0 + discount_rate: 0.09 + debt_equity_ratio: 2.62 + property_tax_and_insurance: 0.03 + total_income_tax_rate: 0.308 + capital_gains_tax_rate: 0.15 + sales_tax_rate: 0.07375 + debt_interest_rate: 0.07 + debt_type: "Revolving debt" + loan_period_if_used: 0 + cash_onhand_months: 1 + admin_expense: 0.00 + capital_items: + depr_type: "MACRS" + depr_period: 5 + refurb: [0.] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 + target_dollar_year: 2022 diff --git a/examples/16_natural_gas/run_natural_gas.py b/examples/16_natural_gas/run_natural_gas.py new file mode 100644 index 000000000..364f6e149 --- /dev/null +++ b/examples/16_natural_gas/run_natural_gas.py @@ -0,0 +1,11 @@ +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +# Create an H2I model +h2i = H2IntegrateModel("natgas.yaml") + +# Run the model +h2i.run() + +# Post-process the results +h2i.post_process() diff --git a/examples/16_natural_gas/tech_config.yaml b/examples/16_natural_gas/tech_config.yaml new file mode 100644 index 000000000..46c87f430 --- /dev/null +++ b/examples/16_natural_gas/tech_config.yaml @@ -0,0 +1,34 @@ +name: "technology_config" +description: "This plant produces electricity from natural gas using a combined cycle turbine" + +technologies: + ng_feedstock: + performance_model: + model: "feedstock_performance" + cost_model: + model: "feedstock_cost" + model_inputs: + shared_parameters: + feedstock_type: "natural_gas" + units: "MMBtu" + performance_parameters: + rated_capacity: 100. + cost_parameters: + cost_year: 2023 + price: 4.2 + annual_cost: 0. + start_up_cost: 100000. + natural_gas_plant: + performance_model: + model: "natural_gas_performance" + cost_model: + model: "natural_gas_cost" + model_inputs: + shared_parameters: + heat_rate_mmbtu_per_mwh: 7.5 # MMBtu/MWh - typical for NGCC + cost_parameters: + plant_capacity_mw: 100. + capex_per_kw: 1000 # $/kW - typical for NGCC + fixed_opex_per_kw_per_year: 10.0 # $/kW/year + variable_opex_per_mwh: 2.5 # $/MWh + cost_year: 2023 diff --git a/h2integrate/converters/ammonia/test/test_simple_ammonia_model.py b/h2integrate/converters/ammonia/test/test_simple_ammonia_model.py index 8d7775c97..e7df3f702 100644 --- a/h2integrate/converters/ammonia/test/test_simple_ammonia_model.py +++ b/h2integrate/converters/ammonia/test/test_simple_ammonia_model.py @@ -7,6 +7,15 @@ ) +plant_config = { + "plant": { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + }, + }, +} + tech_config_dict = { "model_inputs": { "shared_parameters": { diff --git a/h2integrate/converters/natural_gas/__init__.py b/h2integrate/converters/natural_gas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/h2integrate/converters/natural_gas/natural_gas_baseclass.py b/h2integrate/converters/natural_gas/natural_gas_baseclass.py new file mode 100644 index 000000000..e8f80c3cf --- /dev/null +++ b/h2integrate/converters/natural_gas/natural_gas_baseclass.py @@ -0,0 +1,41 @@ +import openmdao.api as om + + +class NaturalGasPerformanceBaseClass(om.ExplicitComponent): + """ + Base class for natural gas plant performance models. + + This base class defines the common interface for natural gas combustion + turbine (NGCT) and natural gas combined cycle (NGCC) performance models. + """ + + def initialize(self): + self.options.declare("driver_config", types=dict) + self.options.declare("plant_config", types=dict) + self.options.declare("tech_config", types=dict) + + def setup(self): + n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + self.add_input( + "natural_gas_in", + val=0.0, + shape=n_timesteps, + units="MMBtu", + desc="Natural gas input energy", + ) + self.add_output( + "electricity_out", + val=0.0, + shape=n_timesteps, + units="MW", + desc="Electricity output from natural gas plant", + ) + + def compute(self, inputs, outputs): + """ + Computation for the OM component. + + For a template class this is not implemented and raises an error. + """ + + raise NotImplementedError("This method should be implemented in a subclass.") diff --git a/h2integrate/converters/natural_gas/natural_gas_cc_ct.py b/h2integrate/converters/natural_gas/natural_gas_cc_ct.py new file mode 100644 index 000000000..e7855a82f --- /dev/null +++ b/h2integrate/converters/natural_gas/natural_gas_cc_ct.py @@ -0,0 +1,263 @@ +import openmdao.api as om +from attrs import field, define + +from h2integrate.core.utilities import BaseConfig, CostModelBaseConfig, merge_shared_inputs +from h2integrate.core.validators import gt_zero, gte_zero +from h2integrate.core.model_baseclasses import CostModelBaseClass + + +@define +class NaturalGasPerformanceConfig(BaseConfig): + """ + Configuration class for natural gas plant performance model. + + This configuration class handles the parameters for both natural gas + combustion turbines (NGCT) and natural gas combined cycle (NGCC) plants. + + Attributes: + heat_rate_mmbtu_per_mwh (float): Heat rate of the natural gas plant in MMBtu/MWh. + This represents the amount of fuel energy required to produce + one MWh of electricity. Lower values indicate higher efficiency. + Typical values: + - NGCT: 10-14 MMBtu/MWh + - NGCC: 6-8 MMBtu/MWh + """ + + heat_rate_mmbtu_per_mwh: float = field(validator=gt_zero) + + +class NaturalGasPerformanceModel(om.ExplicitComponent): + """ + Performance model for natural gas power plants. + + This model calculates electricity output from natural gas input based on + the plant's heat rate. It can be used for both natural gas combustion + turbines (NGCT) and natural gas combined cycle (NGCC) plants by providing + appropriate heat rate values. + + The model implements the relationship: + electricity_out = natural_gas_in / heat_rate + + Inputs: + natural_gas_in (array): Natural gas input energy in MMBtu for each timestep + + Outputs: + electricity_out (array): Electricity output in MW for each timestep + + Parameters (from config): + heat_rate_mmbtu_per_mwh (float): Plant heat rate in MMBtu/MWh + """ + + def initialize(self): + self.options.declare("driver_config", types=dict) + self.options.declare("plant_config", types=dict) + self.options.declare("tech_config", types=dict) + + def setup(self): + self.config = NaturalGasPerformanceConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance") + ) + n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + + # Add natural gas input + self.add_input( + "natural_gas_in", + val=0.0, + shape=n_timesteps, + units="MMBtu", + desc="Natural gas input energy", + ) + + # Add natural gas consumed output + self.add_output( + "natural_gas_consumed", + val=0.0, + shape=n_timesteps, + units="MMBtu", + desc="Natural gas consumed by the plant", + ) + + # Add electricity output + self.add_output( + "electricity_out", + val=0.0, + shape=n_timesteps, + units="MW", + desc="Electricity output from natural gas plant", + ) + + # Add heat_rate as an OpenMDAO input with config value as default + self.add_input( + "heat_rate_mmbtu_per_mwh", + val=self.config.heat_rate_mmbtu_per_mwh, + units="MMBtu/MW/h", + desc="Plant heat rate in MMBtu/MWh", + ) + + def compute(self, inputs, outputs): + """ + Compute electricity output from natural gas input. + + The computation uses the heat rate to convert natural gas energy input + to electrical energy output. The heat rate represents the fuel energy + required per unit of electrical energy produced. + + Args: + inputs: OpenMDAO inputs object containing natural_gas_in and heat_rate + outputs: OpenMDAO outputs object for electricity_out + """ + natural_gas_in = inputs["natural_gas_in"] + heat_rate_mmbtu_per_mwh = inputs["heat_rate_mmbtu_per_mwh"] + + # Convert natural gas input to electricity output using heat rate + electricity_out = natural_gas_in / heat_rate_mmbtu_per_mwh + + outputs["electricity_out"] = electricity_out + outputs["natural_gas_consumed"] = natural_gas_in + + +@define +class NaturalGasCostModelConfig(CostModelBaseConfig): + """ + Configuration class for natural gas plant cost model. + + This configuration handles cost parameters for both natural gas combustion + turbines (NGCT) and natural gas combined cycle (NGCC) plants. + + Attributes: + plant_capacity_mw (float | int): Plant capacity in MW. + + capex_per_kw (float|int): Capital cost per unit capacity in $/kW. This includes + all equipment, installation, and construction costs. + Typical values: + - NGCT: 600-2400 $/kW + - NGCC: 800-2400 $/kW + + fixed_opex_per_kw_per_year (float|int): Fixed operating expenses per unit capacity + in $/kW/year. This includes fixed O&M costs that don't vary with generation. + Typical values: 5-15 $/kW/year + + variable_opex_per_mwh (float|int): Variable operating expenses per unit generation in $/MWh. + This includes variable O&M costs that scale with electricity generation. + Typical values: 1-5 $/MWh + + heat_rate_mmbtu_per_mwh (float): Heat rate in MMBtu/MWh, used for fuel cost calculations. + This should match the heat rate used in the performance model. + + cost_year (int): Dollar year corresponding to input costs. + """ + + plant_capacity_mw: float | int = field(validator=gt_zero) + capex_per_kw: float | int = field(validator=gte_zero) + fixed_opex_per_kw_per_year: float | int = field(validator=gte_zero) + variable_opex_per_mwh: float | int = field(validator=gte_zero) + heat_rate_mmbtu_per_mwh: float = field(validator=gt_zero) + + +class NaturalGasCostModel(CostModelBaseClass): + """ + Cost model for natural gas power plants. + + This model calculates capital and operating costs for natural gas plants + including fuel costs. It can be used for both NGCT and NGCC plants. + + The model calculates: + - CapEx: Capital expenditure based on plant capacity + - OpEx: Operating expenditure including fixed O&M, variable O&M, and fuel costs + + Cost components: + 1. Capital costs: capex_per_kw * plant_capacity_kw + 2. Fixed O&M: fixed_opex_per_kw_per_year * plant_capacity_kw + 3. Variable O&M: variable_opex_per_mwh * delivered_electricity_MWh + + Inputs: + plant_capacity_mw (float): Plant capacity in MW + electricity_out (array): Hourly electricity output in MW from performance model + capex_per_kw (float): Capital cost per unit capacity in $/kW + fixed_opex_per_kw_per_year (float): Fixed operating expenses per unit capacity in $/kW/year + variable_opex_per_mwh (float): Variable operating expenses per unit generation in $/MWh + heat_rate_mmbtu_per_mwh (float): Heat rate in MMBtu/MWh + + Outputs: + CapEx (float): Total capital expenditure in USD + OpEx (float): Total operating expenditure in USD/year + cost_year (int): Dollar year for the costs + """ + + def setup(self): + self.config = NaturalGasCostModelConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost") + ) + n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + + super().setup() + + # Add inputs specific to the cost model with config values as defaults + self.add_input( + "plant_capacity_mw", + val=self.config.plant_capacity_mw, + units="MW", + desc="Natural gas plant capacity", + ) + self.add_input( + "electricity_out", + val=0.0, + shape=n_timesteps, + units="MW", + desc="Hourly electricity output from performance model", + ) + self.add_input( + "capex_per_kw", + val=self.config.capex_per_kw, + units="USD/kW", + desc="Capital cost per unit capacity", + ) + self.add_input( + "fixed_opex_per_kw_per_year", + val=self.config.fixed_opex_per_kw_per_year, + units="USD/kW/year", + desc="Fixed operating expenses per unit capacity per year", + ) + self.add_input( + "variable_opex_per_mwh", + val=self.config.variable_opex_per_mwh, + units="USD/MW/h", + desc="Variable operating expenses per unit generation", + ) + self.add_input( + "heat_rate_mmbtu_per_mwh", + val=self.config.heat_rate_mmbtu_per_mwh, + units="MMBtu/MW/h", + desc="Plant heat rate", + ) + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + """ + Compute capital and operating costs for the natural gas plant. + """ + plant_capacity_kw = inputs["plant_capacity_mw"][0] * 1000 # Convert MW to kW + electricity_out = inputs["electricity_out"] # MW hourly profile + capex_per_kw = inputs["capex_per_kw"][0] + fixed_opex_per_kw_per_year = inputs["fixed_opex_per_kw_per_year"][0] + variable_opex_per_mwh = inputs["variable_opex_per_mwh"][0] + + # Sum hourly electricity output to get annual generation + # electricity_out is in MW, so sum gives MWh for hourly data + dt = self.options["plant_config"]["plant"]["simulation"]["dt"] + delivered_electricity_MWdt = electricity_out.sum() + delivered_electricity_MWh = delivered_electricity_MWdt * dt / 3600 + + # Calculate capital expenditure + capex = capex_per_kw * plant_capacity_kw + + # Calculate fixed operating expenses over project life + fixed_om = fixed_opex_per_kw_per_year * plant_capacity_kw + + # Calculate variable operating expenses over project life + variable_om = variable_opex_per_mwh * delivered_electricity_MWh + + # Total operating expenditure includes all O&M + opex = fixed_om + variable_om + + outputs["CapEx"] = capex + outputs["OpEx"] = opex diff --git a/h2integrate/converters/natural_gas/test/__init__.py b/h2integrate/converters/natural_gas/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/h2integrate/converters/natural_gas/test/test_natural_gas_models.py b/h2integrate/converters/natural_gas/test/test_natural_gas_models.py new file mode 100644 index 000000000..4f10cf834 --- /dev/null +++ b/h2integrate/converters/natural_gas/test/test_natural_gas_models.py @@ -0,0 +1,238 @@ +import numpy as np +import pytest +import openmdao.api as om +from pytest import fixture + +from h2integrate.converters.natural_gas.natural_gas_cc_ct import ( + NaturalGasCostModel, + NaturalGasPerformanceModel, +) + + +@fixture +def ngcc_performance_params(): + """Natural Gas Combined Cycle performance parameters.""" + tech_params = { + "heat_rate_mmbtu_per_mwh": 7.5, # MMBtu/MWh - typical for NGCC + } + return tech_params + + +@fixture +def ngct_performance_params(): + """Natural Gas Combustion Turbine performance parameters.""" + tech_params = { + "heat_rate_mmbtu_per_mwh": 11.5, # MMBtu/MWh - typical for NGCT + } + return tech_params + + +@fixture +def ngcc_cost_params(): + """Natural Gas Combined Cycle cost parameters.""" + cost_params = { + "capex_per_kw": 1000, # $/kW + "fixed_opex_per_kw_per_year": 10.0, # $/kW/year + "variable_opex_per_mwh": 2.5, # $/MWh + "heat_rate_mmbtu_per_mwh": 7.5, # MMBtu/MWh + "plant_capacity_mw": 100, # MW + "cost_year": 2023, + } + return cost_params + + +@fixture +def ngct_cost_params(): + """Natural Gas Combustion Turbine cost parameters.""" + cost_params = { + "capex_per_kw": 800, # $/kW + "fixed_opex_per_kw_per_year": 8.0, # $/kW/year + "variable_opex_per_mwh": 3.0, # $/MWh + "heat_rate_mmbtu_per_mwh": 11.5, # MMBtu/MWh + "plant_capacity_mw": 100, # MW + "cost_year": 2023, + } + return cost_params + + +def get_plant_config(): + """Fixture to get plant configuration.""" + return { + "plant": { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + "dt": 3600, + }, + }, + } + + +def test_ngcc_performance(ngcc_performance_params, subtests): + """Test NGCC performance model with typical operating conditions.""" + tech_config_dict = { + "model_inputs": { + "performance_parameters": ngcc_performance_params, + } + } + + # Create a simple natural gas input profile (constant 750 MMBtu/h for 100 MW plant) + natural_gas_input = np.full(8760, 750.0) # MMBtu + + prob = om.Problem() + perf_comp = NaturalGasPerformanceModel( + plant_config=get_plant_config(), + tech_config=tech_config_dict, + ) + + prob.model.add_subsystem("ng_perf", perf_comp, promotes=["*"]) + prob.setup() + + # Set the natural gas input + prob.set_val("natural_gas_in", natural_gas_input) + prob.run_model() + + electricity_out = prob.get_val("electricity_out") + + with subtests.test("NGCC Electricity Output"): + # Expected: 750 MMBtu / 7.5 MMBtu/MWh = 100 MW + expected_output = natural_gas_input / ngcc_performance_params["heat_rate_mmbtu_per_mwh"] + assert pytest.approx(electricity_out, rel=1e-6) == expected_output + + with subtests.test("NGCC Average Output"): + # Check average output is 100 MW + assert pytest.approx(np.mean(electricity_out), rel=1e-6) == 100.0 + + +def test_ngct_performance(ngct_performance_params, subtests): + """Test NGCT performance model with typical operating conditions.""" + tech_config_dict = { + "model_inputs": { + "performance_parameters": ngct_performance_params, + } + } + + # Create a simple natural gas input profile (constant 575 MMBtu/h for 50 MW plant) + natural_gas_input = np.full(8760, 575.0) # MMBtu + + prob = om.Problem() + perf_comp = NaturalGasPerformanceModel( + plant_config=get_plant_config(), + tech_config=tech_config_dict, + ) + + prob.model.add_subsystem("ng_perf", perf_comp, promotes=["*"]) + prob.setup() + + # Set the natural gas input + prob.set_val("natural_gas_in", natural_gas_input) + prob.run_model() + + electricity_out = prob.get_val("electricity_out") + + with subtests.test("NGCT Electricity Output"): + # Expected: 575 MMBtu / 11.5 MMBtu/MWh = 50 MW + expected_output = natural_gas_input / ngct_performance_params["heat_rate_mmbtu_per_mwh"] + assert pytest.approx(electricity_out, rel=1e-6) == expected_output + + with subtests.test("NGCT Average Output"): + # Check average output is 50 MW + assert pytest.approx(np.mean(electricity_out), rel=1e-6) == 50.0 + + +def test_ngcc_cost(ngcc_cost_params, subtests): + """Test NGCC cost model calculations.""" + tech_config_dict = { + "model_inputs": { + "cost_parameters": ngcc_cost_params, + } + } + + # Plant parameters for a 100 MW NGCC plant + plant_capacity_mw = 100_000 # 100 MW + annual_generation_MWh = 700_000 # ~80% capacity factor + + # Create hourly electricity output that sums to annual generation + electricity_out = np.full(8760, annual_generation_MWh / 8760) # MW + + prob = om.Problem() + cost_comp = NaturalGasCostModel( + plant_config=get_plant_config(), + tech_config=tech_config_dict, + ) + + prob.model.add_subsystem("ng_cost", cost_comp, promotes=["*"]) + prob.setup() + + # Set inputs + prob.set_val("plant_capacity_mw", plant_capacity_mw) + prob.set_val("electricity_out", electricity_out) + prob.run_model() + + capex = prob.get_val("CapEx")[0] + opex = prob.get_val("OpEx")[0] + cost_year = prob.get_val("cost_year") + + # Calculate expected values + expected_capex = ngcc_cost_params["capex_per_kw"] * plant_capacity_mw * 1000.0 + expected_fixed_om = ngcc_cost_params["fixed_opex_per_kw_per_year"] * plant_capacity_mw * 1000.0 + expected_variable_om = ngcc_cost_params["variable_opex_per_mwh"] * annual_generation_MWh + expected_opex = expected_fixed_om + expected_variable_om + + with subtests.test("NGCC Capital Cost"): + assert pytest.approx(capex, rel=1e-6) == expected_capex + + with subtests.test("NGCC Operating Cost"): + assert pytest.approx(opex, rel=1e-6) == expected_opex + + with subtests.test("NGCC Cost Year"): + assert cost_year == ngcc_cost_params["cost_year"] + + +def test_ngct_cost(ngct_cost_params, subtests): + """Test NGCT cost model calculations.""" + tech_config_dict = { + "model_inputs": { + "cost_parameters": ngct_cost_params, + } + } + + # Plant parameters for a 50 MW NGCT plant + plant_capacity_mw = 50_000 # 50 MW + annual_generation_MWh = 100_000 # ~23% capacity factor (peaking plant) + + # Create hourly electricity output that sums to annual generation + electricity_out = np.full(8760, annual_generation_MWh / 8760) # MW + + prob = om.Problem() + cost_comp = NaturalGasCostModel( + plant_config=get_plant_config(), + tech_config=tech_config_dict, + ) + + prob.model.add_subsystem("ng_cost", cost_comp, promotes=["*"]) + prob.setup() + + # Set inputs + prob.set_val("plant_capacity_mw", plant_capacity_mw) + prob.set_val("electricity_out", electricity_out) + prob.run_model() + + capex = prob.get_val("CapEx")[0] + opex = prob.get_val("OpEx")[0] + cost_year = prob.get_val("cost_year") + + # Calculate expected values + expected_capex = ngct_cost_params["capex_per_kw"] * plant_capacity_mw * 1000.0 + expected_fixed_om = ngct_cost_params["fixed_opex_per_kw_per_year"] * plant_capacity_mw * 1000.0 + expected_variable_om = ngct_cost_params["variable_opex_per_mwh"] * annual_generation_MWh + expected_opex = expected_fixed_om + expected_variable_om + + with subtests.test("NGCT Capital Cost"): + assert pytest.approx(capex, rel=1e-6) == expected_capex + + with subtests.test("NGCT Operating Cost"): + assert pytest.approx(opex, rel=1e-6) == expected_opex + + with subtests.test("NGCT Cost Year"): + assert cost_year == ngct_cost_params["cost_year"] diff --git a/h2integrate/converters/solar/test/test_pysam_with_atb_costs.py b/h2integrate/converters/solar/test/test_pysam_with_atb_costs.py index dae00a464..51a24ac04 100644 --- a/h2integrate/converters/solar/test/test_pysam_with_atb_costs.py +++ b/h2integrate/converters/solar/test/test_pysam_with_atb_costs.py @@ -93,7 +93,30 @@ def residential_pv_performance_params(): return tech_params -def test_utility_pv_cost(utility_scale_pv_performance_params, solar_resource_dict, subtests): +@fixture +def plant_config(): + pv_resource_dir = EXAMPLE_DIR / "11_hybrid_energy_plant" / "tech_inputs" / "weather" / "solar" + pv_filename = "30.6617_-101.7096_psmv3_60_2013.csv" + pv_resource_dict = { + "latitude": 30.6617, + "longitude": -101.7096, + "year": 2013, + "solar_resource_filepath": pv_resource_dir / pv_filename, + } + return { + "plant": { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + }, + }, + "site": pv_resource_dict, + } + + +def test_utility_pv_cost( + utility_scale_pv_performance_params, solar_resource_dict, plant_config, subtests +): # costs from 2024_v3 ATB workbook using Solar - Utility PV costs # 2035 class 1 moderate cost_dict = { @@ -121,7 +144,7 @@ def test_utility_pv_cost(utility_scale_pv_performance_params, solar_resource_dic tech_config=tech_config_dict, ) cost_comp = ATBUtilityPVCostModel( - plant_config={}, + plant_config=plant_config, tech_config=tech_config_dict, ) @@ -145,7 +168,9 @@ def test_utility_pv_cost(utility_scale_pv_performance_params, solar_resource_dic ) -def test_commercial_pv_cost(commercial_pv_performance_params, solar_resource_dict, subtests): +def test_commercial_pv_cost( + commercial_pv_performance_params, solar_resource_dict, plant_config, subtests +): # costs from 2024_v3 ATB workbook using Solar - PV Dist. Comm costs # 2030 class 1 moderate cost_dict = { @@ -176,7 +201,7 @@ def test_commercial_pv_cost(commercial_pv_performance_params, solar_resource_dic tech_config=tech_config_dict, ) cost_comp = ATBResComPVCostModel( - plant_config={}, + plant_config=plant_config, tech_config=tech_config_dict, ) @@ -197,7 +222,9 @@ def test_commercial_pv_cost(commercial_pv_performance_params, solar_resource_dic ) -def test_residential_pv_cost(residential_pv_performance_params, solar_resource_dict, subtests): +def test_residential_pv_cost( + residential_pv_performance_params, solar_resource_dict, plant_config, subtests +): # costs from 2024_v3 ATB workbook using Solar - PV Dist. Res costs # 2030 class 1 moderate cost_dict = { @@ -228,7 +255,7 @@ def test_residential_pv_cost(residential_pv_performance_params, solar_resource_d tech_config=tech_config_dict, ) cost_comp = ATBResComPVCostModel( - plant_config={}, + plant_config=plant_config, tech_config=tech_config_dict, ) diff --git a/h2integrate/core/test/test_ro_desalination.py b/h2integrate/converters/water/desal/test/test_ro_desalination.py similarity index 91% rename from h2integrate/core/test/test_ro_desalination.py rename to h2integrate/converters/water/desal/test/test_ro_desalination.py index 86cb53a7d..a4da3ee28 100644 --- a/h2integrate/core/test/test_ro_desalination.py +++ b/h2integrate/converters/water/desal/test/test_ro_desalination.py @@ -77,8 +77,17 @@ def test_ro_desalination_cost(subtests): } } + plant_config = { + "plant": { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + }, + }, + } + prob = om.Problem() - comp = ReverseOsmosisCostModel(tech_config=tech_config) + comp = ReverseOsmosisCostModel(tech_config=tech_config, plant_config=plant_config) prob.model.add_subsystem("comp", comp, promotes=["*"]) prob.setup() diff --git a/h2integrate/core/feedstocks.py b/h2integrate/core/feedstocks.py index 9cce3857b..bfae066df 100644 --- a/h2integrate/core/feedstocks.py +++ b/h2integrate/core/feedstocks.py @@ -1,45 +1,95 @@ import numpy as np import openmdao.api as om +from attrs import field, define +from h2integrate.core.utilities import BaseConfig, CostModelBaseConfig, merge_shared_inputs +from h2integrate.core.model_baseclasses import CostModelBaseClass -class FeedstockComponent(om.ExplicitComponent): + +@define +class FeedstockPerformanceConfig(BaseConfig): + """Config class for feedstock. + + Attributes: + name (str): feedstock name + units (str): feedstock usage units (such as "galUS" or "kg") + rated_capacity (float): The rated capacity of the feedstock in `units`/hour. + This is used to size the feedstock supply to meet the plant's needs. + """ + + feedstock_type: str = field() + units: str = field() + rated_capacity: float = field() + + +class FeedstockPerformanceModel(om.ExplicitComponent): def initialize(self): - self.options.declare("feedstocks_config", types=dict) + self.options.declare("driver_config", types=dict) + self.options.declare("plant_config", types=dict) + self.options.declare("tech_config", types=dict) def setup(self): - self.feedstock_data = {} - for feedstock_name, feedstock_data in self.options["feedstocks_config"].items(): - self.feedstock_data[feedstock_name] = feedstock_data - self.add_output(feedstock_name, shape=(8760,), units=feedstock_data["capacity_units"]) - self.add_output(f"{feedstock_name}_CapEx", val=0.0, units="USD") - self.add_output(f"{feedstock_name}_OpEx", val=0.0, units="USD/yr") + self.config = FeedstockPerformanceConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance") + ) + n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + feedstock_type = self.config.feedstock_type - # Add total CapEx and OpEx outputs - self.add_output("CapEx", val=0.0, units="USD") - self.add_output("OpEx", val=0.0, units="USD/yr") + self.add_output(f"{feedstock_type}_out", shape=n_timesteps, units=self.config.units) def compute(self, inputs, outputs): - total_capex = 0.0 - total_opex = 0.0 - - for feedstock_name, feedstock_data in self.feedstock_data.items(): - rated_capacity = feedstock_data["rated_capacity"] - price = feedstock_data["price"] - - # Generate feedstock array operating at full capacity for the full year - outputs[feedstock_name] = np.full(8760, rated_capacity) - - # Calculate capex (given as $0) - capex = 0.0 - outputs[f"{feedstock_name}_CapEx"] = capex - total_capex += capex - - # Calculate opex based on the cost of feedstock and total feedstock used - total_feedstock_used = rated_capacity * 8760 - opex = total_feedstock_used * price - outputs[f"{feedstock_name}_OpEx"] = opex - total_opex += opex - - # Set total CapEx and OpEx - outputs["CapEx"] = total_capex - outputs["OpEx"] = total_opex + feedstock_type = self.config.feedstock_type + n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + # Generate feedstock array operating at full capacity for the full year + outputs[f"{feedstock_type}_out"] = np.full(n_timesteps, self.config.rated_capacity) + + +@define +class FeedstockCostConfig(CostModelBaseConfig): + """Config class for feedstock. + + Attributes: + name (str): feedstock name + units (str): feedstock usage units (such as "galUS" or "kg") + price (scalar or list): The cost of the feedstock in USD/`units`). + If scalar, cost is assumed to be constant for each timestep and each year. + If list, then it can be the cost per timestep of the simulation + + annual_cost (float, optional): fixed cost associated with the feedstock in USD/year + start_up_cost (float, optional): one-time capital cost associated with the feedstock in USD. + cost_year (int): dollar-year for costs. + """ + + feedstock_type: str = field() + units: str = field() + price: int | float | list = field() + annual_cost: float = field(default=0.0) + start_up_cost: float = field(default=0.0) + + +class FeedstockCostModel(CostModelBaseClass): + def setup(self): + self.config = FeedstockCostConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost") + ) + n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] + + super().setup() + + feedstock_type = self.config.feedstock_type + self.add_input( + f"{feedstock_type}_consumed", + val=0.0, + shape=int(n_timesteps), + units=self.config.units, + desc=f"Consumption profile of {feedstock_type}", + ) + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + feedstock_type = self.config.feedstock_type + price = self.config.price + hourly_consumption = inputs[f"{feedstock_type}_consumed"] + cost_per_year = sum(price * hourly_consumption) + + outputs["CapEx"] = self.config.start_up_cost + outputs["OpEx"] = self.config.annual_cost + cost_per_year diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 85730d076..b619b1265 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -7,7 +7,6 @@ from h2integrate.core.finances import AdjustedCapexOpexComp from h2integrate.core.utilities import create_xdsm_from_config -from h2integrate.core.feedstocks import FeedstockComponent from h2integrate.core.resource_summer import ElectricitySumComp from h2integrate.core.supported_models import supported_models, electricity_producing_techs from h2integrate.core.inputs.validation import load_tech_yaml, load_plant_yaml, load_driver_yaml @@ -189,9 +188,15 @@ def create_technology_models(self): # Create a technology group for each technology for tech_name, individual_tech_config in self.technology_config["technologies"].items(): - if "feedstocks" in tech_name: - feedstock_component = FeedstockComponent(feedstocks_config=individual_tech_config) - self.plant.add_subsystem(tech_name, feedstock_component) + perf_model = individual_tech_config.get("performance_model", {}).get("model") + + if perf_model is not None and "feedstock" in perf_model: + comp = self.supported_models[perf_model]( + driver_config=self.driver_config, + plant_config=self.plant_config, + tech_config=individual_tech_config, + ) + self.plant.add_subsystem(f"{tech_name}_source", comp) else: tech_group = self.plant.add_subsystem(tech_name, om.Group()) self.tech_names.append(tech_name) @@ -253,6 +258,16 @@ def create_technology_models(self): ) self.financial_models.append(financial_object) + for tech_name, individual_tech_config in self.technology_config["technologies"].items(): + cost_model = individual_tech_config.get("cost_model", {}).get("model") + if cost_model is not None and "feedstock" in cost_model: + comp = self.supported_models[cost_model]( + driver_config=self.driver_config, + plant_config=self.plant_config, + tech_config=individual_tech_config, + ) + self.plant.add_subsystem(tech_name, comp) + def _process_model(self, model_type, individual_tech_config, tech_group): # Generalized function to process model definitions model_name = individual_tech_config[model_type]["model"] @@ -462,6 +477,22 @@ def connect_technologies(self): # make the connection_name based on source, dest, item, type connection_name = f"{source_tech}_to_{dest_tech}_{transport_type}" + # Get the performance model of the source_tech + source_tech_config = self.technology_config["technologies"].get(source_tech, {}) + perf_model_name = source_tech_config.get("performance_model", {}).get("model") + cost_model_name = source_tech_config.get("cost_model", {}).get("model") + + # If the source is a feedstock, make sure to connect the amount of + # feedstock consumed from the technology back to the feedstock cost model + if cost_model_name is not None and "feedstock" in cost_model_name: + self.plant.connect( + f"{dest_tech}.{transport_item}_consumed", + f"{source_tech}.{transport_item}_consumed", + ) + + if perf_model_name is not None and "feedstock" in perf_model_name: + source_tech = f"{source_tech}_source" + # Create the transport object connection_component = self.supported_models[transport_type]( transport_item=transport_item diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index ed4a932a0..ba221006f 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -22,7 +22,13 @@ def initialize(self): def setup(self): # Define outputs: CapEx and OpEx costs self.add_output("CapEx", val=0.0, units="USD", desc="Capital expenditure") - self.add_output("OpEx", val=0.0, units="USD/year", desc="Operational expenditure") + self.add_output( + "OpEx", + val=0.0, + units="USD/year", + desc="Total (fixed and variable) operational expenditure", + ) + # Define discrete outputs: cost_year self.add_discrete_output( "cost_year", val=self.config.cost_year, desc="Dollar year for costs" diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index 1d2dec768..fd4a4cd70 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -1,4 +1,5 @@ from h2integrate.resource.river import RiverResource +from h2integrate.core.feedstocks import FeedstockCostModel, FeedstockPerformanceModel from h2integrate.transporters.pipe import PipePerformanceModel from h2integrate.transporters.cable import CablePerformanceModel from h2integrate.converters.steel.steel import SteelPerformanceModel, SteelCostAndFinancialModel @@ -49,6 +50,10 @@ CO2HMethanolPlantFinanceModel, CO2HMethanolPlantPerformanceModel, ) +from h2integrate.converters.natural_gas.natural_gas_cc_ct import ( + NaturalGasCostModel, + NaturalGasPerformanceModel, +) from h2integrate.converters.hydrogen.singlitico_cost_model import SingliticoCostModel from h2integrate.converters.co2.marine.direct_ocean_capture import DOCCostModel, DOCPerformanceModel from h2integrate.converters.hydrogen.eco_tools_pem_electrolyzer import ( @@ -125,6 +130,8 @@ "stimulated_geoh2_performance": StimulatedGeoH2PerformanceModel, "stimulated_geoh2_cost": StimulatedGeoH2CostModel, "stimulated_geoh2": StimulatedGeoH2FinanceModel, + "natural_gas_performance": NaturalGasPerformanceModel, + "natural_gas_cost": NaturalGasCostModel, # Transport "cable": CablePerformanceModel, "pipe": PipePerformanceModel, @@ -136,9 +143,12 @@ # Storage "hydrogen_tank_performance": HydrogenTankPerformanceModel, "hydrogen_tank_cost": HydrogenTankCostModel, + # Feedstock + "feedstock_performance": FeedstockPerformanceModel, + "feedstock_cost": FeedstockCostModel, "h2_storage": H2Storage, # Finance "ProFastComp": ProFastComp, } -electricity_producing_techs = ["wind", "solar", "pv", "river", "hopp"] +electricity_producing_techs = ["wind", "solar", "pv", "river", "hopp", "natural_gas_plant"] diff --git a/h2integrate/core/test/test_feedstocks.py b/h2integrate/core/test/test_feedstocks.py new file mode 100644 index 000000000..9465181fb --- /dev/null +++ b/h2integrate/core/test/test_feedstocks.py @@ -0,0 +1,273 @@ +""" +Tests for feedstock performance and cost models. + +These tests validate the feedstock components that provide resource inputs to technologies, +including natural gas, electricity, water, and other feedstock types. +""" + +import unittest +from pathlib import Path + +import numpy as np +import openmdao.api as om + +from h2integrate.core.feedstocks import FeedstockCostModel, FeedstockPerformanceModel + + +class TestFeedstocks(unittest.TestCase): + """Test cases for feedstock models.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_dir = Path(__file__).parent / "test_feedstock_configs" + self.test_dir.mkdir(exist_ok=True) + + def tearDown(self): + """Clean up test files.""" + if self.test_dir.exists(): + import shutil + + shutil.rmtree(self.test_dir) + + def create_basic_feedstock_config( + self, + feedstock_type="natural_gas", + units="MMBtu", + rated_capacity=100.0, + price=4.2, + annual_cost=0.0, + start_up_cost=100000.0, + ): + """Create a basic feedstock configuration for testing.""" + tech_config = { + "model_inputs": { + "shared_parameters": { + "feedstock_type": feedstock_type, + "units": units, + }, + "performance_parameters": { + "rated_capacity": rated_capacity, + }, + "cost_parameters": { + "price": price, + "annual_cost": annual_cost, + "start_up_cost": start_up_cost, + "cost_year": 2023, + }, + } + } + + plant_config = {"plant": {"plant_life": 30, "simulation": {"n_timesteps": 8760}}} + + driver_config = {} + + return tech_config, plant_config, driver_config + + def test_single_feedstock_natural_gas(self): + """Test a single natural gas feedstock with basic parameters.""" + tech_config, plant_config, driver_config = self.create_basic_feedstock_config() + + # Test performance model + perf_model = FeedstockPerformanceModel() + perf_model.options["tech_config"] = tech_config + perf_model.options["plant_config"] = plant_config + perf_model.options["driver_config"] = driver_config + + prob = om.Problem() + prob.model.add_subsystem("feedstock_perf", perf_model) + prob.setup() + prob.run_model() + + # Check that output is generated correctly + ng_output = prob.get_val("feedstock_perf.natural_gas_out") + self.assertEqual(len(ng_output), 8760) + self.assertTrue(np.all(ng_output == 100.0)) # rated_capacity + + # Test cost model + cost_model = FeedstockCostModel() + cost_model.options["tech_config"] = tech_config + cost_model.options["plant_config"] = plant_config + cost_model.options["driver_config"] = driver_config + + prob_cost = om.Problem() + prob_cost.model.add_subsystem("feedstock_cost", cost_model) + prob_cost.setup() + + # Set some consumption values + consumption = np.full(8760, 50.0) # 50 MMBtu/hour + prob_cost.set_val("feedstock_cost.natural_gas_consumed", consumption) + prob_cost.run_model() + + # Check outputs + capex = prob_cost.get_val("feedstock_cost.CapEx")[0] + opex = prob_cost.get_val("feedstock_cost.OpEx")[0] + + self.assertEqual(capex, 100000.0) # start_up_cost + expected_opex = 0.0 + 4.2 * consumption.sum() # annual_cost + price * consumption + self.assertAlmostEqual(opex, expected_opex, places=5) + + def test_multiple_same_type_feedstocks(self): + """Test multiple feedstocks of the same type with different parameters.""" + # Test two natural gas feedstocks with different capacities and prices + tech_config1, plant_config, driver_config = self.create_basic_feedstock_config( + rated_capacity=50.0, price=4.0, start_up_cost=50000.0 + ) + tech_config2, _, _ = self.create_basic_feedstock_config( + rated_capacity=150.0, price=4.5, start_up_cost=150000.0 + ) + + # Test both feedstocks can coexist and have different outputs + perf_model1 = FeedstockPerformanceModel() + perf_model1.options.update( + { + "tech_config": tech_config1, + "plant_config": plant_config, + "driver_config": driver_config, + } + ) + + perf_model2 = FeedstockPerformanceModel() + perf_model2.options.update( + { + "tech_config": tech_config2, + "plant_config": plant_config, + "driver_config": driver_config, + } + ) + + prob = om.Problem() + prob.model.add_subsystem("feedstock1", perf_model1) + prob.model.add_subsystem("feedstock2", perf_model2) + prob.setup() + prob.run_model() + + ng_output1 = prob.get_val("feedstock1.natural_gas_out") + ng_output2 = prob.get_val("feedstock2.natural_gas_out") + + self.assertTrue(np.all(ng_output1 == 50.0)) + self.assertTrue(np.all(ng_output2 == 150.0)) + + def test_multiple_different_type_feedstocks(self): + """Test feedstocks of different types (natural gas, electricity, water).""" + # Natural gas feedstock + ng_config, plant_config, driver_config = self.create_basic_feedstock_config( + feedstock_type="natural_gas", units="MMBtu", rated_capacity=100.0, price=4.2 + ) + + # Electricity feedstock + elec_config, _, _ = self.create_basic_feedstock_config( + feedstock_type="electricity", units="MW*h", rated_capacity=50.0, price=0.05 + ) + + # Water feedstock + water_config, _, _ = self.create_basic_feedstock_config( + feedstock_type="water", units="galUS", rated_capacity=1000.0, price=0.001 + ) + + # Test all three feedstock types + perf_ng = FeedstockPerformanceModel() + perf_ng.options.update( + {"tech_config": ng_config, "plant_config": plant_config, "driver_config": driver_config} + ) + + perf_elec = FeedstockPerformanceModel() + perf_elec.options.update( + { + "tech_config": elec_config, + "plant_config": plant_config, + "driver_config": driver_config, + } + ) + + perf_water = FeedstockPerformanceModel() + perf_water.options.update( + { + "tech_config": water_config, + "plant_config": plant_config, + "driver_config": driver_config, + } + ) + + prob = om.Problem() + prob.model.add_subsystem("ng_feedstock", perf_ng) + prob.model.add_subsystem("elec_feedstock", perf_elec) + prob.model.add_subsystem("water_feedstock", perf_water) + prob.setup() + prob.run_model() + + # Check outputs + ng_out = prob.get_val("ng_feedstock.natural_gas_out") + elec_out = prob.get_val("elec_feedstock.electricity_out") + water_out = prob.get_val("water_feedstock.water_out") + + self.assertTrue(np.all(ng_out == 100.0)) + self.assertTrue(np.all(elec_out == 50.0)) + self.assertTrue(np.all(water_out == 1000.0)) + + def test_variable_pricing(self): + """Test feedstock with variable pricing (array of prices).""" + # Create hourly price array that varies throughout the year + hourly_prices = np.full(8760, 4.2) + # Add some variation - higher prices during peak hours + for i in range(8760): + hour_of_day = i % 24 + if 16 <= hour_of_day <= 20: # Peak hours + hourly_prices[i] = 6.0 + elif 22 <= hour_of_day or hour_of_day <= 6: # Off-peak hours + hourly_prices[i] = 3.0 + + tech_config, plant_config, driver_config = self.create_basic_feedstock_config( + price=hourly_prices.tolist() + ) + + cost_model = FeedstockCostModel() + cost_model.options["tech_config"] = tech_config + cost_model.options["plant_config"] = plant_config + cost_model.options["driver_config"] = driver_config + + prob = om.Problem() + prob.model.add_subsystem("feedstock_cost", cost_model) + prob.setup() + + # Set consumption pattern + consumption = np.full(8760, 30.0) # 30 MMBtu/hour + prob.set_val("feedstock_cost.natural_gas_consumed", consumption) + prob.run_model() + + # Check that OpEx reflects variable pricing + opex = prob.get_val("feedstock_cost.OpEx")[0] + expected_opex = 0.0 + np.sum(hourly_prices * consumption) + self.assertAlmostEqual(opex, expected_opex, places=5) + + # OpEx should be different from constant pricing + constant_price_opex = 0.0 + 4.2 * consumption.sum() + self.assertNotAlmostEqual(opex, constant_price_opex, places=2) + + def test_zero_cost_feedstock(self): + """Test feedstock with zero costs (free resource).""" + tech_config, plant_config, driver_config = self.create_basic_feedstock_config( + price=0.0, annual_cost=0.0, start_up_cost=0.0 + ) + + cost_model = FeedstockCostModel() + cost_model.options["tech_config"] = tech_config + cost_model.options["plant_config"] = plant_config + cost_model.options["driver_config"] = driver_config + + prob = om.Problem() + prob.model.add_subsystem("feedstock_cost", cost_model) + prob.setup() + + consumption = np.full(8760, 100.0) + prob.set_val("feedstock_cost.natural_gas_consumed", consumption) + prob.run_model() + + capex = prob.get_val("feedstock_cost.CapEx")[0] + opex = prob.get_val("feedstock_cost.OpEx")[0] + + self.assertEqual(capex, 0.0) + self.assertEqual(opex, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/h2integrate/transporters/pipe.py b/h2integrate/transporters/pipe.py index f1ef7300d..73e61b527 100644 --- a/h2integrate/transporters/pipe.py +++ b/h2integrate/transporters/pipe.py @@ -8,25 +8,33 @@ class PipePerformanceModel(om.ExplicitComponent): def initialize(self): self.options.declare( - "transport_item", values=["hydrogen", "co2", "methanol", "ammonia", "nitrogen"] + "transport_item", + values=["hydrogen", "co2", "methanol", "ammonia", "nitrogen", "natural_gas"], ) def setup(self): - self.input_name = self.options["transport_item"] + "_in" - self.output_name = self.options["transport_item"] + "_out" + transport_item = self.options["transport_item"] + self.input_name = transport_item + "_in" + self.output_name = transport_item + "_out" + + if transport_item == "natural_gas": + units = "MMBtu" + else: + units = "kg/s" + self.add_input( self.input_name, val=0.0, shape_by_conn=True, copy_shape=self.output_name, - units="kg/s", + units=units, ) self.add_output( self.output_name, val=0.0, shape_by_conn=True, copy_shape=self.input_name, - units="kg/s", + units=units, ) def compute(self, inputs, outputs): diff --git a/tests/h2integrate/test_all_examples.py b/tests/h2integrate/test_all_examples.py index c2e55db7c..07defb67a 100644 --- a/tests/h2integrate/test_all_examples.py +++ b/tests/h2integrate/test_all_examples.py @@ -555,17 +555,100 @@ def test_wind_wave_oae_example_with_financials(subtests): assert pytest.approx(model.prob.get_val("oae.carbon_credit_value"), rel=1e-3) == 569.5 +def test_natural_gas_example(subtests): + # Change the current working directory to the example's directory + os.chdir(EXAMPLE_DIR / "16_natural_gas") + + # Create a H2Integrate model + model = H2IntegrateModel(Path.cwd() / "natgas.yaml") + + # Run the model + + model.run() + + model.post_process() + + # Subtests for checking specific values + + with subtests.test("Check CapEx"): + capex = model.prob.get_val("natural_gas_plant.CapEx")[0] + assert pytest.approx(capex, rel=1e-6) == 1e8 + + with subtests.test("Check OpEx"): + opex = model.prob.get_val("natural_gas_plant.OpEx")[0] + assert pytest.approx(opex, rel=1e-6) == 1292000.0 + + with subtests.test("Check total electricity produced"): + total_electricity = model.prob.get_val( + "financials_group_default.electricity_sum.total_electricity_produced" + )[0] + assert pytest.approx(total_electricity, rel=1e-6) == 1.168e8 + + with subtests.test("Check opex adjusted ng_feedstock"): + opex_ng_feedstock = model.prob.get_val( + "financials_group_default.opex_adjusted_ng_feedstock" + )[0] + assert pytest.approx(opex_ng_feedstock, rel=1e-6) == 3589463.41463415 + + with subtests.test("Check capex adjusted natural_gas_plant"): + capex_ng_plant = model.prob.get_val( + "financials_group_default.capex_adjusted_natural_gas_plant" + )[0] + assert pytest.approx(capex_ng_plant, rel=1e-6) == 97560975.60975611 + + with subtests.test("Check opex adjusted natural_gas_plant"): + opex_ng_plant = model.prob.get_val( + "financials_group_default.opex_adjusted_natural_gas_plant" + )[0] + assert pytest.approx(opex_ng_plant, rel=1e-6) == 1260487.80487805 + + with subtests.test("Check total adjusted CapEx"): + total_capex = model.prob.get_val("financials_group_default.total_capex_adjusted")[0] + assert pytest.approx(total_capex, rel=1e-6) == 97658536.58536586 + + with subtests.test("Check total adjusted OpEx"): + total_opex = model.prob.get_val("financials_group_default.total_opex_adjusted")[0] + assert pytest.approx(total_opex, rel=1e-6) == 4849951.2195122 + + with subtests.test("Check LCOE"): + lcoe = model.prob.get_val("financials_group_default.LCOE")[0] + assert pytest.approx(lcoe, rel=1e-6) == 0.12959097 + + # Test feedstock-specific values + with subtests.test("Check feedstock output"): + ng_output = model.prob.get_val("ng_feedstock_source.natural_gas_out") + # Should be rated capacity (100 MMBtu) for all timesteps + assert all(ng_output == 100.0) + + with subtests.test("Check feedstock consumption"): + ng_consumed = model.prob.get_val("ng_feedstock.natural_gas_consumed") + # Total consumption should match what the natural gas plant uses + expected_consumption = ( + model.prob.get_val("natural_gas_plant.electricity_out") * 7.5 + ) # Convert MWh to MMBtu using heat rate + assert pytest.approx(ng_consumed.sum(), rel=1e-3) == expected_consumption.sum() + + with subtests.test("Check feedstock CapEx"): + ng_capex = model.prob.get_val("ng_feedstock.CapEx")[0] + assert pytest.approx(ng_capex, rel=1e-6) == 100000.0 # start_up_cost + + with subtests.test("Check feedstock OpEx"): + ng_opex = model.prob.get_val("ng_feedstock.OpEx")[0] + # OpEx should be annual_cost (0) + price * consumption + ng_consumed = model.prob.get_val("ng_feedstock.natural_gas_consumed") + expected_opex = 4.2 * ng_consumed.sum() # price = 4.2 $/MMBtu + assert pytest.approx(ng_opex, rel=1e-6) == expected_opex + + def test_wind_solar_electrolyzer_example(subtests): # Change the current working directory to the example's directory os.chdir(EXAMPLE_DIR / "15_wind_solar_electrolyzer") # Create a H2Integrate model model = H2IntegrateModel(Path.cwd() / "15_wind_solar_electrolyzer.yaml") - model.run() model.post_process() - with subtests.test("Check LCOE"): assert ( pytest.approx(