diff --git a/electrolyzer/connectors/degradation_combiner.py b/electrolyzer/connectors/degradation_combiner.py new file mode 100644 index 0000000..1116762 --- /dev/null +++ b/electrolyzer/connectors/degradation_combiner.py @@ -0,0 +1,20 @@ +import openmdao.api as om + + +class CombineDegradation(om.ExplicitComponent): + """Combine cell voltage with the cell degradation""" + + def initialize(self): + pass + + def setup(self): + self.add_input("V_cell_deg", val=0.0, shape_by_conn=True, units="V") + self.add_input("V_cell", val=0.0, copy_shape="V_cell_deg", units="V") + self.add_input("I_actual", val=0.0, copy_shape="V_cell_deg", units="A") + + self.add_output("V_cell_total", val=0.0, copy_shape="V_cell_deg", units="V") + self.add_output("P_cell_total", val=0.0, copy_shape="V_cell_deg", units="W") + + def compute(self, inputs, outputs): + outputs["V_cell_total"] = inputs["V_cell_deg"] + inputs["V_cell"] + outputs["P_cell_total"] = inputs["I_actual"] * outputs["V_cell_total"] diff --git a/electrolyzer/connectors/series_scalar.py b/electrolyzer/connectors/series_scalar.py index e47f1d1..85fb74e 100644 --- a/electrolyzer/connectors/series_scalar.py +++ b/electrolyzer/connectors/series_scalar.py @@ -36,7 +36,7 @@ def setup(self): class SplitAcrossSerialComponents(GenericSeriesConverter): - """Scale power down""" + """Split values across components connected in series""" def compute(self, inputs, outputs): for o_name in outputs.keys(): @@ -49,7 +49,7 @@ def compute(self, inputs, outputs): class CombineSerialComponents(GenericSeriesConverter): - """Scale power down""" + """Add values across components connected in series""" def setup(self): super().setup() diff --git a/electrolyzer/core/bert.py b/electrolyzer/core/bert.py index 01856eb..36d11f3 100644 --- a/electrolyzer/core/bert.py +++ b/electrolyzer/core/bert.py @@ -9,6 +9,7 @@ from electrolyzer.connectors.series_scalar import ( CombineSerialComponents, # , SplitAcrossSerialComponents ) +from electrolyzer.connectors.degradation_combiner import CombineDegradation from electrolyzer.components.cell.cell_design_params import get_cell_params_for_model from electrolyzer.components.classifiers.cell_classifier import CellClassification @@ -137,7 +138,8 @@ def create_components(self): "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) + sim_prom_vars = [*promotion_vars, "n_stacks", "n_cells"] + cluster_group.add_subsystem("simulation", simulator, promotes=sim_prom_vars) # simulation.dynamics gets I_min and I_max from the converter outputs # Connect the converter bounds to the @@ -172,10 +174,29 @@ def create_cluster_simulation_block(self, cell_design_params): degradation = self.create_component("degradation") dynamics = self.create_component("dynamics") + cell_scale_up = CombineSerialComponents( + scaling_component="cells", n_comps=self.config["stack"]["n_cells"] + ) + stack_scale_up = CombineSerialComponents( + scaling_component="stacks", n_comps=self.config["cluster"]["n_stacks"] + ) + degradation_combiner = CombineDegradation() + simulation.add_subsystem("dynamics", dynamics, promotes=["I_min", "I_max"]) simulation.add_subsystem("cell_nominal", cell_nom, promotes=cell_design_params) simulation.add_subsystem("degradation", degradation) simulation.add_subsystem("cell_real", cell_real, promotes=cell_design_params) + simulation.add_subsystem("degradation_combiner", degradation_combiner) + simulation.add_subsystem("scale_cell_to_stack", cell_scale_up, promotes_inputs=["n_cells"]) + + scale_up_base_vars = ["J", "P", "H2", "O2", "V"] # todo: add in I? + cluster_out_prom_vars = [(f"{v}_out", f"Cluster_{v}") for v in scale_up_base_vars] + simulation.add_subsystem( + "scale_stack_to_cluster", + stack_scale_up, + promotes_inputs=["n_stacks"], + promotes_outputs=cluster_out_prom_vars, + ) # connect dynamics current output to nominal cell current input simulation.connect("dynamics.I_out", "cell_nominal.I_in") @@ -187,6 +208,28 @@ def create_cluster_simulation_block(self, cell_design_params): simulation.connect("cell_nominal.V_cell_out", "degradation.V_cell_nominal") # connect the degraded current to the cell voltage simulation.connect("degradation.I_actual", "cell_real.I_in") + + # combine the degradation results at the cell level + simulation.connect("cell_real.V_cell_out", "degradation_combiner.V_cell") + simulation.connect("degradation.V_cell_degraded", "degradation_combiner.V_cell_deg") + simulation.connect("degradation.I_actual", "degradation_combiner.I_actual") + + # connect the outputs from the real cell to the cell scale-up components + simulation.connect("cell_real.J_out", "scale_cell_to_stack.J_in") + simulation.connect("degradation.I_actual", "scale_cell_to_stack.I_in") + # TODO: in the future, add a losses component and connect the outputs from that to the scale-up + simulation.connect("cell_real.H2_cell_out", "scale_cell_to_stack.H2_in") + simulation.connect("cell_real.O2_cell_out", "scale_cell_to_stack.O2_in") + + # connect the power and voltage from the degradation combiner to the scale-up components + simulation.connect("degradation_combiner.V_cell_total", "scale_cell_to_stack.V_in") + simulation.connect("degradation_combiner.P_cell_total", "scale_cell_to_stack.P_in") + + # Scale up from stack to cluster level + + for var in scale_up_base_vars: + simulation.connect(f"scale_cell_to_stack.{var}_out", f"scale_stack_to_cluster.{var}_in") + return simulation def create_controller_translator(self): diff --git a/electrolyzer/test/test_om_examples.py b/electrolyzer/test/test_om_examples.py index 0c5b66d..7dd135f 100644 --- a/electrolyzer/test/test_om_examples.py +++ b/electrolyzer/test/test_om_examples.py @@ -141,3 +141,49 @@ def test_example_00_no_controller(subtests): pytest.approx(7.491416242830744, rel=1e-6) == bert.model.get_val("Cluster0.classifier.H2_max", units="kg/h")[0] ) + + with subtests.test("Cluster H2 Production"): + cell_h2 = bert.model.get_val( + "Cluster0.simulation.cell_real.H2_cell_out", units="kg/h" + ).sum() + + assert ( + pytest.approx(cell_h2 * scale_fac, rel=1e-6) + == bert.model.get_val("Cluster0.simulation.Cluster_H2", units="kg/h").sum() + ) + with subtests.test("Cluster H2 Production (value)"): + assert ( + pytest.approx(81.49985544233095, rel=1e-6) + == bert.model.get_val("Cluster0.simulation.Cluster_H2", units="kg/h").sum() + ) + + with subtests.test("Cluster Voltage"): + cell_voltage = bert.model.get_val( + "Cluster0.simulation.degradation_combiner.V_cell_total", units="V" + )[-1] + + assert ( + pytest.approx(cell_voltage * scale_fac, rel=1e-6) + == bert.model.get_val("Cluster0.simulation.Cluster_V", units="V")[-1] + ) + with subtests.test("Cluster Voltage (value)"): + cell_voltage = bert.model.get_val( + "Cluster0.simulation.degradation_combiner.V_cell_total", units="V" + )[-1] + + assert ( + pytest.approx(227.1918107704954, rel=1e-6) + == bert.model.get_val("Cluster0.simulation.Cluster_V", units="V")[-1] + ) + + with subtests.test("Degradation power"): + V_cell_bol = bert.model.get_val("Cluster0.simulation.cell_nominal.V_cell_out", units="V") + V_cell_deg = bert.model.get_val( + "Cluster0.simulation.degradation.V_cell_degraded", units="V" + ) + I_deg = bert.model.get_val("Cluster0.simulation.degradation.I_actual", units="A") + I_bol = bert.model.get_val("Cluster0.simulation.degradation.I_in", units="A") + P_cell_bol = I_bol * V_cell_bol + P_cell_deg = I_deg * (V_cell_deg + V_cell_bol) + assert np.allclose(P_cell_bol, P_cell_deg) + assert np.allclose(P_cell_bol * scale_fac, P_cell_deg * scale_fac)