diff --git a/watertap/flowsheets/specsheet_fitting_tools/__init__.py b/watertap/flowsheets/specsheet_fitting_tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/watertap/flowsheets/specsheet_fitting_tools/ro_module_fitter.py b/watertap/flowsheets/specsheet_fitting_tools/ro_module_fitter.py new file mode 100644 index 0000000000..c9fa096c69 --- /dev/null +++ b/watertap/flowsheets/specsheet_fitting_tools/ro_module_fitter.py @@ -0,0 +1,322 @@ +################################################################################# +# WaterTAP Copyright (c) 2020-2024, 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/" +################################################################################# + +__author__ = "Alexander V. Dudchenko" + +from pyomo.environ import ( + ConcreteModel, + value, + units as pyunits, + TransformationFactory, + assert_optimal_termination, +) +from idaes.core.util.model_statistics import degrees_of_freedom +from idaes.core import FlowsheetBlock +import idaes.core.util.scaling as iscale + +from watertap.unit_models.reverse_osmosis_1D import ( + ReverseOsmosis1D as RO1D, + PressureChangeType, + MassTransferCoefficient, + ConcentrationPolarizationType, +) +from watertap.core.util.model_diagnostics.infeasible import * +from watertap.property_models import NaCl_T_dep_prop_pack as props +from watertap.core.solvers import get_solver +import yaml +import os + + +def fit_nitto_ESPA2_LD(save_location=None): + # https://membranes.com/wp-content/uploads/Documents/Element-Specification-Sheets/RO/ESPA/ESPA2-LD.pdf + return fit_ro_module_to_spec_sheet( + membrane_name="ESPA2-LD", + water_production_rate=37.9 * pyunits.m**3 / pyunits.day, + nacl_rejection=99.6 * pyunits.percent, # percent + feed_conc=1500 * pyunits.mg / pyunits.liter, + recovery=0.15, + module_length=1 * pyunits.m, + pressure=150 * pyunits.psi, + membrane_area=37.2 * pyunits.m**2, + channel_height=0.86 * pyunits.mm, + spacer_porosity=0.85, + temperature=25, # degrees C + save_location=save_location, + ) + + +def fit_nitto_ESPA4_LD(save_location=None): + # https://membranes.com/wp-content/uploads/Documents/Element-Specification-Sheets/RO/ESPA/ESPA4-LD.pdf + return fit_ro_module_to_spec_sheet( + membrane_name="ESPA4-LD", + water_production_rate=45.4 * pyunits.m**3 / pyunits.day, + nacl_rejection=99.2 * pyunits.percent, # percent + feed_conc=500 * pyunits.mg / pyunits.liter, + recovery=0.15, + module_length=1 * pyunits.m, + pressure=100 * pyunits.psi, + membrane_area=37.2 * pyunits.m**2, + channel_height=0.86 * pyunits.mm, + spacer_porosity=0.85, + temperature=25, # degrees C + save_location=save_location, + ) + + +def fit_bw30_4040_to_spec_sheet(save_location=None): + # https://www.dupont.com/content/dam/water/amer/us/en/water/public/documents/en/RO-FilmTec-BW30-PRO-4040-and-BW30-PRO-2540-PDS-45-D03970-en.pdf + return fit_ro_module_to_spec_sheet( + membrane_name="BW30 PRO-4040", + water_production_rate=9.8 * pyunits.m**3 / pyunits.day, + nacl_rejection=99.7 * pyunits.percent, # percent + feed_conc=2000 * pyunits.mg / pyunits.liter, + recovery=0.15, + module_length=1 * pyunits.m, + pressure=225 * pyunits.psi, + membrane_area=7.9 * pyunits.m**2, + channel_height=1 * pyunits.mm, + spacer_porosity=0.85, + temperature=25, # degrees C + save_location=save_location, + ) + + +def fit_sw30_4040_to_spec_sheet(save_location=None): + # https://www.dupont.com/content/dam/water/amer/us/en/water/public/documents/en/RO-FilmTec-SW30-Seawater-PDS-45-D01519-en.pdf + return fit_ro_module_to_spec_sheet( + membrane_name="SW30-4040", + water_production_rate=7.4 * pyunits.m**3 / pyunits.day, + nacl_rejection=99.7 * pyunits.percent, # percent + feed_conc=32000 * pyunits.mg / pyunits.liter, + recovery=0.08, + module_length=1 * pyunits.m, + pressure=55 * pyunits.bar, + membrane_area=7.9 * pyunits.m**2, + channel_height=1 * pyunits.mm, + spacer_porosity=0.85, + temperature=25, # degrees C + save_location=save_location, + ) + + +def fit_ro_module_to_spec_sheet( + membrane_name="RO_module", + water_production_rate=38 * pyunits.m**3 / pyunits.day, + nacl_rejection=99.5 * pyunits.percent, # percent + feed_conc=1500 * pyunits.mg / pyunits.liter, + recovery=0.15, + module_length=1 * pyunits.m, + pressure=150 * pyunits.psi, + membrane_area=40 * pyunits.m**2, + channel_height=1e-3 * pyunits.m, + spacer_porosity=0.85, + temperature=25, # degrees C + save_location=None, + save_to_yaml=True, +): + """Method for fitting RO spec sheet data to 1D RO model to find A and B membrane parameters. Use pyunits to specify units + for each input. + + Args: + membrane_name : str + Name of the membrane module. + water_production_rate : float + Water production rate (volume flow rate) [m3/day]. + nacl_rejection : float + Salt rejection [%]. + feed_conc : float + Feed concentration [mg/L]. + recovery : float + Recovery [%]. + module_length : float + Length of the RO module [m]. + pressure : float + Feed pressure [Pa]. + membrane_area : float + Membrane area [m2]. + channel_height : float + Channel height [m]. + spacer_porosity : float + Spacer porosity [-]. + temperature : float + Temperature [deg C]. + save_location : str + Directory to save the fitted membrane parameters yaml file. + save_to_yaml : bool + + Output: + Dictionary that contains fitted membrane parameters A and B along with other + membrane design parameters. + """ + + solver = get_solver() + + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + + m.fs.properties = props.NaClParameterBlock() + # seem feed and ro model + m.fs.RO = RO1D( + property_package=m.fs.properties, + has_pressure_change=True, + pressure_change_type=PressureChangeType.calculated, + mass_transfer_coefficient=MassTransferCoefficient.calculated, + concentration_polarization_type=ConcentrationPolarizationType.calculated, + transformation_scheme="BACKWARD", + transformation_method="dae.finite_difference", + module_type="spiral_wound", + finite_elements=10, + has_full_reporting=True, + ) + + # m.fs.feed_to_ro = Arc(source=m.fs.feed.outlet, destination=m.fs.RO.inlet) + + TransformationFactory("network.expand_arcs").apply_to(m) + + # specify feed conditions + m.fs.RO.feed_side.properties[0, 0].flow_vol_phase["Liq"].fix( + water_production_rate / recovery + ) + m.fs.RO.feed_side.properties[0, 0].conc_mass_phase_comp["Liq", "NaCl"].fix( + feed_conc + ) + m.fs.RO.feed_side.properties[0, 0].temperature.fix(temperature + 273.15) + m.fs.RO.feed_side.properties[0, 0].pressure.fix(pressure) + + print( + "Degrees of freedom before initialization: ", + degrees_of_freedom(m.fs.RO.feed_side.properties[0, 0]), + ) + assert degrees_of_freedom(m.fs.RO.feed_side.properties[0, 0]) == 0 + # Solve the feed to get mass flows and concentrations through out + result = solver.solve(m.fs.RO.feed_side.properties[0, 0]) + assert_optimal_termination(result) + + # RO expects flow mass phase comp to be fixed for intialization + m.fs.RO.feed_side.properties[0, 0].flow_vol_phase["Liq"].unfix() + m.fs.RO.feed_side.properties[0, 0].conc_mass_phase_comp["Liq", "NaCl"].unfix() + m.fs.RO.feed_side.properties[0, 0].flow_mass_phase_comp.fix() + # configure RO + m.fs.RO.length.fix(module_length) + iscale.set_scaling_factor(m.fs.RO.length, value(1 / m.fs.RO.length)) + m.fs.RO.area.fix(membrane_area) + iscale.set_scaling_factor(m.fs.RO.area, value(1 / m.fs.RO.area)) + m.fs.RO.feed_side.channel_height.fix(channel_height) + m.fs.RO.feed_side.spacer_porosity.fix(spacer_porosity) + # initial guess for initialization + m.fs.RO.A_comp.fix(4.2e-12) + m.fs.RO.B_comp.fix(3.5e-8) + m.fs.RO.permeate.pressure[0].fix(101325) # 1 atm + + # scale mass flow units + m.fs.properties.set_default_scaling( + "flow_mass_phase_comp", + value( + 1 / m.fs.RO.feed_side.properties[0, 0].flow_mass_phase_comp["Liq", "H2O"] + ), + index=("Liq", "H2O"), + ) + m.fs.properties.set_default_scaling( + "flow_mass_phase_comp", + value( + 1 / m.fs.RO.feed_side.properties[0, 0].flow_mass_phase_comp["Liq", "NaCl"] + ), + index=("Liq", "NaCl"), + ) + iscale.calculate_scaling_factors(m) + # initialization guess + m.fs.RO.initialize() + + print("Degrees of freedom before RO box solve: ", degrees_of_freedom(m)) + assert degrees_of_freedom(m) == 0 + results = solver.solve(m, tee=False) + assert_optimal_termination(results) + # now fix recovery and rejection to spec sheet values and unfix A and B to solve for them + m.fs.RO.A_comp.unfix() + m.fs.RO.B_comp.unfix() + m.fs.RO.rejection_phase_comp[0, "Liq", "NaCl"].fix(nacl_rejection) + m.fs.RO.recovery_vol_phase[0.0, "Liq"].fix(recovery) + + print("Degrees of freedom before A/B solve: ", degrees_of_freedom(m)) + assert degrees_of_freedom(m) == 0 + results = solver.solve(m, tee=True) + assert_optimal_termination(results) + print('Fit successfully completed for membrane: "', membrane_name, '"') + membrane_design = { + "A": { + "value": m.fs.RO.A_comp[0, "H2O"].value, + "units": str(m.fs.RO.A_comp[0, "H2O"].get_units()), + }, + "B": { + "value": m.fs.RO.B_comp[0, "NaCl"].value, + "units": str(m.fs.RO.B_comp[0, "NaCl"].get_units()), + }, + "A (LMH/bar)": { + "value": value( + pyunits.convert( + m.fs.RO.A_comp[0, "H2O"], + to_units=pyunits.L / (pyunits.m**2 * pyunits.hr * pyunits.bar), + ) + ), + "units": "LMH/bar", + }, + "B (LMH)": { + "value": value( + pyunits.convert( + m.fs.RO.B_comp[0, "NaCl"], + to_units=pyunits.L / (pyunits.m**2 * pyunits.hr), + ) + ), + "units": "LMH", + }, + "Area": {"value": m.fs.RO.area.value, "units": str(m.fs.RO.area.get_units())}, + "Length": { + "value": m.fs.RO.length.value, + "units": str(m.fs.RO.length.get_units()), + }, + "Porosity": { + "value": m.fs.RO.feed_side.spacer_porosity.value, + "units": str(m.fs.RO.feed_side.spacer_porosity.get_units()), + }, + "Channel_height": { + "value": m.fs.RO.feed_side.channel_height.value, + "units": str(m.fs.RO.feed_side.channel_height.get_units()), + }, + "Rejection_NaCl": { + "value": m.fs.RO.rejection_phase_comp[0, "Liq", "NaCl"].value, + "units": str(m.fs.RO.rejection_phase_comp[0, "Liq", "NaCl"].get_units()), + }, + "Recovery": { + "value": m.fs.RO.recovery_vol_phase[0.0, "Liq"].value, + "units": str(m.fs.RO.recovery_vol_phase[0.0, "Liq"].get_units()), + }, + } + for key, val in membrane_design.items(): + print(f"{key}: {val}") + if save_to_yaml: + save_dir = save_location if save_location is not None else os.getcwd() + os.makedirs(save_dir, exist_ok=True) + + print("Saving membrane design to directory: ", save_dir) + with open(f"{save_dir}/{membrane_name}.yaml", "w") as outfile: + yaml.dump( + membrane_design, outfile, default_flow_style=False, sort_keys=False + ) + return membrane_design + + +if __name__ == "__main__": + fit_sw30_4040_to_spec_sheet() + fit_bw30_4040_to_spec_sheet() + fit_nitto_ESPA2_LD() + fit_nitto_ESPA4_LD() + fit_ro_module_to_spec_sheet() diff --git a/watertap/flowsheets/specsheet_fitting_tools/tests/__init__.py b/watertap/flowsheets/specsheet_fitting_tools/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/BW30 PRO-4040.yaml b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/BW30 PRO-4040.yaml new file mode 100644 index 0000000000..9c5ff56246 --- /dev/null +++ b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/BW30 PRO-4040.yaml @@ -0,0 +1,30 @@ +A: + value: 1.1864448156677198e-11 + units: m**2*s/kg +B: + value: 2.9373129406392077e-08 + units: m/s +A (LMH/bar): + value: 4.271201336403789 + units: LMH/bar +B (LMH): + value: 0.10574326586301146 + units: LMH +Area: + value: 7.9 + units: m**2 +Length: + value: 1.0 + units: m +Porosity: + value: 0.85 + units: dimensionless +Channel_height: + value: 0.001 + units: m +Rejection_NaCl: + value: 0.997 + units: dimensionless +Recovery: + value: 0.15 + units: dimensionless diff --git a/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/ESPA2-LD.yaml b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/ESPA2-LD.yaml new file mode 100644 index 0000000000..1a42b713d0 --- /dev/null +++ b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/ESPA2-LD.yaml @@ -0,0 +1,30 @@ +A: + value: 1.555235310803282e-11 + units: m**2*s/kg +B: + value: 3.458945069362014e-08 + units: m/s +A (LMH/bar): + value: 5.598847118891813 + units: LMH/bar +B (LMH): + value: 0.12452202249703247 + units: LMH +Area: + value: 37.2 + units: m**2 +Length: + value: 1.0 + units: m +Porosity: + value: 0.85 + units: dimensionless +Channel_height: + value: 0.00086 + units: m +Rejection_NaCl: + value: 0.996 + units: dimensionless +Recovery: + value: 0.15 + units: dimensionless diff --git a/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/ESPA4-LD.yaml b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/ESPA4-LD.yaml new file mode 100644 index 0000000000..6648327558 --- /dev/null +++ b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/ESPA4-LD.yaml @@ -0,0 +1,30 @@ +A: + value: 2.749202913162948e-11 + units: m**2*s/kg +B: + value: 8.09476140145459e-08 + units: m/s +A (LMH/bar): + value: 9.897130487386608 + units: LMH/bar +B (LMH): + value: 0.2914114104523652 + units: LMH +Area: + value: 37.2 + units: m**2 +Length: + value: 1.0 + units: m +Porosity: + value: 0.85 + units: dimensionless +Channel_height: + value: 0.00086 + units: m +Rejection_NaCl: + value: 0.9920000000000001 + units: dimensionless +Recovery: + value: 0.15 + units: dimensionless diff --git a/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/RO_module.yaml b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/RO_module.yaml new file mode 100644 index 0000000000..8e5bb36264 --- /dev/null +++ b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/RO_module.yaml @@ -0,0 +1,30 @@ +A: + value: 1.4441273763939004e-11 + units: m**2*s/kg +B: + value: 3.9359866018440615e-08 + units: m/s +A (LMH/bar): + value: 5.198858555018039 + units: LMH/bar +B (LMH): + value: 0.1416955176663862 + units: LMH +Area: + value: 40.0 + units: m**2 +Length: + value: 1.0 + units: m +Porosity: + value: 0.85 + units: dimensionless +Channel_height: + value: 0.001 + units: m +Rejection_NaCl: + value: 0.995 + units: dimensionless +Recovery: + value: 0.15 + units: dimensionless diff --git a/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/SW30-4040.yaml b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/SW30-4040.yaml new file mode 100644 index 0000000000..fb697e3971 --- /dev/null +++ b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/SW30-4040.yaml @@ -0,0 +1,30 @@ +A: + value: 5.113908764712423e-12 + units: m**2*s/kg +B: + value: 2.554496834650047e-08 + units: m/s +A (LMH/bar): + value: 1.8410071552964713 + units: LMH/bar +B (LMH): + value: 0.09196188604740167 + units: LMH +Area: + value: 7.9 + units: m**2 +Length: + value: 1.0 + units: m +Porosity: + value: 0.85 + units: dimensionless +Channel_height: + value: 0.001 + units: m +Rejection_NaCl: + value: 0.997 + units: dimensionless +Recovery: + value: 0.08 + units: dimensionless diff --git a/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/__init__.py b/watertap/flowsheets/specsheet_fitting_tools/tests/expected_ro_results/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/watertap/flowsheets/specsheet_fitting_tools/tests/test_ro_module_fitter.py b/watertap/flowsheets/specsheet_fitting_tools/tests/test_ro_module_fitter.py new file mode 100644 index 0000000000..8034fb3536 --- /dev/null +++ b/watertap/flowsheets/specsheet_fitting_tools/tests/test_ro_module_fitter.py @@ -0,0 +1,77 @@ +################################################################################# +# WaterTAP Copyright (c) 2020-2024, 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/" +################################################################################# + +__author__ = "Alexander Dudchenko" +import pytest + +from watertap.flowsheets.specsheet_fitting_tools.ro_module_fitter import ( + fit_bw30_4040_to_spec_sheet, + fit_sw30_4040_to_spec_sheet, + fit_ro_module_to_spec_sheet, + fit_nitto_ESPA2_LD, + fit_nitto_ESPA4_LD, +) +import os + +_test_file_path = os.path.dirname(os.path.abspath(__file__)) + +import yaml + + +def load_test_yaml(file_name, remove_file=False): + with open(os.path.join(_test_file_path, file_name), "r") as f: + data = yaml.safe_load(f) + if remove_file: + os.remove(file_name) + return data + + +def validate_dict(dict1, dict2, rel_tol=1e-2): + for key in dict1: + assert pytest.approx(dict1[key]["value"], rel=rel_tol) == dict2[key]["value"] + assert dict1[key]["units"] == dict2[key]["units"] + + +def validate_result(ro_module, result): + expected_data = load_test_yaml( + f"{_test_file_path}/expected_ro_results/{ro_module}.yaml" + ) + validate_dict(result, expected_data) + load_result = load_test_yaml( + f"{_test_file_path}/{ro_module}.yaml", remove_file=True + ) + validate_dict(load_result, expected_data) + + +def test_fit_ESPA2_LD_to_spec_sheet(): + result = fit_nitto_ESPA2_LD(save_location=_test_file_path) + validate_result("ESPA2-LD", result) + + +def test_fit_ESPA4_LD_to_spec_sheet(): + result = fit_nitto_ESPA4_LD(save_location=_test_file_path) + validate_result("ESPA4-LD", result) + + +def test_fit_bw30_4040_to_spec_sheet(): + result = fit_bw30_4040_to_spec_sheet(save_location=_test_file_path) + validate_result("BW30 PRO-4040", result) + + +def test_fit_sw30_4040_to_spec_sheet(): + result = fit_sw30_4040_to_spec_sheet(save_location=_test_file_path) + validate_result("SW30-4040", result) + + +def test_fit_ro_module_to_spec_sheet(): + result = fit_ro_module_to_spec_sheet(save_location=_test_file_path) + validate_result("RO_module", result)