#####################################################################################################
# “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 model is derived from
# https://github.com/watertap-org/watertap/blob/main/watertap/flowsheets/ion_exchange/ion_exchange_demo.py
# WaterTAP License Agreement
# WaterTAP Copyright (c) 2020-2026, 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/"
#################################################################################
r"""This model is an example on how to use the ion exchange multicomponent
model (IXMC) for the removal of REEs.
"""
# Import Python libraries
import os
import json
import logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Import Pyomo components
import pyomo.environ as pyo
# Import IDAES models and libraries
from idaes.core import FlowsheetBlock, UnitModelCostingBlock
from idaes.core.solvers import get_solver
from idaes.core.scaling.scaling_base import ScalerBase
from idaes.core.util.model_statistics import degrees_of_freedom
from idaes.core.util.model_diagnostics import DiagnosticsToolbox
from idaes.core.util.exceptions import ConfigurationError
# Import WaterTAP models and libraries
from watertap.property_models.multicomp_aq_sol_prop_pack import MCASParameterBlock
# Import PrOMMiS ion exchange models
from prommis.ion_exchange.ion_exchange_multicomponent import (
IonExchangeMultiComp,
)
from prommis.ion_exchange.costing.ion_exchange_cost_model import (
IXCosting,
IXCostingData,
)
logging.basicConfig(level=logging.INFO)
logging.getLogger("pyomo.repn.plugins.nl_writer").setLevel(logging.ERROR)
""" This model solves an example for the separation of multiple rare
earth elements (REEs) using an Ion Exchange (IX) model
REFERENCES:
[1] M. Hermassi, M. Granados, C. Valderrama, N. Skoglund, C. Ayora,
J.L. Cortina, Impact of functional group types in ion exchange resins
on rare earth element recovery from treated acid mine waters, Journal
of Cleaner Production, Volume 379, Part 2, 2022, 134742,
https://doi.org/10.1016/j.jclepro.2022.134742.
[2] WebPlotDigitizer, https://automeris.io/docs/
[3] Croll, H. C., Adelman, M. J., Chow, S. J., Schwab, K. J., Capelle,
R., Oppenheimer, J., & Jacangelo, J. G. (2023). Fundamental kinetic
constants for breakthrough of per- and polyfluoroalkyl substances at
varying empty bed contact times: Theoretical analysis and pilot scale
demonstration. Chemical Engineering Journal,
464. doi:10.1016/j.cej.2023.142587
[4] Crittenden, J. C., Trussell, R. R., Hand, D. W., Howe, K. J., & Tchobanoglous, G. (2012).
Chapter 16: Ion Exchange. MWH's Water Treatment (pp. 1263-1334): John Wiley & Sons, Inc.
[5] Ana T. Lima and Lisbeth M. Ottosen 2022 J. Electrochem. Soc. 169 033501
"""
__author__ = "Soraya_Rawlings"
[docs]
def main():
"""Method to build an ion exchange unit model using data from the
literature.
"""
# Define path for the data files for resins, properties of
# components, and calculated parameters from Parmest model
# solution. The pressure drop and bed expansion parameters in the
# resin_data file where obtained using ref[2].
# curr_dir = dirname(abspath(__file__))
# data_path = abspath(join(curr_dir, "data"))
# print(data_ptah)
path = os.path.dirname(os.path.realpath(__file__))
resin_file = os.path.join(path, "data", "resin_data.json")
comp_prop_file = os.path.join(path, "data", "properties_data.json")
parmest_file = os.path.join(path, "data", "parmest_data.json")
# Read original breakthrough data for multiple components. The
# data is from ref[1], Figure 4, using ref[2].
curve_file = os.path.join(path, "data", "breakthrough_literature_data.csv")
curve_data = pd.read_csv(curve_file)
# Define solver to use
solver = get_solver(solver="ipopt_v2")
# Add relevant data for IX model: resin name, target component,
# number of trapezoidal points, and the minimum concentration for
# the period 0 in the trapezoidal rule equations.
resin = "S950"
target_component = "La"
num_traps = 30
c_trap_min = 1e-3
regenerant = "single_use"
save_plot = False
hazardous_waste = False
# Build the model by calling the Pyomo ConcreteModel() component
m = build_model()
# Add all relevant data
add_data(
m,
resin=resin,
curve_data=curve_data,
resin_file=resin_file,
comp_prop_file=comp_prop_file,
parmest_file=parmest_file,
)
# Build IX model
parmest_data = m.fs.parmest_data
build_clark(
m,
resin=resin,
regenerant=regenerant,
target_component=target_component,
num_traps=num_traps,
c_trap_min=c_trap_min,
resin_file=resin_file,
hazardous_waste=hazardous_waste,
)
# Add lower and upper bounds to relevant variables in the IX model
set_bounds(m)
# Add the operating conditions for the column
set_operating_conditions(
m, parmest_data=parmest_data, target_component=target_component, resin=resin
)
# Call IDAES diagnostics tool box to make sure there are no
# structural issues in the model
dt = DiagnosticsToolbox(model=m)
dt.assert_no_structural_warnings()
print()
print("=========== Start initialization")
initialize_system(m, solver=solver)
# Add scaling factors to the IX model variables and constraints
set_scaling(m)
dt.assert_no_numerical_warnings(ignore_parallel_components=True)
assert degrees_of_freedom(m) == 0
# Solve scaled model with zero degrees of freedom
init_results = model_solve(m)
pyo.assert_optimal_termination(init_results)
print("=========== End initialization")
# Add relevant costing metrics for the IX model
add_costing(m)
print()
print("=========== Re-run initialization with costing")
# Solve and re-initialize the model after adding costing variables
initialize_system(m, solver=solver)
dt.assert_no_structural_warnings()
init_costing_results = model_solve(m, solver=solver)
pyo.assert_optimal_termination(init_costing_results)
print("=========== End initialization(s)")
print()
print()
print("=========== Start optimization run")
print()
# Solve an optimization model
run_optimization(m, target_component=target_component)
results = model_solve(m)
pyo.assert_optimal_termination(results)
print()
print()
print("****** Optimization results")
m.fs.unit_ix.report()
# Plot the breakthrough profiles with optimization solution
output_plot = "breakthrough_curves_optimization"
plot_traps(
m,
results=results,
curve_data=curve_data,
output_plot=output_plot,
save_plot=save_plot,
)
[docs]
def add_data(
m,
resin=None,
curve_data=None,
resin_file=None,
comp_prop_file=None,
parmest_file=None,
):
"""
Add component properties, resin, and parameter estimation data
"""
# Declare all components in the feed stream as lists
m.list_solvent = ["H2O"]
m.list_reactive_ions = ["La", "Dy", "Ho", "Er", "Yb", "Sm"]
m.list_all = m.list_solvent + m.list_reactive_ions
# Declare lists and dictionaries to be used during scaling
m.fs.calc_from_constr_dict = {
"service_flow_rate": "eq_service_flow_rate",
"bed_volume_total": "eq_bed_design",
"column_height": "eq_column_height",
}
m.fs.scale_from_value = [
"bed_volume_total",
"bed_diameter",
"column_height",
"service_flow_rate",
"ebct",
"N_Re",
"N_Pe_bed",
"N_Pe_particle",
]
# Read data for resins, properties of components, and calculated
# parameters from Parmest model solution using .json files.
with open(resin_file, "r") as file:
m.fs.resin_data = json.load(file)
with open(comp_prop_file, "r") as file:
m.fs.props_data = json.load(file)
with open(parmest_file, "r") as file:
m.fs.parmest_data = json.load(file)
# Save the data of breakthrough curves in a new empty dictionary
# "data_init" and assign the relevant values to new elements in
# the dictionary.
m.fs.data_init = {}
m.fs.data_init["conc_mass"] = curve_data.set_index("compound")[
"c0"
].to_dict() # in mg/L
m.fs.data_init["c_norm"] = curve_data.set_index("compound")[
"c_norm"
].to_dict() # dimensionless
m.fs.data_init["flow_in"] = curve_data["flow_in"][0] # in m3/s
m.fs.data_init["bed_diam"] = curve_data["bed_diam"][0] # in m
m.fs.data_init["bed_depth"] = curve_data["bed_depth"][0] # in m
m.fs.data_init["loading_rate"] = curve_data["vel_bed"][0] # in m/s
m.fs.data_init["density_solution"] = 1e3 # in kg/m3, assumed
return m
[docs]
def build_model():
"""
Method to build a flowsheet
"""
m = pyo.ConcreteModel()
m.fs = FlowsheetBlock(dynamic=False)
return m
def build_clark(
m,
resin=None,
regenerant=None,
target_component=None,
num_traps=None,
c_trap_min=None,
resin_file=None,
hazardous_waste=None,
):
# Add sets for solvent and ion species
m.fs.set_solvent = pyo.Set(initialize=m.list_solvent)
m.fs.set_reactive_ions = pyo.Set(initialize=m.list_reactive_ions)
m.fs.set_all = pyo.Set(initialize=m.list_all)
# Declare properties for ions needed in the IX unit model
ion_props = {
"solute_list": [],
"diffusivity_data": {},
"molar_volume_data": {},
"mw_data": {"H2O": m.fs.props_data["mw"]["H2O"]}, # in kg/mol
"charge": {},
}
for ion in m.fs.set_reactive_ions:
ion_props["solute_list"].append(ion)
# Diffusivity data from ref[5]
if ion in m.fs.props_data["diffusivity"]:
ion_props["diffusivity_data"][("Liq", ion)] = m.fs.props_data[
"diffusivity"
][ion]
else:
ion_props["molar_volume_data"][("Liq", ion)] = m.fs.props_data["molar_vol"][
ion
]
ion_props["mw_data"][ion] = m.fs.props_data["mw"][ion]
ion_props["charge"][ion] = m.fs.props_data["charge"][ion]
# NOTE: Use Hayduk Laudie to calculate diffusivity
ion_props["diffus_calculation"] = "HaydukLaudie"
m.fs.ix_properties = MCASParameterBlock(**ion_props)
ix_config = {
"property_package": m.fs.ix_properties,
"regenerant": regenerant,
"hazardous_waste": hazardous_waste,
"target_component": target_component,
"reactive_ions": m.list_reactive_ions,
"number_of_trapezoids": num_traps,
"minimum_concentration_trapezoids": c_trap_min,
"resin_data_path": resin_file,
"resin": resin,
}
ix = m.fs.unit_ix = IonExchangeMultiComp(**ix_config)
# Touch relevant variables
ix.process_flow.properties_in[0].flow_vol_phase["Liq"]
ix.process_flow.properties_in[0].pressure
ix.process_flow.properties_in[0].temperature
for i in m.fs.set_all:
ix.process_flow.properties_in[0].flow_mass_phase_comp["Liq", i]
ix.process_flow.properties_out[0].flow_mass_phase_comp["Liq", i]
ix.process_flow.properties_in[0].conc_mass_phase_comp["Liq", i]
ix.process_flow.properties_out[0].conc_mass_phase_comp["Liq", i]
@m.fs.unit_ix.Expression(
m.fs.set_reactive_ions, doc="Percentage of recovery for all the REEs"
)
def recovery_comp(b, s):
conc_in = b.process_flow.properties_in[0].conc_mass_phase_comp["Liq", s]
conc_out = b.process_flow.properties_out[0].conc_mass_phase_comp["Liq", s]
return ((conc_in - conc_out) / conc_in) * 100
[docs]
def set_bounds(m):
"""This method adds bounds to relevant variables"""
ix = m.fs.unit_ix
ix.bed_porosity.setub(1)
ix.number_columns.setlb(0)
for c in m.fs.set_reactive_ions:
ix.conc_comp_norm_trapezoids[c, 0].fix(1e-12)
ix.breakthrough_time_trapezoids[c, 0].fix(1e-3)
ix.bed_diameter.setlb(0.01)
ix.bed_diameter.setub(100)
ix.bed_depth.setlb(0.01)
ix.bed_depth.setub(100)
ix.service_flow_rate.setlb(1e-10)
ix.process_flow.properties_in[0.0].visc_k_phase["Liq"].setlb(1e-16)
ix.process_flow.properties_in[0].flow_vol_phase["Liq"].setlb(1e-16)
for c in m.fs.set_reactive_ions:
ix.process_flow.properties_in[0.0].diffus_phase_comp["Liq", c].setlb(1e-16)
ix.bv_50[c].setlb(1e-3)
ix.loading_rate.setlb(1e-16)
ix.process_flow.properties_in[0].flow_mass_phase_comp["Liq", c].setlb(1e-16)
ix.process_flow.properties_out[0].flow_mass_phase_comp["Liq", c].setlb(1e-16)
for i in range(1, ix.number_of_trapezoids + 1):
ix.conc_comp_norm_trapezoids[c, i].setlb(1e-16)
[docs]
def set_operating_conditions(m, parmest_data=None, target_component=None, resin=None):
"""Set design parameters and operating conditions for the ion exchange
column.
"""
ix = m.fs.unit_ix
pf = ix.process_flow
# Add water density
rho_m3 = 1000 * (pyo.units.kg / pyo.units.m**3)
# Calculate the mass of water based on the given data in ref[1]
L_solution = pyo.units.convert(50 * pyo.units.cm**3, to_units=pyo.units.L)
dens = m.fs.data_init["density_solution"] * (pyo.units.kg / pyo.units.m**3)
kg_solution = dens * pyo.units.convert(L_solution, to_units=pyo.units.m**3)
# Add volumetric flow, initial concentration for all REEs, and
# calculate missing parameters needed in the IX model. All values
# are obtained from ref[1].
flow_vol = m.fs.data_init["flow_in"] * (pyo.units.m**3 / pyo.units.seconds)
init_mass_comp = {}
for c in m.fs.set_reactive_ions:
# Calculate total and individual mass for REEs
init_mass_comp[c] = L_solution * pyo.units.convert(
m.fs.data_init["conc_mass"][c] * (pyo.units.mg / pyo.units.L),
to_units=pyo.units.kg / pyo.units.L,
)
sum_init_mass_comp = sum(init_mass_comp[c] for c in m.list_reactive_ions) # in kg/L
# Save the initial concentration for water in the data_init
# dictionary
m.fs.data_init["conc_mass"]["H2O"] = pyo.value(
pyo.units.convert(
(kg_solution - sum_init_mass_comp) / L_solution,
to_units=pyo.units.mg / pyo.units.L,
)
)
# Convert initial concentrations from data to kg/m3
conc_mass_init_conv = {}
for c in m.fs.set_all:
conc = m.fs.data_init["conc_mass"][c] * (pyo.units.mg / pyo.units.L)
conc_mass_init_conv[c] = pyo.units.convert(
conc, to_units=pyo.units.kg / pyo.units.m**3
)
mw = {}
flow_mol = {}
for i in m.fs.set_all:
mw[i] = m.fs.props_data["mw"][i] * (pyo.units.kg / pyo.units.mol)
if i == "H2O":
flow_mol[i] = pyo.units.convert(
(flow_vol * rho_m3) / mw[i], to_units=pyo.units.mol / pyo.units.seconds
)
else:
flow_mol[i] = pyo.units.convert(
(conc_mass_init_conv[i] * flow_vol) / mw[i], # when conc_mass in kg/m3
to_units=pyo.units.mol / pyo.units.seconds,
)
# Add inlet pressure, temperature, and flow to IX
pf.properties_in[0].pressure.fix(101325)
pf.properties_in[0].temperature.fix(298.15)
for i in m.fs.set_all:
pf.properties_in[0].flow_mol_phase_comp["Liq", i].fix(flow_mol[i])
# Add resin parameters from .json file
ix.resin_diam.fix(m.fs.resin_data[resin]["diameter"]["value"])
ix.resin_density.fix(m.fs.resin_data[resin]["density_kgm3"]["value"])
# Add column design parameters. bed_depth and bed_diameter are
# taken from ref[1].
ix.bed_depth.fix(m.fs.data_init["bed_depth"])
ix.bed_diameter.fix(m.fs.data_init["bed_diam"])
ix.bed_porosity.fix(0.8)
ix.number_columns.fix(1)
ix.number_columns_redundant.fix(1)
loading_rate0 = parmest_data["loading_rate"][target_component]
ix.loading_rate.set_value(loading_rate0)
ebct0 = (
(np.pi * (m.fs.data_init["bed_diam"] * pyo.units.m / 2) ** 2)
* m.fs.data_init["bed_depth"]
* pyo.units.m
/ flow_vol
) # in seconds
ix.ebct.set_value(ebct0)
# Equilibrium parameters
for c in m.fs.set_reactive_ions:
# Fix Freundlich parameters using the data from parameter
# estimation
ix.freundlich_n[c].fix(parmest_data["freundlich_n"][c])
ix.mass_transfer_coeff[c].fix(parmest_data["mass_transfer_coeff"][c])
ix.bv_50[c].fix(parmest_data["bv_50"][c])
ix.conc_comp_norm_breakthrough[c].fix(0.99)
return m
def get_comp_list(blk, comp=pyo.Var):
cs = []
split_name = blk.name + "."
skip_list = ["ref", "process_flow", "regeneration"]
for c in blk.component_objects(comp):
if any(s in c.name for s in skip_list):
continue
cs.append(c.name.split(split_name)[1])
return cs
[docs]
def set_scaling(m):
"""Set scaling factors for all variables in flowsheet"""
ix = m.fs.unit_ix
m.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT)
sb = ScalerBase()
for var in m.fs.component_data_objects(pyo.Var, descend_into=True):
if "temperature" in var.name:
sb.set_variable_scaling_factor(var, 1e-1, overwrite=True)
if "pressure" in var.name:
sb.set_variable_scaling_factor(var, 1e-5)
if "flow_vol" in var.name:
sb.set_variable_scaling_factor(var, 1e8)
if "flow_mol_phase_comp" in var.name:
if "H2O" in var.name:
sb.set_variable_scaling_factor(var, 1e4)
else:
sb.set_variable_scaling_factor(var, 1e8)
for v in get_comp_list(ix):
ixv = getattr(ix, v)
if ixv.is_indexed():
idx = [*ixv.index_set()]
for i in idx:
if ixv[i].is_fixed():
if ixv[i]() == 0:
continue
sb.set_variable_scaling_factor(ixv[i], 1 / ixv[i]())
else:
if ixv.is_fixed():
if ixv() == 0:
continue
sb.set_variable_scaling_factor(ixv, 1 / ixv())
for v in m.fs.scale_from_value:
ixv = getattr(ix, v)
if ixv.is_indexed():
idx = [*ixv.index_set()]
for i in idx:
if ixv[i]() == 0:
continue
sb.set_variable_scaling_factor(ixv[i], 1 / ixv[i]())
else:
if ixv() == 0:
continue
sb.set_variable_scaling_factor(ixv, 1 / ixv())
return m
def set_scaling_regeneration(m):
ix = m.fs.unit_ix
ix.scaling_factor[ix.regeneration_stream[0].pressure] = 1e-3
ix.scaling_factor[ix.loading_rate] = 1e3
ix.scaling_factor[ix.service_flow_rate] = 1e-3
ix.scaling_factor[ix.bed_volume] = 1e3
for c in m.fs.set_reactive_ions:
ix.scaling_factor[ix.regeneration_stream[0].flow_mol_phase_comp["Liq", c]] = 1e1
ix.scaling_factor[ix.breakthrough_time[c]] = 1e-3
for k in range(1, ix.number_of_trapezoids + 1):
ix.scaling_factor[ix.conc_comp_norm_trapezoids[c, k]] = 1e3
ix.scaling_factor[ix.breakthrough_time_trapezoids[c, k]] = 1e-3
ix.scaling_factor[ix.costing.capital_cost_backwash_tank_constraint] = 1
for c in m.fs.set_reactive_ions:
ix.scaling_factor[ix.eq_mass_transfer_regen[c]] = 1e3
ix.scaling_factor[ix.eq_conc_comp_norm_breakthrough_trapezoids[c]] = 1
def initialize_system(
m,
solver=None,
**kwargs,
):
ix = m.fs.unit_ix
ix.initialize()
# Check and raise an error if the degrees of freedom are not 0
if degrees_of_freedom(m) != 0:
raise ConfigurationError(
"The degrees of freedom after building the model are not 0. "
"You have {} degrees of freedom. "
"Please check your inputs to ensure a square problem "
"before initializing the model.".format(degrees_of_freedom(m))
)
def add_costing(m):
flow_out = m.fs.unit_ix.process_flow.properties_out[0].flow_vol_phase["Liq"]
m.fs.costing = IXCosting()
m.fs.costing.base_currency = pyo.units.USD_2021
m.fs.costing.base_period = pyo.units.year
# Add costs related only to IX unit
m.fs.unit_ix.costing = UnitModelCostingBlock(
flowsheet_costing_block=m.fs.costing,
costing_method=IXCostingData.cost_ion_exchange,
)
# Calculate costs of entire process
m.fs.costing.cost_process()
m.fs.costing.utilization_factor.fix(1)
m.fs.costing.aggregate_fixed_operating_cost()
m.fs.costing.aggregate_variable_operating_cost()
m.fs.costing.aggregate_capital_cost()
m.fs.costing.total_capital_cost()
m.fs.costing.total_operating_cost()
# Set initial values
m.fs.costing.aggregate_capital_cost.set_value(1e3)
m.fs.costing.aggregate_fixed_operating_cost.set_value(1e1)
m.fs.costing.aggregate_variable_operating_cost.set_value(1e-3)
# Touch relevant cost variable
m.fs.costing.total_annualized_cost
# Add costs for REEs. References are: [a]
# https://www.metal.com/Rare-Earth-Metals/ and [b]
# https://www.metal.com/price/Rare%20Earth/Rare-Earth-Oxides.
market_prices = {
# From ref[a]
"La": 2.642,
"Dy": 247.07,
"Ho": 63.610,
# From [b]
"Er": 39.641,
"Yb": 12.291,
"Sm": 8.973,
}
m.fs.market_price = pyo.Param(
m.fs.set_reactive_ions,
initialize=market_prices,
units=pyo.units.USD_2021 / pyo.units.kg,
doc="Market price for REEs",
)
m.fs.operational_daily_hours = pyo.Param(
initialize=8, units=pyo.units.hours / pyo.units.day, doc="IX operational hours"
)
m.fs.operational_yearly_days = pyo.Param(
initialize=365, units=pyo.units.day / pyo.units.year, doc="IX operational hours"
)
m.fs.ix_lifetime = pyo.Param(
initialize=15, units=pyo.units.years, doc="IX lifetime"
)
@m.fs.unit_ix.Expression()
def expected_annual_profit_ree(b):
return (
sum(
m.fs.market_price[s]
* pyo.units.convert(
(
b.process_flow.properties_in[0.0].flow_mass_phase_comp["Liq", s]
- b.process_flow.properties_out[0.0].flow_mass_phase_comp[
"Liq", s
]
),
to_units=pyo.units.kg / pyo.units.hour,
)
for s in m.fs.set_reactive_ions
)
* m.fs.operational_daily_hours
* m.fs.operational_yearly_days
)
m.fs.unit_ix.target_breakthrough_time.setlb(1e-3)
m.fs.unit_ix.target_breakthrough_time.setub(1e10)
[docs]
def run_optimization(m, target_component=None):
"""This method unfixes variables and add constraints to solve an
optimization problem
"""
ix = m.fs.unit_ix
for c in m.fs.set_reactive_ions:
ix.conc_comp_norm_breakthrough[c].unfix()
ix.bed_depth.unfix()
ix.bed_diameter.unfix()
# For this example, we optimize the model to have an effluent with
# a very small concentration of the multiple REEs (conc_comp_norm_breakthrough closer
# to 1 or final concentration closer to initial concentration)
@m.fs.unit_ix.Constraint(m.fs.set_reactive_ions)
def components_specifications(b, c):
if c == target_component:
return b.conc_comp_norm_breakthrough[c] == 0.999
else:
return b.conc_comp_norm_breakthrough[c] >= 0.9
@m.fs.unit_ix.costing.Expression()
def annualized_capital_cost(b):
return b.capital_cost / m.fs.ix_lifetime
m.fs.obj = pyo.Objective(
expr=(
ix.costing.annualized_capital_cost
+ ix.costing.fixed_operating_cost
- ix.expected_annual_profit_ree
)
)
def plot_traps(m, results=None, curve_data=None, output_plot=None, save_plot=None):
distinct_colors = [
"#1f77b4", # Blue
"#ff7f0e", # Orange
"#2ca02c", # Green
"#d62728", # Red
"#9467bd", # Purple
"#8c564b", # Brown
]
# Define a list of distinct markers
distinct_markers = [
"o", # Circle
"s", # Square
"D", # Diamond
"^", # Triangle
"v", # Inverted Triangle
"<", # Left Triangle
]
ix = m.fs.unit_ix
color = ["b", "k"]
num_traps = ix.number_of_trapezoids
plt.figure(figsize=(14, 12))
plt.minorticks_on()
plt.grid(linestyle=":", which="both", color="gray", alpha=0.50)
for idx, c in enumerate(m.fs.set_reactive_ions):
comp = curve_data["compound"]
x_orig_values = curve_data[comp == c]["bv"].values
y_orig_values = curve_data[comp == c]["c_norm"].values
plt.plot(
x_orig_values,
y_orig_values,
marker=distinct_markers[idx % len(distinct_markers)],
ms=8,
linestyle="",
linewidth=0.5,
color=distinct_colors[idx % len(distinct_colors)],
alpha=0.8,
label=f"Literature {c}",
)
for idx, c in enumerate(m.fs.set_reactive_ions):
bvs_values = []
c_breakthru_values = []
for i in range(1, num_traps + 1):
bvs_values.append(pyo.value(ix.bv_trapezoids[c, i]))
c_breakthru_values.append(pyo.value(ix.conc_comp_norm_trapezoids[c, i]))
plt.plot(
bvs_values,
c_breakthru_values,
linestyle="-",
linewidth=1,
color=distinct_colors[idx % len(distinct_colors)],
alpha=0.6,
label=f"Calculated {c}",
)
plt.xlabel("Bed Volume (BV)", fontsize=16)
plt.ylabel("C/C0", fontsize=16)
plt.xticks(np.arange(0, 241, 20), fontsize=14)
plt.yticks(np.arange(0, 1.1, 0.2), fontsize=14)
plt.legend(
frameon=False,
loc="upper left",
bbox_to_anchor=(0.80, 0.75),
ncol=1,
fontsize=12,
)
if save_plot:
plt.savefig("breakthru_trapezoidal_all_components.png", bbox_inches="tight")
plt.close()
def model_solve(m, solver=None):
opt_args = {
"print_user_options": "yes",
"halt_on_ampl_error": "yes",
}
solver = get_solver(solver="ipopt_v2", solver_options=opt_args)
results = solver.solve(
m,
tee=True,
symbolic_solver_labels=True,
)
return results
if __name__ == "__main__":
m = main()