#####################################################################################################
# “PrOMMiS” was produced under the DOE Process Optimization and Modeling for Minerals Sustainability
# (“PrOMMiS”) initiative, and is copyright (c) 2023-2026 by the software owners: The Regents of the
# University of California, through Lawrence Berkeley National Laboratory, et al. All rights reserved.
# Please see the files COPYRIGHT.md and LICENSE.md for full copyright and license information.
#####################################################################################################
"""
Sample flowsheet for the multi-component diafiltration cascade.
Author: Molly Dougher
"""
from pyomo.environ import (
ConcreteModel,
SolverFactory,
TransformationFactory,
assert_optimal_termination,
value,
)
from pyomo.network import Arc
from idaes.core import FlowsheetBlock
from idaes.core.util.model_diagnostics import DiagnosticsToolbox
from idaes.models.unit_models import Feed, Product
import matplotlib.pyplot as plt
from pandas import DataFrame
from prommis.nanofiltration.multi_component_diafiltration_stream_properties import (
MultiComponentDiafiltrationStreamParameter,
)
from prommis.nanofiltration.multi_component_diafiltration_solute_properties import (
MultiComponentDiafiltrationSoluteParameter,
)
from prommis.nanofiltration.multi_component_diafiltration import (
MultiComponentDiafiltration,
)
[docs]
def main():
"""
Builds and solves flowsheet with multi-component diafiltration unit model
for a two-salt (LiCl + CoCl2) solution.
"""
# build flowsheet
m = ConcreteModel()
m.fs = FlowsheetBlock(dynamic=False)
# specify the feed
cation_list = ["Li", "Co"]
anion_list = ["Cl"]
inlet_flow_volume = {"feed": 12.5, "diafiltrate": 3.75}
inlet_concentration = {
"feed": {"Li": 245, "Co": 288, "Cl": 821},
"diafiltrate": {"Li": 14, "Co": 3, "Cl": 20},
}
m.fs.stream_properties = MultiComponentDiafiltrationStreamParameter(
cation_list=cation_list,
anion_list=anion_list,
)
m.fs.properties = MultiComponentDiafiltrationSoluteParameter(
cation_list=cation_list,
anion_list=anion_list,
)
# add feed blocks for feed and diafiltrate
m.fs.feed_block = Feed(property_package=m.fs.stream_properties)
m.fs.diafiltrate_block = Feed(property_package=m.fs.stream_properties)
# add the membrane unit model
m.fs.membrane = MultiComponentDiafiltration(
property_package=m.fs.properties,
cation_list=cation_list,
anion_list=anion_list,
include_boundary_layer=True,
NFE_module_length=10,
NFE_boundary_layer_thickness=5,
NFE_membrane_thickness=5,
)
# update parameter inputs if desired
update_membrane_parameters(m)
# add product blocks for retentate and permeate
m.fs.retentate_block = Product(property_package=m.fs.stream_properties)
m.fs.permeate_block = Product(property_package=m.fs.stream_properties)
# fix the degrees of freedom
fix_variables(m, inlet_flow_volume, inlet_concentration)
# initialize membrane model
initialized_membrane_model = m.fs.membrane.default_initializer()
initialized_membrane_model.initialize(m.fs.membrane)
# add and connect flowsheet streams
add_and_connect_streams(m)
# check structural warnings
dt = DiagnosticsToolbox(m)
dt.assert_no_structural_warnings()
# solve model
solve_model(m)
# check numerical warnings
dt.assert_no_numerical_warnings()
# visualize the results
overall_results_plot = plot_results_by_length(m)
boundary_layer_results_plot = plot_results_by_thickness(m, phase="Boundary Layer")
membrane_results_plot = plot_results_by_thickness(m, phase="Membrane")
rejection_plot = plot_rejection_versus_concentration(m)
return (
m,
overall_results_plot,
boundary_layer_results_plot,
membrane_results_plot,
rejection_plot,
)
[docs]
def update_membrane_parameters(m):
"""
Updates parameters needed in multi-component diafiltration unit model if desired.
Args:
m: Pyomo model
"""
pass
def fix_variables(m, inlet_flow_volume, inlet_concentration):
# fix degrees of freedom in the membrane
m.fs.membrane.total_module_length.fix()
m.fs.membrane.total_membrane_length.fix()
m.fs.membrane.applied_pressure.fix()
m.fs.membrane.feed_flow_volume.fix(inlet_flow_volume["feed"])
m.fs.membrane.diafiltrate_flow_volume.fix(inlet_flow_volume["diafiltrate"])
for t in m.fs.membrane.time:
for j in m.fs.membrane.solutes:
m.fs.membrane.feed_conc_mol_comp[t, j].fix(inlet_concentration["feed"][j])
m.fs.membrane.diafiltrate_conc_mol_comp[t, j].fix(
inlet_concentration["diafiltrate"][j]
)
def add_and_connect_streams(m):
m.fs.feed_stream = Arc(
source=m.fs.feed_block.outlet,
destination=m.fs.membrane.feed_inlet,
)
m.fs.diafiltrate_stream = Arc(
source=m.fs.diafiltrate_block.outlet,
destination=m.fs.membrane.diafiltrate_inlet,
)
m.fs.retentate_stream = Arc(
source=m.fs.membrane.retentate_outlet,
destination=m.fs.retentate_block.inlet,
)
m.fs.permeate_stream = Arc(
source=m.fs.membrane.permeate_outlet,
destination=m.fs.permeate_block.inlet,
)
TransformationFactory("network.expand_arcs").apply_to(m)
[docs]
def solve_model(m):
"""
Solves scaled model.
Args:
m: Pyomo model
"""
scaling = TransformationFactory("core.scale_model")
scaled_model = scaling.create_using(m, rename=False)
solver = SolverFactory("ipopt")
results = solver.solve(scaled_model, tee=True)
assert_optimal_termination(results)
scaling.propagate_solution(scaled_model, m)
[docs]
def plot_results_by_length(m):
"""
Plots concentration and flux variables across the length of the membrane module.
Args:
m: Pyomo model
"""
# store values for x-coordinate
x_axis_values = []
# store values for concentration of Li in the retentate
conc_ret_lith = []
# store values for concentration of Li at interface of BL and M
conc_int_lith = []
# store values for concentration of Li in the permeate
conc_perm_lith = []
# store values for concentration of Co in the retentate
conc_ret_cob = []
# store values for concentration of Co at interface of BL and M
conc_int_cob = []
# store values for concentration of Co in the permeate
conc_perm_cob = []
# store values for water flux across membrane
water_flux = []
# store values for mol flux of Li across membrane
Li_flux = []
# store values for mol flux of Co across membrane
Co_flux = []
# store values for percent recovery
percent_recovery = []
# store values for Li rejection (observed)
Li_rejection_obs = []
# store values for Li rejection (actual)
Li_rejection_act = []
# store values for Co rejection (observed)
Co_rejection_obs = []
# store values for Co rejection (actual)
Co_rejection_act = []
for x_val in m.fs.membrane.dimensionless_module_length:
if x_val != 0:
x_axis_values.append(x_val * value(m.fs.membrane.total_module_length))
conc_ret_lith.append(
value(m.fs.membrane.retentate_conc_mol_comp[0, x_val, "Li"])
)
conc_int_lith.append(
value(m.fs.membrane.boundary_layer_conc_mol_comp[0, x_val, 1, "Li"])
)
conc_perm_lith.append(
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Li"])
)
conc_ret_cob.append(
value(m.fs.membrane.retentate_conc_mol_comp[0, x_val, "Co"])
)
conc_int_cob.append(
value(m.fs.membrane.boundary_layer_conc_mol_comp[0, x_val, 1, "Co"])
)
conc_perm_cob.append(
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Co"])
)
water_flux.append(value(m.fs.membrane.volume_flux_water[0, x_val]))
Li_flux.append(value(m.fs.membrane.molar_ion_flux[0, x_val, "Li"]))
Co_flux.append(value(m.fs.membrane.molar_ion_flux[0, x_val, "Co"]))
Li_rejection_obs.append(
(
1
- (
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Li"])
/ value(m.fs.membrane.retentate_conc_mol_comp[0, x_val, "Li"])
)
)
* 100
)
Li_rejection_act.append(
(
1
- (
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Li"])
/ value(
m.fs.membrane.boundary_layer_conc_mol_comp[
0, x_val, 1, "Li"
]
)
)
)
* 100
)
Co_rejection_obs.append(
(
1
- (
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Co"])
/ value(m.fs.membrane.retentate_conc_mol_comp[0, x_val, "Co"])
)
)
* 100
)
Co_rejection_act.append(
(
1
- (
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Co"])
/ value(
m.fs.membrane.boundary_layer_conc_mol_comp[
0, x_val, 1, "Co"
]
)
)
)
* 100
)
percent_recovery.append(
(
value(m.fs.membrane.permeate_flow_volume[0, x_val])
/ (
value(m.fs.membrane.feed_flow_volume[0])
+ value(m.fs.membrane.diafiltrate_flow_volume[0])
)
* 100
)
)
fig, ((ax1, ax2), (ax3, ax4), (ax5, ax6)) = plt.subplots(
3, 2, dpi=100, figsize=(12, 10)
)
ax1.plot(x_axis_values, conc_ret_lith, linewidth=2, label="retentate")
ax1.plot(x_axis_values, conc_int_lith, linewidth=2, label="interface")
ax1.plot(x_axis_values, conc_perm_lith, linewidth=2, label="permeate")
ax1.set_ylabel(
"Lithium Concentration (mol/m$^3$)",
fontsize=10,
fontweight="bold",
)
ax1.tick_params(direction="in", labelsize=10)
ax1.legend()
ax2.plot(x_axis_values, conc_ret_cob, linewidth=2, label="retentate")
ax2.plot(x_axis_values, conc_int_cob, linewidth=2, label="interface")
ax2.plot(x_axis_values, conc_perm_cob, linewidth=2, label="permeate")
ax2.set_ylabel(
"Cobalt Concentration (mol/m$^3$)",
fontsize=10,
fontweight="bold",
)
ax2.tick_params(direction="in", labelsize=10)
ax2.legend()
ax3.plot(x_axis_values, water_flux, linewidth=2)
ax3.set_xlabel("Module Length (m)", fontsize=10, fontweight="bold")
ax3.set_ylabel("Water Flux (m$^3$/m$^2$/h)", fontsize=10, fontweight="bold")
ax3.tick_params(direction="in", labelsize=10)
ax4.plot(x_axis_values, Li_flux, linewidth=2, label="Li")
ax4.plot(x_axis_values, Co_flux, linewidth=2, label="Co")
ax4.set_xlabel("Module Length (m)", fontsize=10, fontweight="bold")
ax4.set_ylabel("Solute Molar Flux (mol/m$^2$/h)", fontsize=10, fontweight="bold")
ax4.tick_params(direction="in", labelsize=10)
ax4.legend()
ax5.plot(x_axis_values, Li_rejection_obs, linewidth=2, label="Li (observed)")
ax5.plot(
x_axis_values,
Li_rejection_act,
"--",
linewidth=2,
label="Li (actual)",
)
ax5.plot(x_axis_values, Co_rejection_obs, linewidth=2, label="Co (observed)")
ax5.plot(x_axis_values, Co_rejection_act, "--", linewidth=2, label="Co (actual)")
ax5.set_xlabel("Module Length (m)", fontsize=10, fontweight="bold")
ax5.set_ylabel("Solute Rejection (%)", fontsize=10, fontweight="bold")
ax5.tick_params(direction="in", labelsize=10)
ax5.legend()
ax6.plot(x_axis_values, percent_recovery, linewidth=2)
ax6.set_xlabel("Module Length (m)", fontsize=10, fontweight="bold")
ax6.set_ylabel("Percent Recovery (%)", fontsize=10, fontweight="bold")
ax6.tick_params(direction="in", labelsize=10)
plt.show()
return fig
[docs]
def plot_results_by_thickness(m, phase):
"""
Plots concentrations within the boundary layer or membrane.
Args:
m: Pyomo model
"""
x_axis_values = []
z_axis_values = []
# store values for concentration of Li
conc_lith = []
conc_lith_dict = {}
# store values for concentration of Co
conc_cob = []
conc_cob_dict = {}
# store values for concentration of Cl
conc_chl = []
conc_chl_dict = {}
for x_val in m.fs.membrane.dimensionless_module_length:
if x_val != 0:
x_axis_values.append(x_val * value(m.fs.membrane.total_module_length))
if phase == "Boundary Layer":
for z_val in m.fs.membrane.dimensionless_boundary_layer_thickness:
z_axis_values.append(
z_val * value(m.fs.membrane.total_boundary_layer_thickness) * 1e6
)
for x_val in m.fs.membrane.dimensionless_module_length:
if x_val != 0:
conc_lith.append(
value(
m.fs.membrane.boundary_layer_conc_mol_comp[
0, x_val, z_val, "Li"
]
)
)
conc_cob.append(
value(
m.fs.membrane.boundary_layer_conc_mol_comp[
0, x_val, z_val, "Co"
]
)
)
conc_chl.append(
value(
m.fs.membrane.boundary_layer_conc_mol_comp[
0, x_val, z_val, "Cl"
]
)
)
conc_lith_dict[f"{z_val}"] = conc_lith
conc_cob_dict[f"{z_val}"] = conc_cob
conc_chl_dict[f"{z_val}"] = conc_chl
conc_lith = []
conc_cob = []
conc_chl = []
elif phase == "Membrane":
for z_val in m.fs.membrane.dimensionless_membrane_thickness:
z_axis_values.append(
z_val * value(m.fs.membrane.total_membrane_thickness) * 1e9
)
for x_val in m.fs.membrane.dimensionless_module_length:
if x_val != 0:
conc_lith.append(
value(
m.fs.membrane.membrane_conc_mol_comp[0, x_val, z_val, "Li"]
)
)
conc_cob.append(
value(
m.fs.membrane.membrane_conc_mol_comp[0, x_val, z_val, "Co"]
)
)
conc_chl.append(
value(
m.fs.membrane.membrane_conc_mol_comp[0, x_val, z_val, "Cl"]
)
)
conc_lith_dict[f"{z_val}"] = conc_lith
conc_cob_dict[f"{z_val}"] = conc_cob
conc_chl_dict[f"{z_val}"] = conc_chl
conc_lith = []
conc_cob = []
conc_chl = []
conc_lith_df = DataFrame(index=x_axis_values, data=conc_lith_dict)
conc_cob_df = DataFrame(index=x_axis_values, data=conc_cob_dict)
conc_chl_df = DataFrame(index=x_axis_values, data=conc_chl_dict)
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, dpi=125, figsize=(15, 7))
Li_plot = ax1.pcolor(z_axis_values, x_axis_values, conc_lith_df, cmap="Greens")
if phase == "Boundary Layer":
ax1.set_xlabel("Boundary Layer Thickness (um)", fontsize=10, fontweight="bold")
elif phase == "Membrane":
ax1.set_xlabel("Membrane Thickness (nm)", fontsize=10, fontweight="bold")
ax1.set_ylabel("Module Length (m)", fontsize=10, fontweight="bold")
ax1.set_title(
f"Lithium Concentration\n in {phase} (mol/m$^3$)",
fontsize=10,
fontweight="bold",
)
ax1.tick_params(direction="in", labelsize=10)
fig.colorbar(Li_plot, ax=ax1)
Co_plot = ax2.pcolor(z_axis_values, x_axis_values, conc_cob_df, cmap="Blues")
if phase == "Boundary Layer":
ax2.set_xlabel("Boundary Layer Thickness (um)", fontsize=10, fontweight="bold")
elif phase == "Membrane":
ax2.set_xlabel("Membrane Thickness (nm)", fontsize=10, fontweight="bold")
ax2.set_title(
f"Cobalt Concentration\n in {phase} (mol/m$^3$)", fontsize=10, fontweight="bold"
)
ax2.tick_params(direction="in", labelsize=10)
fig.colorbar(Co_plot, ax=ax2)
Cl_plot = ax3.pcolor(z_axis_values, x_axis_values, conc_chl_df, cmap="Oranges")
if phase == "Boundary Layer":
ax3.set_xlabel("Boundary Layer Thickness (um)", fontsize=10, fontweight="bold")
elif phase == "Membrane":
ax3.set_xlabel("Membrane Thickness (nm)", fontsize=10, fontweight="bold")
ax3.set_title(
f"Chloride Concentration\n in {phase} (mol/m$^3$)",
fontsize=10,
fontweight="bold",
)
ax3.tick_params(direction="in", labelsize=10)
fig.colorbar(Cl_plot, ax=ax3)
plt.show()
return fig
[docs]
def plot_rejection_versus_concentration(m):
"""
Plots rejection versus retentate-side concentration.
Args:
m: Pyomo model
"""
# store values for concentration of Li in the retentate
conc_ret_lith = []
# store values for concentration of Li at interface of BL and M
conc_int_lith = []
# store values for concentration of Li in the permeate
conc_perm_lith = []
# store values for concentration of Co in the retentate
conc_ret_cob = []
# store values for concentration of Co at interface of BL and M
conc_int_cob = []
# store values for concentration of Co in the permeate
conc_perm_cob = []
# store values for Li rejection (observed)
Li_rejection_obs = []
# store values for Li rejection (actual)
Li_rejection_act = []
# store values for Co rejection (observed)
Co_rejection_obs = []
# store values for Co rejection (actual)
Co_rejection_act = []
for x_val in m.fs.membrane.dimensionless_module_length:
if x_val != 0:
conc_ret_lith.append(
value(m.fs.membrane.retentate_conc_mol_comp[0, x_val, "Li"])
)
conc_int_lith.append(
value(m.fs.membrane.boundary_layer_conc_mol_comp[0, x_val, 1, "Li"])
)
conc_perm_lith.append(
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Li"])
)
conc_ret_cob.append(
value(m.fs.membrane.retentate_conc_mol_comp[0, x_val, "Co"])
)
conc_int_cob.append(
value(m.fs.membrane.boundary_layer_conc_mol_comp[0, x_val, 1, "Co"])
)
conc_perm_cob.append(
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Co"])
)
Li_rejection_obs.append(
(
1
- (
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Li"])
/ value(m.fs.membrane.retentate_conc_mol_comp[0, x_val, "Li"])
)
)
* 100
)
Li_rejection_act.append(
(
1
- (
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Li"])
/ value(
m.fs.membrane.boundary_layer_conc_mol_comp[
0, x_val, 1, "Li"
]
)
)
)
* 100
)
Co_rejection_obs.append(
(
1
- (
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Co"])
/ value(m.fs.membrane.retentate_conc_mol_comp[0, x_val, "Co"])
)
)
* 100
)
Co_rejection_act.append(
(
1
- (
value(m.fs.membrane.permeate_conc_mol_comp[0, x_val, "Co"])
/ value(
m.fs.membrane.boundary_layer_conc_mol_comp[
0, x_val, 1, "Co"
]
)
)
)
* 100
)
fig, (ax1, ax2) = plt.subplots(1, 2, dpi=100, figsize=(10, 5))
ax1.plot(conc_ret_lith, Li_rejection_obs, linewidth=2, label="observed")
ax1.plot(conc_ret_lith, Li_rejection_act, linewidth=2, label="actual")
ax1.set_xlabel(
"Lithium Concentration (Feed-Side) (mol/m$^3$)",
fontsize=10,
fontweight="bold",
)
ax1.set_ylabel("Percent Rejection (%)", fontsize=10, fontweight="bold")
ax1.tick_params(direction="in", labelsize=10)
ax1.legend()
ax2.plot(conc_ret_cob, Co_rejection_obs, linewidth=2, label="observed")
ax2.plot(conc_ret_cob, Co_rejection_act, linewidth=2, label="actual")
ax2.set_xlabel(
"Cobalt Concentration (Feed-Side) (mol/m$^3$)",
fontsize=10,
fontweight="bold",
)
ax2.set_ylabel("Percent Rejection (%)", fontsize=10, fontweight="bold")
ax2.tick_params(direction="in", labelsize=10)
ax2.legend()
plt.show()
return fig
if __name__ == "__main__":
main()