From e677ac2f2cbe0f5999b34ed74a6f1660a91744bc Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:21:50 -0600 Subject: [PATCH] added feedstock cost model and tests and updated costmodelbaseclass --- h2integrate/core/feedstocks.py | 128 ++++++++++ h2integrate/core/model_baseclasses.py | 12 +- h2integrate/core/test/test_feedstock.py | 305 ++++++++++++++++++++++++ 3 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 h2integrate/core/test/test_feedstock.py diff --git a/h2integrate/core/feedstocks.py b/h2integrate/core/feedstocks.py index 9cce3857b..02d57987f 100644 --- a/h2integrate/core/feedstocks.py +++ b/h2integrate/core/feedstocks.py @@ -1,5 +1,133 @@ import numpy as np import openmdao.api as om +from attrs import field, define + +from h2integrate.core.utilities import CostModelBaseConfig, merge_shared_inputs +from h2integrate.core.model_baseclasses import CostModelBaseClass + + +@define +class FeedstockCostConfig(CostModelBaseConfig): + """Config class for feedstock. + + Attributes: + name (str): feedstock name + units (str): feedstock usage units (such as "galUS" or "kg") + cost (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 + or per year of the plant life. + + 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. + """ + + name: str = field() + units: str = field() + cost: int | float | list = field() + annual_cost: float = field(default=0) + start_up_cost: float = field(default=0) + + +class FeedstockCostModel(CostModelBaseClass): + def initialize(self): + super().initialize() + self.options.declare("feedstock_name", types=str) + return + + def setup(self): + self.config = FeedstockCostConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost") + ) + self.n_timesteps = n_timesteps = self.options["plant_config"]["plant"]["simulation"][ + "n_timesteps" + ] + + super().setup() + + self.add_input( + f"{self.config.name}_consumed", + val=0.0, + shape=n_timesteps, + units=self.config.units, + desc=f"Consumption profile of {self.config.name}", + ) + + self.add_input( + f"total_{self.config.name}_consumed", + val=0.0, + shape=self.plant_life, + units=f"{self.config.units}/yr", + desc=f"Annual consumption of {self.config.name}", + ) + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + has_annual_consumption = True + has_timestep_consumption = True + if all(c == 0.0 for c in inputs[f"total_{self.config.name}_consumed"]): + has_annual_consumption = False + if all(c == 0.0 for c in inputs[f"{self.config.name}_consumed"]): + has_timestep_consumption = False + + if self.config.start_up_cost > 0: + outputs["CapEx"] = self.config.start_up_cost + if self.config.annual_cost > 0: + outputs["OpEx"] = self.config.annual_cost + + cost_per_year = None + if isinstance(self.config.cost, list): + if len(self.config.cost) == self.plant_life and has_annual_consumption: + annual_consumption = inputs[f"total_{self.config.name}_consumed"] + cost_per_year = [ + cost * cons for cost, cons in zip(self.config.cost, annual_consumption) + ] + outputs["VarOpEx"] = cost_per_year + return + elif len(self.config.cost) == self.n_timesteps and has_timestep_consumption: + hourly_consumption = inputs[f"{self.config.name}_consumed"] + cost_per_year = sum( + [cost * cons for cost, cons in zip(self.config.cost, hourly_consumption)] + ) + outputs["VarOpEx"] = [cost_per_year] * self.plant_life + return + else: + msg = "Missing input or incorrect length of cost input" + if len(self.config.cost) == self.plant_life: + msg = ( + f"Cost of {self.config.name} provided on annual basis ({self.plant_life} " + f"entries) but annual consumption was not input" + f"(`total_{self.config.name}_consumed` has values of zero)" + ) + if len(self.config.cost) == self.n_timesteps: + msg = ( + f"Cost of {self.config.name} provided per time-step ({self.n_timesteps} " + f"entries) but hourly consumption was not input" + f" (`{self.config.name}_consumed` has values of zero)" + ) + if ( + len(self.config.cost) != self.plant_life + and len(self.config.cost) != self.n_timesteps + ): + msg = ( + f"Costs provided for {self.config.name} must either have a length of " + f"{self.plant_life} or {self.n_timesteps}, but list of costs has length " + f"of {len(self.config.cost)}." + ) + raise ValueError(msg) + else: + if has_annual_consumption: + cost_per_year = [ + self.config.cost * c for c in inputs[f"total_{self.config.name}_consumed"] + ] + outputs["VarOpEx"] = cost_per_year + return + if has_timestep_consumption: + cost_per_year = [ + sum(inputs[f"{self.config.name}_consumed"]) * self.config.cost + ] * self.plant_life + outputs["VarOpEx"] = cost_per_year + return class FeedstockComponent(om.ExplicitComponent): diff --git a/h2integrate/core/model_baseclasses.py b/h2integrate/core/model_baseclasses.py index ed4a932a0..b42b17963 100644 --- a/h2integrate/core/model_baseclasses.py +++ b/h2integrate/core/model_baseclasses.py @@ -20,9 +20,19 @@ def initialize(self): self.options.declare("tech_config", types=dict) def setup(self): + self.plant_life = plant_life = int(self.options["plant_config"]["plant"]["plant_life"]) + # 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="Fixed operational expenditure") + self.add_output( + "VarOpEx", + val=0.0, + shape=plant_life, + units="USD/year", + desc="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/test/test_feedstock.py b/h2integrate/core/test/test_feedstock.py new file mode 100644 index 000000000..080086115 --- /dev/null +++ b/h2integrate/core/test/test_feedstock.py @@ -0,0 +1,305 @@ +import numpy as np +import pytest +import openmdao.api as om +from pytest import fixture + +from h2integrate.core.feedstocks import FeedstockCostModel + + +plant_life = 30 +n_timesteps = 8760 +min_cost = 5.0 +max_cost = 10.0 +avg_cost = (min_cost + max_cost) / 2 + +min_consumption = 0.1 +max_consumption = 0.3 +avg_consumption = (min_consumption + max_consumption) / 2 + + +@fixture +def comp_scalar_cost(): + plant_config = { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + "dt": 3600, + }, + } + feedstock_cost_input = { + "water_feedstock": { + "name": "water", + "units": "galUS", + "cost": 7.5, + "cost_year": 2022, + } + } + + tech_config = {"model_inputs": {"cost_parameters": feedstock_cost_input["water_feedstock"]}} + + return plant_config, tech_config + + +@fixture +def comp_cost_per_year(): + cost_profile = [7.5] * 30 + + plant_config = { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + "dt": 3600, + }, + } + feedstock_cost_input = { + "water_feedstock": { + "name": "water", + "units": "galUS", + "cost": cost_profile, + "cost_year": 2022, + } + } + + tech_config = {"model_inputs": {"cost_parameters": feedstock_cost_input["water_feedstock"]}} + + return plant_config, tech_config + + +@fixture +def comp_cost_per_hour(): + # cost_profile = 5.0*np.ones(8760) + # cost_profile[0:8760:2] = 10.0 + cost_profile = 7.5 * np.ones(8760) + plant_config = { + "plant_life": 30, + "simulation": { + "n_timesteps": 8760, + "dt": 3600, + }, + } + feedstock_cost_input = { + "water_feedstock": { + "name": "water", + "units": "galUS", + "cost": cost_profile.tolist(), + "cost_year": 2022, + } + } + + tech_config = {"model_inputs": {"cost_parameters": feedstock_cost_input["water_feedstock"]}} + + return plant_config, tech_config + + +def create_prob(comp, input_hourly: bool, input_annual: bool, constant_usage: bool): + hourly_water_usage = [0.2] * 8760 + annual_water_usage = sum(hourly_water_usage) # [sum(hourly_water_usage)]*30 + + variable_hourly_water_usage = 0.1 * np.ones(8760) + variable_hourly_water_usage[0:8760:2] = 0.3 + + year_even = 0.1 * 8760 + year_odd = 0.3 * 8760 + + variable_annual_water_usage = year_even * np.ones(30) + variable_annual_water_usage[0:30:2] = year_odd + + prob = om.Problem() + prob.model.add_subsystem("water", comp) + prob.setup() + + if constant_usage: + if input_hourly: + prob.set_val("water.water_consumed", hourly_water_usage, units="galUS") + if input_annual: + prob.set_val("water.total_water_consumed", annual_water_usage, units="galUS/yr") + else: + if input_hourly: + prob.set_val("water.water_consumed", variable_hourly_water_usage, units="galUS") + if input_annual: + prob.set_val( + "water.total_water_consumed", variable_annual_water_usage, units="galUS/yr" + ) + + return prob + + +def test_costs_per_year(comp_cost_per_year, subtests): + plant_config, tech_config = comp_cost_per_year + + constant_cost_per_year = 7.5 * 0.2 * 8760 + total_varom = constant_cost_per_year * 30 + test_matrix = { + # should never throw error + "Both/Constant": [True, True, True], + "Both/Variable": [True, True, False], + # should throw error + "Hourly/Constant": [True, False, True], + "Hourly/Variable": [True, False, False], + # should work here + "Annual/Constant": [False, True, True], + "Annual/Variable": [False, True, False], + # should throw error + "Neither/Constant": [False, False, False], + } + + for test_name, input_params in test_matrix.items(): + input_desc, profile_desc = test_name.split("/") + comp = FeedstockCostModel( + plant_config={"plant": plant_config}, + tech_config=tech_config, + driver_config={}, + feedstock_name="water_feedstock", + ) + prob = create_prob(comp, *input_params) + + if input_desc == "Hourly" or input_desc == "Neither": + if input_desc == "Neither": + with pytest.raises(ValueError, match="Cost of water provided on annual"): + prob.run_model() + else: + with pytest.raises(ValueError, match="Cost of water provided on annual"): + prob.run_model() + else: + prob.run_model() + + varom = prob.get_val("water.VarOpEx") + if profile_desc == "Constant": + if input_desc == "Annual" or input_desc == "Both": + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: VarOpEx per year" + ): + assert all( + pytest.approx(v, rel=1e-6) == constant_cost_per_year for v in varom + ) + + if profile_desc == "Variable": + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: total VarOpEx" + ): + assert pytest.approx(sum(varom), rel=1e-6) == total_varom + + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: VarOpex compare year" + ): + assert varom[0] > varom[1] + + +def test_costs_per_timestep(comp_cost_per_hour, subtests): + plant_config, tech_config = comp_cost_per_hour + + constant_cost_per_year = 7.5 * 0.2 * 8760 + total_varom = constant_cost_per_year * 30 + test_matrix = { + # should never throw error + "Both/Constant": [True, True, True], + "Both/Variable": [True, True, False], + # should work here + "Hourly/Constant": [True, False, True], + "Hourly/Variable": [True, False, False], + # should throw errors + "Annual/Constant": [False, True, True], + "Annual/Variable": [False, True, False], + # should throw error + "Neither/Constant": [False, False, False], + } + + for test_name, input_params in test_matrix.items(): + input_desc, profile_desc = test_name.split("/") + comp = FeedstockCostModel( + plant_config={"plant": plant_config}, + tech_config=tech_config, + driver_config={}, + feedstock_name="water_feedstock", + ) + prob = create_prob(comp, *input_params) + + if input_desc == "Annual" or input_desc == "Neither": + with pytest.raises(ValueError, match="Cost of water provided per time-step"): + prob.run_model() + + else: + prob.run_model() + + varom = prob.get_val("water.VarOpEx") + if profile_desc == "Constant": + if input_desc == "Hourly" or input_desc == "Both": + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: VarOpEx per year" + ): + assert all( + pytest.approx(v, rel=1e-6) == constant_cost_per_year for v in varom + ) + + if profile_desc == "Variable": + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: total VarOpEx" + ): + assert pytest.approx(sum(varom), rel=1e-6) == total_varom + + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: VarOpex per year" + ): + assert all(pytest.approx(v, rel=1e-6) == constant_cost_per_year for v in varom) + + +def test_flat_cost(comp_scalar_cost, subtests): + plant_config, tech_config = comp_scalar_cost + + constant_cost_per_year = 7.5 * 0.2 * 8760 + total_varom = constant_cost_per_year * 30 + test_matrix = { + # should never throw error + "Both/Constant": [True, True, True], # never throws error + "Both/Variable": [True, True, False], # never throws error + # should work here + "Hourly/Constant": [True, False, True], + "Hourly/Variable": [True, False, False], + # should work here + "Annual/Constant": [False, True, True], + "Annual/Variable": [False, True, False], + # should give zeros + "Neither/Constant": [False, False, False], # results in zero + } + + for test_name, input_params in test_matrix.items(): + input_desc, profile_desc = test_name.split("/") + comp = FeedstockCostModel( + plant_config={"plant": plant_config}, + tech_config=tech_config, + driver_config={}, + feedstock_name="water_feedstock", + ) + prob = create_prob(comp, *input_params) + + prob.run_model() + + varom = prob.get_val("water.VarOpEx") + if profile_desc == "Constant": + if input_desc == "Neither": + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: VarOpEx per year" + ): + assert all(pytest.approx(v, rel=1e-6) == 0.0 for v in varom) + else: + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: VarOpEx per year" + ): + assert all(pytest.approx(v, rel=1e-6) == constant_cost_per_year for v in varom) + + if profile_desc == "Variable": + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: total VarOpEx" + ): + assert pytest.approx(sum(varom), rel=1e-6) == total_varom + + if input_desc == "Hourly": + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: VarOpex per year" + ): + assert all(pytest.approx(v, abs=0.1) == constant_cost_per_year for v in varom) + else: + with subtests.test( + f"{input_desc} input(s) provided and {profile_desc} usage: VarOpex compare year" + ): + assert varom[0] > varom[1]