From f05e26eac0510682d6ab31c59506682bc24d1cb0 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:32:01 -0600 Subject: [PATCH 1/8] updated pytest options in pyproject.toml file --- pyproject.toml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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] From 434b5c2a954f448f4053089d40bc6fad4d1a01ae Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:20:12 -0600 Subject: [PATCH 2/8] Added cell_design_params file and updated PEM cell to always have same input parameters --- .../components/cell/cell_design_params.py | 20 +++++++++++++++++++ electrolyzer/components/cell/pem_cell.py | 9 ++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 electrolyzer/components/cell/cell_design_params.py 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)") From 44b6f72d89c64385dc2ed4c58cc9acdb62775a19 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:20:41 -0600 Subject: [PATCH 3/8] added promotion to cell parameter values --- electrolyzer/core/bert.py | 53 ++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 15 deletions(-) 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"]) From b613d8f2223f16cfe4b186e51c5b9a09d53a5019 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:21:37 -0600 Subject: [PATCH 4/8] expanded testing of example 1 to check that changes to cell design parameters changes results --- electrolyzer/test/test_om_examples.py | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) 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 From 89ebe66a67acd884d8f3764b9d5a5956c8f82940 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:42:25 -0600 Subject: [PATCH 5/8] added performance classification component --- .../components/classifiers/__init__.py | 0 .../components/classifiers/cell_classifier.py | 56 ++++++++ electrolyzer/connectors/series_scalar.py | 64 +++++++++ electrolyzer/core/bert.py | 125 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 electrolyzer/components/classifiers/__init__.py create mode 100644 electrolyzer/components/classifiers/cell_classifier.py create mode 100644 electrolyzer/connectors/series_scalar.py diff --git a/electrolyzer/components/classifiers/__init__.py b/electrolyzer/components/classifiers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/components/classifiers/cell_classifier.py b/electrolyzer/components/classifiers/cell_classifier.py new file mode 100644 index 0000000..a5e1f04 --- /dev/null +++ b/electrolyzer/components/classifiers/cell_classifier.py @@ -0,0 +1,56 @@ +import numpy as np +import openmdao.api as om + + +class CellClassification(om.ExplicitComponent): + def initialize(self): + self.options.declare("tech_config", types=dict, default={}) + self.options.declare("plant_config", types=dict, default={}) + + def setup(self): + self.add_input("I_max", val=0.0, shape=1, units="A") + self.add_input("I_min", val=0.0, shape=1, units="A") + self.add_input("I_ref_points", val=0.0, shape_by_conn=True, units="A") + + self.vars_to_units = { + "J": "A/(cm**2)", + "P": "kW", + "H2": "kg/h", + "O2": "kg/h", + # "H2O": "kg/s", + "V": "V", + } + + ref_shape = "I_ref_points" + for v, u in self.vars_to_units.items(): + self.add_input(f"{v}_in", val=0.0, copy_shape=ref_shape, units=u) + self.add_output(f"{v}_min", val=0.0, shape=1, units=u) + self.add_output(f"{v}_max", val=0.0, shape=1, units=u) + + # energy bounds + self.add_output("efficiency_min", val=0.0, shape=1, units="kW*h/kg") + self.add_output("efficiency_max", val=0.0, shape=1, units="kW*h/kg") + # Should output: + # - rated cell voltage + # - rated current density + # - rated power consumption + # - rated h2 production rate + # - rated efficiency + # - rated o2 production rate + # - rated water consumption rate + + # self.add_input("J_max", val=self.config.J_max, shape=1, units="A/(cm**2)") + # self.add_input("J_min", val=self.config.J_max, shape=1, units="A/(cm**2)") + + def compute(self, inputs, outputs): + idx_ref_min = np.argwhere(inputs["I_ref_points"] <= inputs["I_min"]).flatten()[-1] + idx_ref_max = np.argwhere(inputs["I_ref_points"] >= inputs["I_max"]).flatten()[0] + + for v in list(self.vars_to_units.keys()): + outputs[f"{v}_min"] = inputs[f"{v}_in"][idx_ref_min] + outputs[f"{v}_max"] = inputs[f"{v}_in"][idx_ref_max] + + # kWh/kg + efficiency = inputs["P_in"] / inputs["H2_in"] + outputs["efficiency_min"] = efficiency[idx_ref_min] + outputs["efficiency_max"] = efficiency[idx_ref_max] diff --git a/electrolyzer/connectors/series_scalar.py b/electrolyzer/connectors/series_scalar.py new file mode 100644 index 0000000..e47f1d1 --- /dev/null +++ b/electrolyzer/connectors/series_scalar.py @@ -0,0 +1,64 @@ +import openmdao.api as om + + +class GenericSeriesConverter(om.ExplicitComponent): + def initialize(self): + self.options.declare("scaling_component", types=str) + self.options.declare("n_comps", types=(int, float), default=1.0) + + def setup(self): + self.add_input( + f"n_{self.options['scaling_component']}", + val=self.options["n_comps"], + shape=1, + units="unitless", + ) + vars_to_units = { + "J": "A/(cm**2)", + "I": "A", + "P": "W", + "H2": "kg/s", + "O2": "kg/s", + # "H2O": "kg/s", + "V": "V", + # "V_deg": "V" + } + + ref_shape = None + for v, u in vars_to_units.items(): + if ref_shape is None: + self.add_input(f"{v}_in", val=0.0, shape_by_conn=True, units=u) + self.add_output(f"{v}_out", val=0.0, copy_shape=f"{v}_in", units=u) + ref_shape = f"{v}_in" + else: + self.add_input(f"{v}_in", val=0.0, copy_shape=ref_shape, units=u) + self.add_output(f"{v}_out", val=0.0, copy_shape=ref_shape, units=u) + + +class SplitAcrossSerialComponents(GenericSeriesConverter): + """Scale power down""" + + def compute(self, inputs, outputs): + for o_name in outputs.keys(): + in_name = o_name.replace("_out", "_in") + + if o_name == "I_out" or o_name == "J_out": + outputs[o_name] = inputs[in_name] + else: + outputs[o_name] = inputs[in_name] / inputs[f"n_{self.options['scaling_component']}"] + + +class CombineSerialComponents(GenericSeriesConverter): + """Scale power down""" + + def setup(self): + super().setup() + + def compute(self, inputs, outputs): + for o_name in outputs.keys(): + in_name = o_name.replace("_out", "_in") + + if o_name == "I_out" or o_name == "J_out": + outputs[o_name] = inputs[in_name] + else: + outputs[o_name] = inputs[in_name] * inputs[f"n_{self.options['scaling_component']}"] diff --git a/electrolyzer/core/bert.py b/electrolyzer/core/bert.py index fb64f7a..18a2505 100644 --- a/electrolyzer/core/bert.py +++ b/electrolyzer/core/bert.py @@ -6,7 +6,14 @@ from electrolyzer.core.file_utils import load_yaml from electrolyzer.core.supported_models import supported_models +from electrolyzer.connectors.series_scalar import ( + CombineSerialComponents, # , SplitAcrossSerialComponents +) from electrolyzer.components.cell.cell_design_params import get_cell_params_for_model +from electrolyzer.components.classifiers.cell_classifier import CellClassification + + +# from electrolyzer.components.classifiers.system_performance import SystemPerformance class State(IntEnum): @@ -116,6 +123,7 @@ def create_components(self): # Step 2: Create controller cluster connector components pre_translator = self.create_controller_cluster_connector(cell_design_params) + cluster_classifier = self.create_cluster_classification_component() # Translator has scale down + power to current conversion translator = self.create_controller_translator() # Step 3: Create the simulate block of a cluster @@ -123,6 +131,9 @@ def create_components(self): promotion_vars = [*cell_design_params, "I_min", "I_max"] cluster_group.add_subsystem("converter", pre_translator, promotes=promotion_vars) + cluster_group.add_subsystem( + "classifier", cluster_classifier, promotes=["I_min", "I_max", "n_cells", "n_stacks"] + ) cluster_group.add_subsystem("translator", translator, promotes=["n_stacks", "n_cells"]) cluster_group.add_subsystem("simulation", simulator, promotes=promotion_vars) @@ -132,10 +143,23 @@ def create_components(self): "converter.p2i.curve_coeffs", "translator.command_to_current.curve_coeffs" ) cluster_group.connect("translator.command_to_current.I_command", "simulation.dynamics.I_in") + + # connect converter group stuff to classification block + cluster_group.connect("converter.I_ref_points", "classifier.I_ref_points") + cluster_group.connect("converter.ref_cell.J_out", "classifier.cell_classifier.J_in") + for var in ["P", "H2", "O2", "V"]: + cluster_group.connect( + f"converter.ref_cell.{var}_cell_out", f"classifier.cell_classifier.{var}_in" + ) + + # Connect controller to cluster self.plant.connect( "controller.P_command", f"Cluster{cluster_i}.translator.cluster_to_stack.P_in" ) + # cluster_group.connect("converter.I_ref_points", "classifier." + # cluster_group.connect("converter.ref_cell.") + self.clusters = clusters def create_cluster_simulation_block(self, cell_design_params): @@ -198,8 +222,22 @@ def create_controller_translator(self): return translator def create_controller_cluster_connector(self, cell_design_params): + """Group containing the: + + 1. Bounds and reference point component (min/max bounds) + 2. Reference cell component + 3. Curve coefficient component + + Args: + cell_design_params (list[str]): cell design parameters to promote + + Returns: + om.Group: pre-simulation group + """ pre_converter_grp = om.Group() bounds_comp = self.create_bounds_component() + + # 1. Operational bounds and reference point component pre_converter_grp.add_subsystem( "IJ_ref", bounds_comp, @@ -207,19 +245,106 @@ def create_controller_cluster_connector(self, cell_design_params): promotes_outputs=["I_ref_points", "I_min", "I_max"], ) + # 2. Reference cell model cell = self.create_cell_model() pre_converter_grp.add_subsystem("ref_cell", cell, promotes_inputs=cell_design_params) + # 3. Curve coefficient component 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"]) + # 4. Add the cell classification component to the system + # The cell classification component inputs of J_in, P_in, H2_in, O2_in, V_in + # Cell model outputs P_cell_out, J_out, H2_cell_out, O2_cell_out, V_cell_out + # cell_classifier = CellClassification(tech_config={}, plant_config=self.plant_config) + # pre_converter_grp.add_subsystem( + # "cell_classifier", + # cell_classifier, + # promotes_inputs=["I_ref_points", "I_min", "I_max"], + # promotes_outputs=["*"], + # ) + + # Connect components + # Connect the reference points to the cell pre_converter_grp.connect("I_ref_points", "ref_cell.I_in") # Connect the power output from the cell to the power to current thing pre_converter_grp.connect("ref_cell.P_cell_out", "p2i.P_ref_points") + # Connect outputs from reference cell to classifier component + # pre_converter_grp.connect("ref_cell.J_out", "cell_classifier.J_in") + # for var in ["P", "H2", "O2", "V"]: + # pre_converter_grp.connect(f"ref_cell.{var}_cell_out", f"cell_classifier.{var}_in") + return pre_converter_grp + def create_cluster_classification_component(self): + # 4. Add the cell classification component to the system + # The cell classification component inputs of J_in, P_in, H2_in, O2_in, V_in + # and outputs min and max values of each input, plus min/max efficiency values + # Cell model outputs P_cell_out, J_out, H2_cell_out, O2_cell_out, V_cell_out + classifier_group = om.Group() + + cell_classifier = CellClassification(tech_config={}, plant_config=self.plant_config) + classifier_group.add_subsystem( + "cell_classifier", + cell_classifier, + promotes_inputs=["I_ref_points", "I_min", "I_max"], + promotes_outputs=["efficiency_min", "efficiency_max"], + ) + + cell_scale_up_lb = CombineSerialComponents( + scaling_component="cells", n_comps=self.config["stack"]["n_cells"] + ) + stack_scale_up_lb = CombineSerialComponents( + scaling_component="stacks", n_comps=self.config["cluster"]["n_stacks"] + ) + + cell_scale_up_ub = CombineSerialComponents( + scaling_component="cells", n_comps=self.config["stack"]["n_cells"] + ) + stack_scale_up_ub = CombineSerialComponents( + scaling_component="stacks", n_comps=self.config["cluster"]["n_stacks"] + ) + + bounds_base_vars = ["J", "P", "H2", "O2", "V"] + + promoted_outputs_lb = [(f"{v}_out", f"{v}_min") for v in bounds_base_vars] + promoted_outputs_ub = [(f"{v}_out", f"{v}_max") for v in bounds_base_vars] + + # # lower bounds + classifier_group.add_subsystem( + "cell_to_stack_lb", cell_scale_up_lb, promotes_inputs=["n_cells"] + ) + classifier_group.add_subsystem( + "stack_to_cluster_lb", + stack_scale_up_lb, + promotes_inputs=["n_stacks"], + promotes_outputs=promoted_outputs_lb, + ) + # # upper bounds + classifier_group.add_subsystem( + "cell_to_stack_ub", cell_scale_up_ub, promotes_inputs=["n_cells"] + ) + classifier_group.add_subsystem( + "stack_to_cluster_ub", + stack_scale_up_ub, + promotes_inputs=["n_stacks"], + promotes_outputs=promoted_outputs_ub, + ) + # Cluster0.classifier.cell_to_stack_lb.I_in + # classifier_group.connect("I_min", "cell_to_stack_lb.I_in") + for var in bounds_base_vars: + # # scale-up lower bounds + classifier_group.connect(f"cell_classifier.{var}_min", f"cell_to_stack_lb.{var}_in") + classifier_group.connect(f"cell_to_stack_lb.{var}_out", f"stack_to_cluster_lb.{var}_in") + + # # scale-up upper bounds + classifier_group.connect(f"cell_classifier.{var}_max", f"cell_to_stack_ub.{var}_in") + classifier_group.connect(f"cell_to_stack_ub.{var}_out", f"stack_to_cluster_ub.{var}_in") + + return classifier_group + def create_cell_model(self): cell_config = self.config["cell"] if (cell_model_name := cell_config.get("model", None)) is not None: From 522c39d9b24bc75c17f9fcee60e1e364378821b3 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:29:15 -0600 Subject: [PATCH 6/8] added subtests for the classifier group --- electrolyzer/core/bert.py | 17 ----------- electrolyzer/test/test_om_examples.py | 42 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/electrolyzer/core/bert.py b/electrolyzer/core/bert.py index 18a2505..4a5b486 100644 --- a/electrolyzer/core/bert.py +++ b/electrolyzer/core/bert.py @@ -253,29 +253,12 @@ def create_controller_cluster_connector(self, 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"]) - # 4. Add the cell classification component to the system - # The cell classification component inputs of J_in, P_in, H2_in, O2_in, V_in - # Cell model outputs P_cell_out, J_out, H2_cell_out, O2_cell_out, V_cell_out - # cell_classifier = CellClassification(tech_config={}, plant_config=self.plant_config) - # pre_converter_grp.add_subsystem( - # "cell_classifier", - # cell_classifier, - # promotes_inputs=["I_ref_points", "I_min", "I_max"], - # promotes_outputs=["*"], - # ) - # Connect components # Connect the reference points to the cell pre_converter_grp.connect("I_ref_points", "ref_cell.I_in") # Connect the power output from the cell to the power to current thing pre_converter_grp.connect("ref_cell.P_cell_out", "p2i.P_ref_points") - - # Connect outputs from reference cell to classifier component - # pre_converter_grp.connect("ref_cell.J_out", "cell_classifier.J_in") - # for var in ["P", "H2", "O2", "V"]: - # pre_converter_grp.connect(f"ref_cell.{var}_cell_out", f"cell_classifier.{var}_in") - return pre_converter_grp def create_cluster_classification_component(self): diff --git a/electrolyzer/test/test_om_examples.py b/electrolyzer/test/test_om_examples.py index 3bc5ddc..0c5b66d 100644 --- a/electrolyzer/test/test_om_examples.py +++ b/electrolyzer/test/test_om_examples.py @@ -99,3 +99,45 @@ def test_example_00_no_controller(subtests): [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 + + with subtests.test("Cluster min voltage"): + assert ( + pytest.approx( + bert.model.get_val("Cluster0.classifier.cell_classifier.V_max", units="V"), rel=1e-6 + ) + == bert.model.get_val("Cluster0.classifier.V_max", units="V") / scale_fac + ) + + with subtests.test("Cell/stack/cluster max power"): + cell_rated_power = bert.model.get_val( + "Cluster0.classifier.cell_to_stack_ub.P_in", units="kW" + ) + stack_rated_power = bert.model.get_val( + "Cluster0.classifier.stack_to_cluster_ub.P_in", units="kW" + ) + assert ( + pytest.approx( + cell_rated_power * bert.model.get_val("Cluster0.n_cells", units="unitless"), + rel=1e-6, + ) + == stack_rated_power + ) + assert pytest.approx( + bert.model.get_val("Cluster0.classifier.P_max", units="kW"), rel=1e-6 + ) == stack_rated_power * bert.model.get_val("Cluster0.n_stacks", units="unitless") + + with subtests.test("Rated conversion efficiency"): + assert pytest.approx(60.84498639, rel=1e-6) == bert.model.get_val( + "Cluster0.classifier.efficiency_max", units="kW*h/kg" + ) + + with subtests.test("Min conversion efficiency"): + assert pytest.approx(48.23406508, rel=1e-6) == bert.model.get_val( + "Cluster0.classifier.efficiency_min", units="kW*h/kg" + ) + + with subtests.test("H2 rated production"): + assert ( + pytest.approx(7.491416242830744, rel=1e-6) + == bert.model.get_val("Cluster0.classifier.H2_max", units="kg/h")[0] + ) From 66ba8ebf7f56785b2773ccc88bcfd4289a600b93 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:58:38 -0600 Subject: [PATCH 7/8] added ability to add recorder --- electrolyzer/core/bert.py | 98 ++++++++++++++++++++++++++++++++- electrolyzer/core/file_utils.py | 41 ++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/electrolyzer/core/bert.py b/electrolyzer/core/bert.py index 4a5b486..01856eb 100644 --- a/electrolyzer/core/bert.py +++ b/electrolyzer/core/bert.py @@ -4,7 +4,7 @@ import numpy as np import openmdao.api as om -from electrolyzer.core.file_utils import load_yaml +from electrolyzer.core.file_utils import load_yaml, make_unique_case_name from electrolyzer.core.supported_models import supported_models from electrolyzer.connectors.series_scalar import ( CombineSerialComponents, # , SplitAcrossSerialComponents @@ -40,6 +40,8 @@ def __init__(self, config_input, make_n2=True): self.create_controller() self.create_components() + self.create_recorder(self.prob) + self.state = State.INITIALIZED def load_config(self, config_input): @@ -383,3 +385,97 @@ def create_controller_component(self): control_variable=self.control_var, ) return controller + + def create_recorder(self, opt_prob): + # TODO: put this into pose_optimization one day + + if "recorder" not in self.config: + return None + + folder_output = self.config.get("folder_output", Path.cwd()) + + recorder_options = ["record_inputs", "record_outputs", "record_residuals"] + if self.config["recorder"].get("flag", False): + # Check that the output folder exists and create it if needed + if not Path(folder_output).exists(): + Path.mkdir(folder_output, parents=True, exist_ok=True) + + if self.config["recorder"].get("flag", False): + # Check that the output folder exists and create it if needed + if not Path(folder_output).exists(): + Path.mkdir(folder_output, parents=True, exist_ok=True) + + overwrite_recorder = self.config["recorder"].get("overwrite_recorder", False) + recorder_path = Path(folder_output) / self.config["recorder"]["file"] + + if not overwrite_recorder: + # make a unique filename with the same base as self.config["recorder"]["file"] + # separate out the filename without the extension + file_base = self.config["recorder"]["file"].split(".sql")[0] + + recorder_fname = make_unique_case_name( + Path(folder_output), f"{file_base}.sql", ".sql" + ) + recorder_path = Path(folder_output) / recorder_fname + + recorder_attachment = ( + self.config["recorder"].get("recorder_attachment", "driver").lower() + ) + allowed_attachments = ["driver", "model"] + if recorder_attachment not in allowed_attachments: + msg = ( + f"Invalid recorder attachment '{recorder_attachment}'. " + f"Currently supported options are {allowed_attachments}. " + "We recommend using 'driver' if running an optimization " + "or parameter sweep in parallel." + ) + raise ValueError(msg) + + # Create recorder + recorder = om.SqliteRecorder(recorder_path) + + if recorder_attachment == "model": + # add the recorder to the model + recorder_options += ["options_excludes"] + + opt_prob.model.add_recorder(recorder) + + for recorder_opt in recorder_options: + if recorder_opt in self.config["recorder"]: + opt_prob.model.recording_options[recorder_opt] = self.config[ + "recorder" + ].get(recorder_opt) + + opt_prob.model.recording_options["includes"] = self.config["recorder"].get( + "includes", ["*"] + ) + # opt_prob.model.recording_options["excludes"] = self.config["recorder"].get( + # "excludes", ["*resource_data"] + # ) + return recorder_path + + if recorder_attachment == "driver": + recorder_options += [ + "record_constraints", + "record_derivative", + "record_desvars", + "record_objectives", + ] + # add the recorder to the driver + opt_prob.driver.add_recorder(recorder) + + for recorder_opt in recorder_options: + if recorder_opt in self.config["recorder"]: + opt_prob.driver.recording_options[recorder_opt] = self.config[ + "recorder" + ].get(recorder_opt) + + opt_prob.driver.recording_options["includes"] = self.config["recorder"].get( + "includes", ["*"] + ) + # opt_prob.driver.recording_options["excludes"] = self.config["recorder"].get( + # "excludes", ["*resource_data"] + # ) + return recorder_path + + return None diff --git a/electrolyzer/core/file_utils.py b/electrolyzer/core/file_utils.py index 26dab44..cc4a8bc 100644 --- a/electrolyzer/core/file_utils.py +++ b/electrolyzer/core/file_utils.py @@ -1,3 +1,4 @@ +import re from pathlib import Path import yaml @@ -8,3 +9,43 @@ def load_yaml(filename, loader=yaml.SafeLoader) -> dict: return filename # filename already yaml dict with Path.open(filename) as fid: return yaml.load(fid, loader) + + +def make_unique_case_name(folder, proposed_fname, fext): + """Generate a filename that does not already exist in a user-defined folder. + + Args: + folder (str | Path): directory that a file is expected to be created in. + proposed_fname (str): filename (with extension) to check for existence and + to use as the base file description of a new an unique file name. + fext (str): file extension, such as ".csv", ".sql", ".yaml", etc. + + Returns: + str: unique filename that does not yet exist in folder. + """ + if "." not in fext: + fext = f".{fext}" + + # if file(s) exist with the same base name, make a new unique filename + file_base = proposed_fname.split(fext)[0] + existing_files = [f for f in Path(folder).glob(f"**/*{fext}") if file_base in f.name] + if len(existing_files) == 0: + return proposed_fname + + # get past numbers that were used to make unique files by matching + # filenames against the file base name followed by a number + past_numbers = [ + int(re.findall(f"{file_base}[0-9]+", str(fname))[0].split(file_base)[-1]) + for fname in existing_files + if len(re.findall(f"{file_base}[0-9]+", str(fname))) > 0 + ] + + if len(past_numbers) > 0: + # if multiple files have the same basename followed by a number, + # take the maximum unique number and add one + unique_number = int(max(past_numbers) + 1) + return f"{file_base}{unique_number}{fext}" + else: + # if no files have the same basename followed by a number, + # but do have the same basename, then add a zero to the file basename + return f"{file_base}0{fext}" From 38b9c18997dd0c5ca25ac43bdb496b8f91f8fca3 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:02:02 -0600 Subject: [PATCH 8/8] updated example 1 to show how to add a recorder --- examples/example_00_refactor/run.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/examples/example_00_refactor/run.py b/examples/example_00_refactor/run.py index 57e8df0..79d9f11 100644 --- a/examples/example_00_refactor/run.py +++ b/examples/example_00_refactor/run.py @@ -2,9 +2,24 @@ from pathlib import Path from electrolyzer.core.bert import BERT +from electrolyzer.core.file_utils import load_yaml os.chdir(Path(__file__).parent) config_fpath = Path(__file__).parent / "bert_config.yaml" bert = BERT(config_fpath) bert.run() + + +# Run with recorder +config = load_yaml(config_fpath) +config["folder_output"] = Path(__file__).parent / "outputs" +config["recorder"] = { + "flag": True, + "file": "case.sql", + "overwrite_recorder": True, + "recorder_attachment": "model", + "includes": ["*"], +} +bert = BERT(config) +bert.run()