diff --git a/electrolyzer/components/cell/cell_design_params.py b/electrolyzer/components/cell/cell_design_params.py new file mode 100644 index 0000000..bcd82fd --- /dev/null +++ b/electrolyzer/components/cell/cell_design_params.py @@ -0,0 +1,20 @@ +cell_base_design_params = { + "A_cell", + "membrane_thickness", + "operating_temperature", + "anode_pressure", + "cathode_pressure", + "R_elec", +} + +params_per_cell = { + "PEMCell": {"i_0a", "i_0c", "alpha_a", "alpha_c", "b_combined", "i_0combined", "f1", "f2"}, +} + + +def get_cell_params_for_model(cell_model_name: str | None) -> list[str]: + if cell_model_name is None: + raise ValueError(f"{cell_model_name} was not input") + if cell_model_name not in params_per_cell: + raise ValueError(f"{cell_model_name} is not a valid cell model") + return list(params_per_cell.get(cell_model_name) | cell_base_design_params) diff --git a/electrolyzer/components/cell/pem_cell.py b/electrolyzer/components/cell/pem_cell.py index 4995577..cf036cc 100644 --- a/electrolyzer/components/cell/pem_cell.py +++ b/electrolyzer/components/cell/pem_cell.py @@ -111,17 +111,20 @@ def setup(self): super().setup() - # self.add_input("H2_demand", val=0.0, shape_by_conn=True, units="g/s") - # self.add_output("I_demand", val=0.0, copy_shape="H2_demand", units="A") - # self.add_output("J_demand", val=0.0, copy_shape="H2_demand", units="A/(cm**2)") # Design parameters if self.config.kinetics_method == "per_electrode": self.add_input("i_0a", val=self.config.i_0a, shape=1, units="A/(cm**2)") self.add_input("i_0c", val=self.config.i_0c, shape=1, units="A/(cm**2)") self.add_input("alpha_a", val=self.config.alpha_a, shape=1, units="unitless") self.add_input("alpha_c", val=self.config.alpha_c, shape=1, units="unitless") + self.add_input("b_combined", val=0, shape=1, units="V") + self.add_input("i_0combined", val=0, shape=1, units="A/(cm**2)") else: # b_combined is in V/decade + self.add_input("i_0a", val=0, shape=1, units="A/(cm**2)") + self.add_input("i_0c", val=0, shape=1, units="A/(cm**2)") + self.add_input("alpha_a", val=0, shape=1, units="unitless") + self.add_input("alpha_c", val=0, shape=1, units="unitless") self.add_input("b_combined", val=self.config.b_combined, shape=1, units="V") self.add_input("i_0combined", val=self.config.i0_combined, shape=1, units="A/(cm**2)") diff --git a/electrolyzer/core/bert.py b/electrolyzer/core/bert.py index e9746e2..fb64f7a 100644 --- a/electrolyzer/core/bert.py +++ b/electrolyzer/core/bert.py @@ -6,6 +6,7 @@ from electrolyzer.core.file_utils import load_yaml from electrolyzer.core.supported_models import supported_models +from electrolyzer.components.cell.cell_design_params import get_cell_params_for_model class State(IntEnum): @@ -43,6 +44,18 @@ def load_config(self, config_input): self.plant_config = {"simulation": simulation_config} self.n_clusters = system_config["n_clusters"] self.control_var = system_config["control_variable"] + if "cell" in config: + # clusters have identical cell models + self.identical_cells = True + else: + self.identical_cells = False + msg = ( + "The ability to have clusters with different cell designs is not yet " + "available. Please ensure your config has a ``cell`` section with " + "the cell model and design parameters" + ) + raise NotImplementedError(msg) + self.config = config if self.control_var == "power": self.control_passed_var = "P" @@ -84,24 +97,34 @@ def create_controller(self): def create_components(self): # - # Step 0: Create cluster groups + + # Get the design parameters of the cell + cell_design_params = get_cell_params_for_model(self.config["cell"].get("model", None)) + + # Step 1: Create cluster groups clusters = [] cluster_i = 0 - cluster_group = self.plant.add_subsystem(f"Cluster{cluster_i}", om.Group()) + + # NOTE: cell design params should only be promoted if all the clusters are identical + if self.identical_cells: + cluster_group = self.plant.add_subsystem( + f"Cluster{cluster_i}", om.Group(), promotes=cell_design_params + ) + else: + cluster_group = self.plant.add_subsystem(f"Cluster{cluster_i}", om.Group()) clusters.append(cluster_group) - # Step 1: Create controller cluster connector components - pre_translator = self.create_controller_cluster_connector() + # Step 2: Create controller cluster connector components + pre_translator = self.create_controller_cluster_connector(cell_design_params) # Translator has scale down + power to current conversion translator = self.create_controller_translator() - # Step 2: Create the simulate block of a cluster - simulator = self.create_cluster_simulation_block() + # Step 3: Create the simulate block of a cluster + simulator = self.create_cluster_simulation_block(cell_design_params) - cluster_group.add_subsystem( - "converter", pre_translator, promotes=["A_cell", "I_min", "I_max"] - ) + promotion_vars = [*cell_design_params, "I_min", "I_max"] + cluster_group.add_subsystem("converter", pre_translator, promotes=promotion_vars) cluster_group.add_subsystem("translator", translator, promotes=["n_stacks", "n_cells"]) - cluster_group.add_subsystem("simulation", simulator, promotes=["I_min", "I_max", "A_cell"]) + cluster_group.add_subsystem("simulation", simulator, promotes=promotion_vars) # simulation.dynamics gets I_min and I_max from the converter outputs # Connect the converter bounds to the @@ -115,7 +138,7 @@ def create_components(self): self.clusters = clusters - def create_cluster_simulation_block(self): + def create_cluster_simulation_block(self, cell_design_params): simulation = om.Group() cell_nom = self.create_cell_model() @@ -124,9 +147,9 @@ def create_cluster_simulation_block(self): dynamics = self.create_component("dynamics") simulation.add_subsystem("dynamics", dynamics, promotes=["I_min", "I_max"]) - simulation.add_subsystem("cell_nominal", cell_nom, promotes=["A_cell"]) + simulation.add_subsystem("cell_nominal", cell_nom, promotes=cell_design_params) simulation.add_subsystem("degradation", degradation) - simulation.add_subsystem("cell_real", cell_real, promotes=["A_cell"]) + simulation.add_subsystem("cell_real", cell_real, promotes=cell_design_params) # connect dynamics current output to nominal cell current input simulation.connect("dynamics.I_out", "cell_nominal.I_in") @@ -174,7 +197,7 @@ def create_controller_translator(self): return translator - def create_controller_cluster_connector(self): + def create_controller_cluster_connector(self, cell_design_params): pre_converter_grp = om.Group() bounds_comp = self.create_bounds_component() pre_converter_grp.add_subsystem( @@ -185,7 +208,7 @@ def create_controller_cluster_connector(self): ) cell = self.create_cell_model() - pre_converter_grp.add_subsystem("ref_cell", cell, promotes_inputs=["A_cell"]) + pre_converter_grp.add_subsystem("ref_cell", cell, promotes_inputs=cell_design_params) coeff_comp = self.create_component("control_command_converter", model_key="coeff_model") pre_converter_grp.add_subsystem("p2i", coeff_comp, promotes_inputs=["I_ref_points"]) diff --git a/electrolyzer/test/test_om_examples.py b/electrolyzer/test/test_om_examples.py index 4ff50a9..3bc5ddc 100644 --- a/electrolyzer/test/test_om_examples.py +++ b/electrolyzer/test/test_om_examples.py @@ -1,4 +1,5 @@ import os +import copy import numpy as np import pytest @@ -25,6 +26,7 @@ def test_example_00_no_controller(subtests): bert.run() i_estimated = bert.model.get_val("Cluster0.translator.command_to_current.I_command", units="A") i_actual = bert.model.get_val("Cluster0.converter.I_ref_points", units="A") + i_error = i_estimated - i_actual with subtests.test("100 cells per stack"): @@ -35,3 +37,65 @@ def test_example_00_no_controller(subtests): with subtests.test("I-V Curve fit error is less than 0.105 A"): assert np.all(np.abs(i_error) < 0.105) + + with subtests.test("Initial operating temperature"): + assert pytest.approx(80.0, rel=1e-6) == bert.model.get_val( + "operating_temperature", units="degC" + ) + + V_initial = copy.deepcopy( + bert.model.get_val("Cluster0.simulation.cell_real.V_cell_out", units="V") + ) + coeff_initial = copy.deepcopy( + bert.model.get_val("Cluster0.translator.command_to_current.curve_coeffs", units="A/W") + ) + V_ref_initial = copy.deepcopy( + bert.model.get_val("Cluster0.converter.ref_cell.V_cell_out", units="V") + ) + P_ref_initial = copy.deepcopy( + bert.model.get_val("Cluster0.converter.ref_cell.P_cell_out", units="W") + ) + with subtests.test("Initial reference rated power"): + assert ( + pytest.approx(4467.1560, rel=1e-6) + == bert.model.get_val("Cluster0.converter.ref_cell.P_cell_out", units="W")[-1] + ) + with subtests.test("Initial curve coefficients"): + expected_initial_coeff = np.array( + [7.08472908e-10, -1.70727901e-05, 4.78002528e-01, 2.34225327e00, -1.42414827e01] + ) + assert pytest.approx(expected_initial_coeff, rel=1e-6, abs=1e-8) == coeff_initial + with subtests.test("Initial reference rated voltage"): + assert ( + pytest.approx(2.233578003273652, rel=1e-6) + == bert.model.get_val("Cluster0.converter.ref_cell.V_cell_out", units="V")[-1] + ) + + # Change one of the cell design parameters + bert.model.set_val("operating_temperature", 60.0, units="degC") + bert.run() + V_new = bert.model.get_val("Cluster0.simulation.cell_real.V_cell_out", units="V") + coeff_new = bert.model.get_val( + "Cluster0.translator.command_to_current.curve_coeffs", units="A/W" + ) + V_ref_new = bert.model.get_val("Cluster0.converter.ref_cell.V_cell_out", units="V") + P_ref_new = bert.model.get_val("Cluster0.converter.ref_cell.P_cell_out", units="W") + + with subtests.test("Reference power points changed"): + P_ref_diff = np.abs(P_ref_initial - P_ref_new) + assert np.all(P_ref_diff < 91.0) + assert np.all(P_ref_diff > 0.20) + + with subtests.test("Reference voltage points changed"): + V_ref_diff = np.abs(V_ref_initial - V_ref_new) + assert np.all(V_ref_diff < 0.046) + assert np.all(V_ref_diff > 0.0004) + + with subtests.test("Real simulation voltage changed"): + assert not all(k for k in np.isclose(V_new, V_initial, rtol=1e-6, atol=1e-6)) + + with subtests.test("Curve coefficients changed"): + expected_coeff = np.array( + [8.30333109e-10, -1.92246906e-05, 4.75331901e-01, 2.52511351e00, -1.62209719e01] + ) + assert pytest.approx(expected_coeff, rel=1e-6, abs=1e-8) == coeff_new diff --git a/pyproject.toml b/pyproject.toml index c52b487..28efa28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,17 +78,18 @@ branch = true source = ["electrolyzer/*"] omit = [ "setup.py", - "tests/*" + "tests/*", + "*/test/*", + "*/test_*.py" ] [tool.pytest.ini_options] python_files = [ "tests/*.py", + "*/test/*", + "*/test_*.py" ] -testpaths = [ - "test/*.py", - "test/glue_code/*.py", -] + [tool.isort]