#####################################################################################################
# “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.
#####################################################################################################
####
# This code has been adapted from a test in the WaterTAP repo to
# simulate a simple separation of lithium and magnesium
#
# watertap > unit_models > tests > test_nanofiltration_DSPMDE_0D.py
# test defined as test_pressure_recovery_step_2_ions()
#
# also used the following flowsheet as a reference
# watertap > examples > flowsheets > nf_dspmde > nf.py
#
# https://github.com/watertap-org/watertap/blob/main/tutorials/nawi_spring_meeting2023.ipynb
####
"""
Nanofiltration flowsheet for Donnan steric pore model with dielectric exclusion
"""
# import statements
from pyomo.environ import (
ConcreteModel,
Constraint,
Objective,
TransformationFactory,
floor,
log10,
value,
)
from pyomo.network import Arc
import idaes.core.util.scaling as iscale
import idaes.logger as idaeslog
from idaes.core import FlowsheetBlock
from idaes.core.util.initialization import propagate_state
from idaes.core.util.model_statistics import degrees_of_freedom
from idaes.models.unit_models import Feed, Product
import matplotlib.pyplot as plt
import numpy as np
from watertap.core.solvers import get_solver
from watertap.property_models.multicomp_aq_sol_prop_pack import (
ActivityCoefficientModel,
DensityCalculation,
MCASParameterBlock,
)
from watertap.unit_models.nanofiltration_DSPMDE_0D import NanofiltrationDSPMDE0D
from watertap.unit_models.pressure_changer import Pump
_log = idaeslog.getLogger(__name__)
[docs]
def main():
"""
Builds and solves the NF flowsheet
Returns:
m: pyomo model
"""
# initialize lists to store sensitivity data
(
area,
li_rejection,
mg_rejection,
mg_li_ratio,
feed_ratio,
feed_pressure,
recovery_vals,
) = initialize_sensitivity()
# solve the system at each point
for recovery in recovery_vals:
solver = get_solver()
m = build()
initialize(m, solver)
_log.info("Initialization Okay")
if degrees_of_freedom(m) != 0:
raise ValueError("Degrees of freedom were not equal to zero")
solve_model(m, solver)
_log.info("Solved Box Problem")
unfix_optimization_variables(m)
add_objective(m)
add_pressure_constraint(m, pressure_limit=None)
add_recovery_constraint(m, recovery_limit=recovery)
solve_model(m, solver)
collect_plot_data(
m, area, li_rejection, mg_rejection, mg_li_ratio, feed_ratio, feed_pressure
)
print_information(m)
# create the sensitivity analysis plots using the data collected above
plot(
area,
li_rejection,
mg_rejection,
mg_li_ratio,
feed_ratio,
feed_pressure,
recovery_vals,
)
return m
[docs]
def set_default_feed(m, solver):
"""
Fixes the concentrations used to initialize the feed using the
concentration of the Salar de Atacama (kg/m3 = g/L)
Note: Cl- concentration will get overridden to enforce electroneutrality
Args:
m: pyomo model
solver: optimization solver
"""
conc_mass_phase_comp = {"Li_+": 1.19, "Mg_2+": 7.31, "Cl_-": 143.72}
set_nf_feed(
blk=m.fs,
solver=solver,
flow_mass_h2o=1, # arbitrary for now
conc_mass_phase_comp=conc_mass_phase_comp,
)
[docs]
def define_feed_composition():
"""
Returns the ion properties needed for the DSPM-DE property package
Ions include lithium, magnesium, and chloride, assuming LiCl and MgCl2 salts
diffusivity:
- https://www.aqion.de/site/diffusion-coefficients
- very confident
molecular weights:
- very confident
Stokes radius:
- average values from https://www.sciencedirect.com/science/article/pii/S138358661100637X
- medium confident (averaged values from multiple studies)
- reasonable orders of magnitude
ion charge:
- very confident
The activity coefficient options are ideal or davies
"""
default = {
"solute_list": ["Li_+", "Mg_2+", "Cl_-"],
"diffusivity_data": {
("Liq", "Li_+"): 1.03e-09,
("Liq", "Mg_2+"): 0.705e-09,
("Liq", "Cl_-"): 2.03e-09,
},
"mw_data": {"H2O": 0.018, "Li_+": 0.0069, "Mg_2+": 0.024, "Cl_-": 0.035},
"stokes_radius_data": {
"Li_+": 3.61e-10,
# "Mg_2+": 4.07e-10,
# "Cl_-": 3.28e-10
# adjusted Cl and Mg to values from nf.py'
"Cl_-": 0.121e-9,
"Mg_2+": 0.347e-9,
},
"charge": {"Li_+": 1, "Mg_2+": 2, "Cl_-": -1},
"activity_coefficient_model": ActivityCoefficientModel.ideal,
"density_calculation": DensityCalculation.constant,
}
return default
[docs]
def build():
"""
Builds the NF flowsheet
Returns:
m: pyomo model
"""
# create the model
m = ConcreteModel()
# create the flowsheet
m.fs = FlowsheetBlock(dynamic=False)
# define the property model
default = define_feed_composition()
m.fs.properties = MCASParameterBlock(**default)
# add the feed and product streams
m.fs.feed = Feed(property_package=m.fs.properties)
m.fs.permeate = Product(property_package=m.fs.properties)
m.fs.retentate = Product(property_package=m.fs.properties)
# define unit models
m.fs.pump = Pump(property_package=m.fs.properties)
m.fs.unit = NanofiltrationDSPMDE0D(property_package=m.fs.properties)
# connect the streams and blocks
m.fs.feed_to_pump = Arc(source=m.fs.feed.outlet, destination=m.fs.pump.inlet)
m.fs.pump_to_nf = Arc(source=m.fs.pump.outlet, destination=m.fs.unit.inlet)
m.fs.nf_to_permeate = Arc(
source=m.fs.unit.permeate, destination=m.fs.permeate.inlet
)
m.fs.nf_to_retentate = Arc(
source=m.fs.unit.retentate, destination=m.fs.retentate.inlet
)
TransformationFactory("network.expand_arcs").apply_to(m)
return m
[docs]
def fix_initial_variables(m):
"""
Fixes the initial variables needed to create 0 DOF
Args:
m: pyomo model
"""
# pump variables
m.fs.pump.efficiency_pump[0].fix(0.75)
m.fs.pump.control_volume.properties_in[0].temperature.fix(298.15)
m.fs.pump.control_volume.properties_in[0].pressure.fix(101325)
m.fs.pump.outlet.pressure[0].fix(2e5)
iscale.set_scaling_factor(m.fs.pump.control_volume.work, 1e-4)
# membrane operation
m.fs.unit.recovery_vol_phase[0, "Liq"].setub(0.95)
m.fs.unit.spacer_porosity.fix(0.85)
m.fs.unit.channel_height.fix(5e-4)
m.fs.unit.velocity[0, 0].fix(0.1)
m.fs.unit.area.fix(100)
m.fs.unit.mixed_permeate[0].pressure.fix(101325)
# variables for calculating mass transfer coefficient with spiral wound correlation
m.fs.unit.spacer_mixing_efficiency.fix()
m.fs.unit.spacer_mixing_length.fix()
# membrane properties
m.fs.unit.radius_pore.fix(0.5e-9)
m.fs.unit.membrane_thickness_effective.fix(1.33e-6)
m.fs.unit.membrane_charge_density.fix(-60)
m.fs.unit.dielectric_constant_pore.fix(41.3)
iscale.calculate_scaling_factors(m)
[docs]
def unfix_optimization_variables(m):
"""
Unfixes select variables to enable optimization with DOF>0
Args:
m: pyomo model
"""
m.fs.pump.outlet.pressure[0].unfix()
m.fs.unit.area.unfix()
[docs]
def add_objective(m):
"""
Adds objective to the pyomo model
Args:
m: pyomo model
"""
# limit Li loss
m.fs.objective = Objective(
expr=m.fs.retentate.flow_mol_phase_comp[0, "Liq", "Li_+"]
)
[docs]
def add_pressure_constraint(m, pressure_limit):
"""
Adds feed pressure constraint to the pyomo model
Args:
m: pyomo model
pressure_limit: upper bound on the outlet pump pressure
"""
if pressure_limit is None:
pressure_limit = 7e6
# bound the feed pressure to a reasonable value for nanofiltration
# choose an upper limit of 70 bar (https://doi.org/10.1021/acs.est.2c08584)
m.fs.pressure_constraint = Constraint(
expr=m.fs.pump.outlet.pressure[0] <= pressure_limit
)
[docs]
def add_recovery_constraint(m, recovery_limit):
"""
Adds recovery constraint to the pyomo model
Args:
m: pyomo model
recovery_limit: upper bound on the volume recovery
"""
if recovery_limit is None:
recovery_limit = 0.8
# limit the NF recovery
m.fs.recovery_constraint = Constraint(
expr=m.fs.unit.recovery_vol_phase[0.0, "Liq"] <= recovery_limit
)
[docs]
def solve_model(m, solver):
"""
Optimizes the flowsheet
Args:
m: pyomo model
solver: optimization solver
"""
_log.info(f"Optimizing with {format(degrees_of_freedom(m))} DOFs")
simulation_results = solver.solve(m, tee=True)
if simulation_results.solver.termination_condition != "optimal":
raise ValueError("The solver did not return optimal termination")
return simulation_results
[docs]
def initialize_sensitivity():
"""
Makes plots to perform a sensitivity analysis on the nanofiltration flowsheet
Returns:
area: list to store optimal membrane area (m2)
li_rejection: list to store lithium rejection
mg_rejection: list to store magnesium rejection
mg_li_ratio: list to store Mg:Li mass ratio of the permeate
feed_ratio: list to store Mg:Li mass ratio of the feed
feed_pressure: list to store the optimal feed pressure (bar)
recovery_vals: list that holds the volume recovery values to test
"""
# initialize lists to store data
area = [] # m2
li_rejection = []
mg_rejection = []
mg_li_ratio = []
feed_ratio = []
feed_pressure = [] # bar
# provide values to constrain
recovery_vals = np.arange(0.2, 1, 0.1)
return (
area,
li_rejection,
mg_rejection,
mg_li_ratio,
feed_ratio,
feed_pressure,
recovery_vals,
)
[docs]
def collect_plot_data(
m, area, li_rejection, mg_rejection, mg_li_ratio, feed_ratio, feed_pressure
):
"""
Stores the relevant information after each flowsheet solve to prepare plots
Args:
m: pyomo model
area: list to store optimal membrane area (m2)
li_rejection: list to store lithium rejection
mg_rejection: list to store magnesium rejection
mg_li_ratio: list to store Mg:Li mass ratio of the permeate
feed_ratio: list to store Mg:Li mass ratio of the feed
feed_pressure: list to store the optimal feed pressure (bar)
"""
area.append(value(m.fs.unit.area))
li_rejection.append(
value(m.fs.unit.rejection_intrinsic_phase_comp[0, "Liq", "Li_+"])
)
mg_rejection.append(
value(m.fs.unit.rejection_intrinsic_phase_comp[0, "Liq", "Mg_2+"])
)
mg_li_ratio.append(
(value(m.fs.permeate.flow_mol_phase_comp[0, "Liq", "Mg_2+"]) / 0.024)
/ (value(m.fs.permeate.flow_mol_phase_comp[0, "Liq", "Li_+"]) / 0.0069)
)
feed_ratio.append(
(value(m.fs.feed.flow_mol_phase_comp[0, "Liq", "Mg_2+"]) / 0.024)
/ (value(m.fs.feed.flow_mol_phase_comp[0, "Liq", "Li_+"]) / 0.0069)
)
feed_pressure.append(value(m.fs.pump.outlet.pressure[0]) / 1e5)
[docs]
def plot(
area,
li_rejection,
mg_rejection,
mg_li_ratio,
feed_ratio,
feed_pressure,
recovery_vals,
):
"""
Creates four subplots of the nanofiltration system, reporting
ion rejection, Mg:Li ratio, membrane area, and feed pressure
as the volume recovery of the membrane changes
Args:
area: list to store optimal membrane area (m2)
li_rejection: list to store lithium rejection
mg_rejection: list to store magnesium rejection
mg_li_ratio: list to store Mg:Li mass ratio of the permeate
feed_ratio: list to store Mg:Li mass ratio of the feed
feed_pressure: list to store the optimal feed pressure (bar)
recovery_vals: list that holds the volume recovery values to test
"""
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2)
ax1.plot(recovery_vals, li_rejection, "-o")
ax1.plot(recovery_vals, mg_rejection, "-o")
ax1.legend(["Li+", "Mg2+"])
ax1.set_title("Ion Rejection vs Volume Recovery")
ax1.set_ylabel("Rejection")
ax2.plot(recovery_vals, area, "-o")
ax2.set_title("Membrane Area vs Volume Recovery")
ax2.set_ylabel("Area (m2)")
ax3.plot(recovery_vals, mg_li_ratio, "-o")
ax3.plot(recovery_vals, feed_ratio, "r")
ax3.legend(["Permeate", "Feed"])
ax3.set_title("Mg:Li Mass Ratio vs Volume Recovery")
ax3.set_xlabel("Recovery")
ax3.set_ylabel("Mg:Li")
ax4.plot(recovery_vals, feed_pressure, "-o")
ax4.set_title("Feed Pressure vs Volume Recovery")
ax4.set_xlabel("Recovery")
ax4.set_ylabel("Feed Pressure (bar)")
plt.show()
[docs]
def initialize(m, solver):
"""
Initializes the flowsheet units
Args:
m: pyomo model
solver: optimization solver
"""
set_default_feed(m, solver)
fix_initial_variables(m)
m.fs.feed.initialize(optarg=solver.options)
propagate_state(m.fs.feed_to_pump)
m.fs.pump.initialize(optarg=solver.options)
propagate_state(m.fs.pump_to_nf)
m.fs.unit.initialize(optarg=solver.options)
propagate_state(m.fs.nf_to_permeate)
propagate_state(m.fs.nf_to_retentate)
m.fs.permeate.initialize(optarg=solver.options)
m.fs.retentate.initialize(optarg=solver.options)
[docs]
def set_nf_feed(blk, solver, flow_mass_h2o, conc_mass_phase_comp): # kg/m3
"""
Calculates the concentration of the feed solution in molar flow rate
Args:
blk: flowsheet block
solver: optimization solver
flow_mass_h2o: inlet water flow rate (feed)
conc_mass_phase_conc: mass concentration (feed)
"""
if solver is None:
solver = get_solver()
# fix the inlet flow to the block as water flowrate
blk.feed.properties[0].flow_mass_phase_comp["Liq", "H2O"].fix(flow_mass_h2o)
# fix the ion cncentrations and unfix ion flows
for ion, x in conc_mass_phase_comp.items():
blk.feed.properties[0].conc_mass_phase_comp["Liq", ion].fix(x)
blk.feed.properties[0].flow_mol_phase_comp["Liq", ion].unfix()
# solve for the new flow rates
solver.solve(blk.feed)
# fix new water concentration
blk.feed.properties[0].conc_mass_phase_comp["Liq", "H2O"].fix()
# unfix ion concentrations and fix flows
for ion, x in conc_mass_phase_comp.items():
blk.feed.properties[0].conc_mass_phase_comp["Liq", ion].unfix()
blk.feed.properties[0].flow_mol_phase_comp["Liq", ion].fix()
blk.feed.properties[0].flow_mass_phase_comp["Liq", ion].unfix()
blk.feed.properties[0].conc_mass_phase_comp["Liq", "H2O"].unfix()
blk.feed.properties[0].flow_mass_phase_comp["Liq", "H2O"].unfix()
blk.feed.properties[0].flow_mol_phase_comp["Liq", "H2O"].fix()
set_nf_feed_scaling(blk)
# assert electroneutrality
blk.feed.properties[0].assert_electroneutrality(
defined_state=True, adjust_by_ion="Cl_-", get_property="flow_mol_phase_comp"
)
# switching to concentration for ease of adjusting in UI
# addresses error in fixing flow_mol_phase_comp
for ion, x in conc_mass_phase_comp.items():
blk.feed.properties[0].conc_mass_phase_comp["Liq", ion].unfix()
blk.feed.properties[0].flow_mol_phase_comp["Liq", ion].fix()
[docs]
def calculate_scale(value):
"""
Calculates a default scaling value
"""
return -1 * floor(log10(value))
[docs]
def set_nf_feed_scaling(blk):
"""
Calculates the default scaling for the feed solution
"""
_add = 0
for i in blk.feed.properties[0].flow_mol_phase_comp:
scale = calculate_scale(value(blk.feed.properties[0].flow_mol_phase_comp[i]))
print(f"{i} flow_mol_phase_comp scaling factor = {10**(scale+_add)}")
blk.properties.set_default_scaling(
"flow_mol_phase_comp", 10 ** (scale + _add), index=i
)
if __name__ == "__main__":
main()