From 96a04207426740eb0aeefbff0b415c9c51f8fbb9 Mon Sep 17 00:00:00 2001 From: hunterbarber Date: Mon, 4 Mar 2024 10:53:02 -0500 Subject: [PATCH 1/3] electrolyzer test harness --- .../unit_models/tests/test_electrolyzer.py | 71 +++ .../costing/unit_models/tests/test_gac.py | 7 - .../unit_models/tests/test_electrolyzer.py | 411 ++++++------------ 3 files changed, 214 insertions(+), 275 deletions(-) create mode 100644 watertap/costing/unit_models/tests/test_electrolyzer.py diff --git a/watertap/costing/unit_models/tests/test_electrolyzer.py b/watertap/costing/unit_models/tests/test_electrolyzer.py new file mode 100644 index 0000000000..1228a55ccb --- /dev/null +++ b/watertap/costing/unit_models/tests/test_electrolyzer.py @@ -0,0 +1,71 @@ +################################################################################# +# WaterTAP Copyright (c) 2020-2023, The Regents of the University of California, +# through Lawrence Berkeley National Laboratory, Oak Ridge National Laboratory, +# National Renewable Energy Laboratory, and National Energy Technology +# Laboratory (subject to receipt of any required approvals from the U.S. Dept. +# of Energy). All rights reserved. +# +# Please see the files COPYRIGHT.md and LICENSE.md for full copyright and license +# information, respectively. These files are also available online at the URL +# "https://github.com/watertap-org/watertap/" +################################################################################# + +import pytest +import pyomo.environ as pyo +import idaes.core.util.model_statistics as istat + +from pyomo.util.check_units import assert_units_consistent +from idaes.core import UnitModelCostingBlock +from idaes.core.solvers import get_solver +from idaes.core.util.testing import initialization_tester +from watertap.costing import WaterTAPCosting +from watertap.unit_models.tests.test_electrolyzer import build + +__author__ = "Hunter Barber" + +solver = get_solver() + + +class TestElectrolyzerCosting: + @pytest.fixture(scope="class") + def build_costing(self): + m = build() + initialization_tester(m) + solver.solve(m) + + # build costing model block + m.fs.costing = WaterTAPCosting() + m.fs.costing.base_currency = pyo.units.USD_2020 + + m.fs.unit.costing = UnitModelCostingBlock(flowsheet_costing_block=m.fs.costing) + m.fs.costing.cost_process() + m.fs.unit.costing.initialize() + + return m + + @pytest.mark.unit + def test_costing(self, build_costing): + + m = build_costing + + # testing gac costing block dof and initialization + assert assert_units_consistent(m) is None + assert istat.degrees_of_freedom(m) == 0 + m.fs.unit.costing.initialize() + + # solve + results = solver.solve(m) + + # Check for optimal solution + assert pyo.check_optimal_termination(results) + + # check solution values + assert pytest.approx(2.0 * 17930, rel=1e-3) == pyo.value( + m.fs.unit.costing.capital_cost + ) + assert pytest.approx(82.50, rel=1e-3) == pyo.value( + m.fs.costing.aggregate_flow_electricity + ) + assert pytest.approx(50040, rel=1e-3) == pyo.value( + m.fs.costing.aggregate_flow_costs["electricity"] + ) diff --git a/watertap/costing/unit_models/tests/test_gac.py b/watertap/costing/unit_models/tests/test_gac.py index f5e8effbeb..3f4243c8ad 100644 --- a/watertap/costing/unit_models/tests/test_gac.py +++ b/watertap/costing/unit_models/tests/test_gac.py @@ -36,13 +36,6 @@ def build(self): initialization_tester(m) solver.solve(m) - m.fs.costing = WaterTAPCosting() - m.fs.costing.base_currency = pyo.units.USD_2020 - - m.fs.unit.costing = UnitModelCostingBlock(flowsheet_costing_block=m.fs.costing) - m.fs.costing.cost_process() - m.fs.unit.costing.initialize() - return m @pytest.mark.component diff --git a/watertap/unit_models/tests/test_electrolyzer.py b/watertap/unit_models/tests/test_electrolyzer.py index 5ee930a514..f839a9464c 100644 --- a/watertap/unit_models/tests/test_electrolyzer.py +++ b/watertap/unit_models/tests/test_electrolyzer.py @@ -13,257 +13,171 @@ import pytest import pyomo.environ as pyo -from pyomo.network import Port -from pyomo.util.check_units import assert_units_consistent from idaes.core import ( FlowsheetBlock, - EnergyBalanceType, - MaterialBalanceType, - MomentumBalanceType, UnitModelCostingBlock, ) from idaes.core.solvers import get_solver -from idaes.core.util.model_statistics import ( - degrees_of_freedom, - number_variables, - number_total_constraints, - number_unused_variables, -) from idaes.core.util.scaling import ( calculate_scaling_factors, - unscaled_variables_generator, - badly_scaled_var_generator, ) -from idaes.core.util.testing import initialization_tester from watertap.property_models.multicomp_aq_sol_prop_pack import MCASParameterBlock from watertap.unit_models.electrolyzer import Electrolyzer -from watertap.costing import WaterTAPCosting +from watertap.unit_models.tests.unit_test_harness import UnitTestHarness __author__ = "Hunter Barber" solver = get_solver() +zero = 1e-6 +relative_tolerance = 1e-3 + + +def build(): + m = pyo.ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + + m.fs.properties = MCASParameterBlock( + solute_list=[ + "NA+", + "CL-", + "CL2-v", + "H2-v", + "OH-", + ], + mw_data={ + "H2O": 0.018015, + "NA+": 0.022989, + "CL-": 0.03545, + "CL2-v": 0.0709, + "H2-v": 0.002016, + "OH-": 0.017007, + }, + charge={"NA+": 1, "CL-": -1, "OH-": -1}, + ignore_neutral_charge=True, + ) + m.fs.unit = Electrolyzer( + property_package=m.fs.properties, + ) + + # shortcut refs + prop = m.fs.properties + anolyte_blk = m.fs.unit.anolyte + catholyte_blk = m.fs.unit.catholyte + + # fix property parameters + m.fs.properties.dens_mass_const = 1200 + + # feed specifications + anolyte_blk.properties_in[0].pressure.fix(101325) + anolyte_blk.properties_in[0].temperature.fix(273.15 + 90) + anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "H2O"].fix(5.551) + anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "NA+"].fix(0.3422) + anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "CL-"].fix(0.3422) + anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "CL2-v"].fix(0) + anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "H2-v"].fix(0) + anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "OH-"].fix(0) + catholyte_blk.properties_in[0].pressure.fix(101325) + catholyte_blk.properties_in[0].temperature.fix(273.15 + 90) + catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "H2O"].fix(5.551) + catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "NA+"].fix(1.288) + catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "CL-"].fix(0) + catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "CL2-v"].fix(0) + catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "H2-v"].fix(0) + catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "OH-"].fix(1.288) + + # touch properties + anolyte_blk.properties_in[0].flow_vol_phase + anolyte_blk.properties_in[0].conc_mass_phase_comp + anolyte_blk.properties_in[0].conc_mol_phase_comp + catholyte_blk.properties_in[0].flow_vol_phase + catholyte_blk.properties_in[0].conc_mass_phase_comp + catholyte_blk.properties_in[0].conc_mol_phase_comp + anolyte_blk.properties_out[0].flow_vol_phase + anolyte_blk.properties_out[0].conc_mass_phase_comp + anolyte_blk.properties_out[0].conc_mol_phase_comp + catholyte_blk.properties_out[0].flow_vol_phase + catholyte_blk.properties_out[0].conc_mass_phase_comp + catholyte_blk.properties_out[0].conc_mol_phase_comp + + # fix electrolysis reaction variables + # TODO: transfer the following variables to generic importable blocks + # membrane properties + m.fs.unit.membrane_ion_transport_number["Liq", "NA+"].fix(1) + # anode properties, Cl- --> 0.5 Cl2 + e- + m.fs.unit.anode_electrochem_potential.fix(1.21) + m.fs.unit.anode_stoich["Liq", "CL-"].fix(-1) + m.fs.unit.anode_stoich["Liq", "CL2-v"].fix(0.5) + # cathode properties, H20 + e- --> 0.5 H2 + OH- + m.fs.unit.cathode_electrochem_potential.fix(-0.99) + m.fs.unit.cathode_stoich["Liq", "H2O"].fix(-1) + m.fs.unit.cathode_stoich["Liq", "H2-v"].fix(0.5) + m.fs.unit.cathode_stoich["Liq", "OH-"].fix(1) + + # fix design and performance variables + # membrane properties + m.fs.unit.membrane_current_density.fix(4000) + # anode properties + m.fs.unit.anode_current_density.fix(3000) + m.fs.unit.anode_overpotential.fix(0.1) # assumed + # cathode properties + m.fs.unit.cathode_current_density.fix(3000) + m.fs.unit.cathode_overpotential.fix(0.1) # assumed + # electrolyzer cell design + m.fs.unit.current.fix(30000) + # performance variables + m.fs.unit.efficiency_current.fix(0.9) + m.fs.unit.efficiency_voltage.fix(0.8) + + # scaling + prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "H2O")) + prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "NA+")) + prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "CL-")) + prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "CL2-v")) + prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "H2-v")) + prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "OH-")) + calculate_scaling_factors(m) + + return m + + +class TestElectrolyzer(UnitTestHarness): + def configure(self): + m = build() + + # arguments for UnitTestHarness + self.default_zero = zero + self.default_relative_tolerance = relative_tolerance + + # assert unit model values from solution + self.unit_solutions[m.fs.unit.membrane_area] = 7.500 + self.unit_solutions[m.fs.unit.anode_area] = 10.00 + self.unit_solutions[m.fs.unit.cathode_area] = 10.00 + self.unit_solutions[m.fs.unit.voltage_cell] = 2.750 + self.unit_solutions[m.fs.unit.resistance] = 1.167e-5 + self.unit_solutions[m.fs.unit.power] = 82510 + self.unit_solutions[m.fs.unit.voltage_reversible] = 2.200 + self.unit_solutions[m.fs.unit.electron_flow] = 0.2798 + self.unit_solutions[m.fs.unit.efficiency_power] = 0.7200 -# ----------------------------------------------------------------------------- -class TestElectrolyzer: - @pytest.fixture(scope="class") - def chlor_alkali_elec(self): - - m = pyo.ConcreteModel() - m.fs = FlowsheetBlock(dynamic=False) - - m.fs.properties = MCASParameterBlock( - solute_list=[ - "NA+", - "CL-", - "CL2-v", - "H2-v", - "OH-", - ], - mw_data={ - "H2O": 0.018015, - "NA+": 0.022989, - "CL-": 0.03545, - "CL2-v": 0.0709, - "H2-v": 0.002016, - "OH-": 0.017007, - }, - charge={"NA+": 1, "CL-": -1, "OH-": -1}, - ignore_neutral_charge=True, - ) - - m.fs.unit = Electrolyzer( - property_package=m.fs.properties, - ) - - # fix property parameters - m.fs.properties.dens_mass_const = 1200 - - # feed specifications - anolyte_blk = m.fs.unit.anolyte - catholyte_blk = m.fs.unit.catholyte - anolyte_blk.properties_in[0].pressure.fix(101325) - anolyte_blk.properties_in[0].temperature.fix(273.15 + 90) - anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "H2O"].fix(5.551) - anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "NA+"].fix(0.3422) - anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "CL-"].fix(0.3422) - anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "CL2-v"].fix(0) - anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "H2-v"].fix(0) - anolyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "OH-"].fix(0) - catholyte_blk.properties_in[0].pressure.fix(101325) - catholyte_blk.properties_in[0].temperature.fix(273.15 + 90) - catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "H2O"].fix(5.551) - catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "NA+"].fix(1.288) - catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "CL-"].fix(0) - catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "CL2-v"].fix(0) - catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "H2-v"].fix(0) - catholyte_blk.properties_in[0].flow_mol_phase_comp["Liq", "OH-"].fix(1.288) - - # touch properties - anolyte_blk.properties_in[0].flow_vol_phase - anolyte_blk.properties_in[0].conc_mass_phase_comp - anolyte_blk.properties_in[0].conc_mol_phase_comp - catholyte_blk.properties_in[0].flow_vol_phase - catholyte_blk.properties_in[0].conc_mass_phase_comp - catholyte_blk.properties_in[0].conc_mol_phase_comp - anolyte_blk.properties_out[0].flow_vol_phase - anolyte_blk.properties_out[0].conc_mass_phase_comp - anolyte_blk.properties_out[0].conc_mol_phase_comp - catholyte_blk.properties_out[0].flow_vol_phase - catholyte_blk.properties_out[0].conc_mass_phase_comp - catholyte_blk.properties_out[0].conc_mol_phase_comp + # check flow at outlet + self.unit_solutions[ + m.fs.unit.anolyte.properties_out[0].flow_mol_phase_comp["Liq", "CL2-v"] + ] = 0.1399 + self.unit_solutions[ + m.fs.unit.catholyte.properties_out[0].flow_mol_phase_comp["Liq", "NA+"] + ] = 1.568 + self.unit_solutions[ + m.fs.unit.catholyte.properties_out[0].flow_mol_phase_comp["Liq", "OH-"] + ] = 1.568 return m @pytest.mark.unit - def test_config(self, chlor_alkali_elec): - - m = chlor_alkali_elec - u_config = m.fs.unit.config - - # check unit config arguments - assert len(u_config) == 8 - assert not u_config.dynamic - assert not u_config.has_holdup - assert u_config.material_balance_type == MaterialBalanceType.useDefault - assert u_config.energy_balance_type == EnergyBalanceType.none - assert u_config.momentum_balance_type == MomentumBalanceType.pressureTotal - - # check properties - assert u_config.property_package is m.fs.properties - assert len(u_config.property_package.solute_set) == 5 - assert len(u_config.property_package.solvent_set) == 1 - - @pytest.mark.unit - def test_build(self, chlor_alkali_elec): - - m = chlor_alkali_elec - - # test units - assert assert_units_consistent(m) is None - - # test ports - port_lst = [ - "anolyte_inlet", - "anolyte_outlet", - "catholyte_inlet", - "catholyte_outlet", - ] - for port_str in port_lst: - port = getattr(m.fs.unit, port_str) - assert len(port.vars) == 3 - assert isinstance(port, Port) - - # test statistics - assert number_variables(m) == 212 - assert number_total_constraints(m) == 153 - assert number_unused_variables(m) == 15 - - @pytest.mark.unit - def test_dof(self, chlor_alkali_elec): - - m = chlor_alkali_elec - - # test initial degrees of freedom - assert degrees_of_freedom(m) == 10 - - # fix electrolysis reaction variables, TODO: transfer the following variables to generic importable blocks - # membrane properties - m.fs.unit.membrane_ion_transport_number["Liq", "NA+"].fix(1) - # anode properties, Cl- --> 0.5 Cl2 + e- - m.fs.unit.anode_electrochem_potential.fix(1.21) - m.fs.unit.anode_stoich["Liq", "CL-"].fix(-1) - m.fs.unit.anode_stoich["Liq", "CL2-v"].fix(0.5) - # cathode properties, H20 + e- --> 0.5 H2 + OH- - m.fs.unit.cathode_electrochem_potential.fix(-0.99) - m.fs.unit.cathode_stoich["Liq", "H2O"].fix(-1) - m.fs.unit.cathode_stoich["Liq", "H2-v"].fix(0.5) - m.fs.unit.cathode_stoich["Liq", "OH-"].fix(1) - - # fix design and performance variables - # membrane properties - m.fs.unit.membrane_current_density.fix(4000) - # anode properties - m.fs.unit.anode_current_density.fix(3000) - m.fs.unit.anode_overpotential.fix(0.1) # assumed - # cathode properties - m.fs.unit.cathode_current_density.fix(3000) - m.fs.unit.cathode_overpotential.fix(0.1) # assumed - # electrolyzer cell design - m.fs.unit.current.fix(30000) - # performance variables - m.fs.unit.efficiency_current.fix(0.9) - m.fs.unit.efficiency_voltage.fix(0.8) - - # test degrees of freedom satisfied - assert degrees_of_freedom(m) == 0 - - @pytest.mark.unit - def test_init(self, chlor_alkali_elec): - - m = chlor_alkali_elec - - # set default scaling factors - prop = m.fs.properties - prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "H2O")) - prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "NA+")) - prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "CL-")) - prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "CL2-v")) - prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "H2-v")) - prop.set_default_scaling("flow_mol_phase_comp", 1, index=("Liq", "OH-")) - - calculate_scaling_factors(m) + def test_electroneutrality(self): - # check that all variables have scaling factors - assert len(list(unscaled_variables_generator(m))) == 0 - - # test initialization - initialization_tester(m) - - # check variable scaling - assert len(list(badly_scaled_var_generator(m, zero=1e-6))) == 0 - - @pytest.mark.component - def test_solve(self, chlor_alkali_elec): - - m = chlor_alkali_elec + m = build() results = solver.solve(m) - # check for optimal solution - assert pyo.check_optimal_termination(results) - - # re-check variable scaling post solve - assert len(list(badly_scaled_var_generator(m, zero=1e-6))) == 0 - - @pytest.mark.component - def test_solution(self, chlor_alkali_elec): - - m = chlor_alkali_elec - - # test report - m.fs.unit.report() - - # check solution values - assert pytest.approx(7.500, rel=1e-3) == pyo.value(m.fs.unit.membrane_area) - assert pytest.approx(10.00, rel=1e-3) == pyo.value(m.fs.unit.anode_area) - assert pytest.approx(10.00, rel=1e-3) == pyo.value(m.fs.unit.cathode_area) - assert pytest.approx(2.750, rel=1e-3) == pyo.value(m.fs.unit.voltage_cell) - assert pytest.approx(1.167e-5, rel=1e-3) == pyo.value(m.fs.unit.resistance) - assert pytest.approx(82510, rel=1e-3) == pyo.value(m.fs.unit.power) - assert pytest.approx(2.200, rel=1e-3) == pyo.value(m.fs.unit.voltage_reversible) - assert pytest.approx(0.2798, rel=1e-3) == pyo.value(m.fs.unit.electron_flow) - assert pytest.approx(0.7200, rel=1e-3) == pyo.value(m.fs.unit.efficiency_power) - - # check flow at outlet - assert pytest.approx(0.1399, rel=1e-3) == pyo.value( - m.fs.unit.anolyte.properties_out[0].flow_mol_phase_comp["Liq", "CL2-v"] - ) - assert pytest.approx(1.568, rel=1e-3) == pyo.value( - m.fs.unit.catholyte.properties_out[0].flow_mol_phase_comp["Liq", "NA+"] - ) - assert pytest.approx(1.568, rel=1e-3) == pyo.value( - m.fs.unit.catholyte.properties_out[0].flow_mol_phase_comp["Liq", "OH-"] - ) - # check charge balance m.fs.unit.anolyte.properties_in[0].assert_electroneutrality( tee=True, @@ -287,42 +201,3 @@ def test_solution(self, chlor_alkali_elec): solve=False, defined_state=False, ) - - @pytest.mark.unit - def test_costing_build(self, chlor_alkali_elec): - - m = chlor_alkali_elec - - # build costing model block - m.fs.costing = WaterTAPCosting() - m.fs.costing.base_currency = pyo.units.USD_2020 - m.fs.unit.costing = UnitModelCostingBlock(flowsheet_costing_block=m.fs.costing) - m.fs.costing.cost_process() - - # check general config after building costing block - assert assert_units_consistent(m) is None - assert degrees_of_freedom(m) == 0 - - @pytest.mark.unit - def test_costing_results(self, chlor_alkali_elec): - - m = chlor_alkali_elec - - # scale, initialize, and solve with costing - calculate_scaling_factors(m) - m.fs.unit.costing.initialize() - results = solver.solve(m) - - # check for optimal solution - assert pyo.check_optimal_termination(results) - - # check solution values - assert pytest.approx(2.0 * 17930, rel=1e-3) == pyo.value( - m.fs.unit.costing.capital_cost - ) - assert pytest.approx(82.50, rel=1e-3) == pyo.value( - m.fs.costing.aggregate_flow_electricity - ) - assert pytest.approx(50040, rel=1e-3) == pyo.value( - m.fs.costing.aggregate_flow_costs["electricity"] - ) From eae33fb7d12be704838e0aecbfe83ce7dbf57e5d Mon Sep 17 00:00:00 2001 From: hunterbarber Date: Mon, 4 Mar 2024 11:16:38 -0500 Subject: [PATCH 2/3] removing unused imports --- watertap/unit_models/tests/test_electrolyzer.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/watertap/unit_models/tests/test_electrolyzer.py b/watertap/unit_models/tests/test_electrolyzer.py index f839a9464c..f89ca4c74f 100644 --- a/watertap/unit_models/tests/test_electrolyzer.py +++ b/watertap/unit_models/tests/test_electrolyzer.py @@ -13,10 +13,7 @@ import pytest import pyomo.environ as pyo -from idaes.core import ( - FlowsheetBlock, - UnitModelCostingBlock, -) +from idaes.core import FlowsheetBlock from idaes.core.solvers import get_solver from idaes.core.util.scaling import ( calculate_scaling_factors, From ce6ac286c18cb0bc4ad532a8e552199f968f881c Mon Sep 17 00:00:00 2001 From: savannahsakhai <123977561+savannahsakhai@users.noreply.github.com> Date: Wed, 6 Mar 2024 10:37:22 -0500 Subject: [PATCH 3/3] Update watertap/costing/unit_models/tests/test_electrolyzer.py --- watertap/costing/unit_models/tests/test_electrolyzer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/watertap/costing/unit_models/tests/test_electrolyzer.py b/watertap/costing/unit_models/tests/test_electrolyzer.py index 1228a55ccb..cf8650714c 100644 --- a/watertap/costing/unit_models/tests/test_electrolyzer.py +++ b/watertap/costing/unit_models/tests/test_electrolyzer.py @@ -48,7 +48,7 @@ def test_costing(self, build_costing): m = build_costing - # testing gac costing block dof and initialization + # testing electrolyzer costing block dof and initialization assert assert_units_consistent(m) is None assert istat.degrees_of_freedom(m) == 0 m.fs.unit.costing.initialize()