Source code for prommis.cmi_precipitator.opt_based_precipitator

#####################################################################################################
# “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.
#####################################################################################################
r"""
Optimization-based Precipitator for the Critical Materials Innovation Hub (CMI) Process
=======================================================================================

Author: Chris Laliwala

This precipitator makes use of equilibrium constants and solubility constants to predict the final concentrations of aqueous species,
and the amount of precipitates formed at equilibrium.

Configuration Arguments
-----------------------

The model requires the user to define the aqueous species and precipitates present in the system. Additionally,
equilibrium constants for aqueous reactions, and solubility constants for precipitation/dissolution reactions are needed.
Reaction stoichiometry must also be provided by the user. The aqueous components, equilibrium constants, and a dictionary
defining the aqueous reaction stoichiometry is stored in the aqeuous property package. The precipitates, solubility constants,
and a dictionary defining the precipitation/dissolution reaction stoichiometry is stored in the precipitate property package.

Model Structure
---------------

This unit model has an inlet for aqueous ('aqueous_inlet') and precipitates ('precipitate_inlet') entering the unit, and an outlet for
aqueous ('aqueous_outlet') and precipitates ('precipitate_outlet') leaving the unit.


Additional Model Information
----------------------------

This precipitator model seeks to model aqueous systems involving precipitation and dissolution reactions as chemical equilibrium problems.
The approach taken here is to solve a system of nonlinear equations involving equilibrium constants (the law of mass action approach, LMA) [1].
Instead of utilizing saturation indices heuristics commonly used by LMA software [1], this model formulates an optimization problem where the
objective function is to minimize the square difference between the ion product, :math:`Q_{r,sp}`, defined over the actual concentration in solution,
and the solubility constant, :math:`K_{r,sp}`, defined over the equilibrium concentration in solution,

.. math:: z = \sum_{r \in N_{rxn,sp}} ( log(K_{r,sp}) - log(Q_{r,sp}) )^2 \quad (1)

where :math:`N_{rxn,sp}` is the set of precipitation/dissolution reactions. By adding in the following constraints, this objective function allows the
identification of the species that should precipitate (i.e. :math:`Q_{r,sp} = K_{r,sp}`) and those that should not (i.e. :math:`Q_{r,sp} \leq K_{r,sp}`):

.. math:: log(Q_{r,sp}) = \sum_{i \in I_{r, products}} \alpha_{i,r} log(C_i^f) \forall r \in N_{rxn,sp} \quad (2)

.. math:: log(Q_{r,sp}) \leq log(K_{r,sp}) \forall r \in N_{rxn,sp} \quad (3)

where :math:`I_{r,products}` is the set of products formed in reaction :math:`r`. Eq. (2) computes the ionic product based on the actual concentrations in
the solution; its upper bound is defined by the solubility product (Eq. 3).

Equilibrium conditions are imposed in all reactions that do not involve solids' formation as shown in Eq. (4):

.. math:: log(K_{r,aq}) = \sum_{i \in I_{r,products}} \alpha_{i,r} log(C_i^f) - \sum_{i \in I_{r, reactants}} \alpha_{i,r} log(C_i^f) \forall r \in N_{rxn,aq} \quad (4)

where :math:`\alpha_{i,r}` is the stoichiometric coefficient of species :math:`i` in reaction :math:`r`, :math:`C_i^f` is the equilibrium concentration of
species :math:`i`, and :math:`N_{rxn,aq}` is the set of all aqueous reactions. The logartihmic form(s) in Eq. (2-4) to minimize numerical issues.

The set of restrictions is completed with the inclusion of mass and concentration balances to calculate the final concentration/mass of species:

.. math:: C_i^f = C_i^0 + \sum_{r \in N_{rxn,i}} \alpha_{i,r} X_r \forall i \in I_{aq} \quad (5)

.. math:: m_i^f = m_i^0 + \sum_{r \in N_{rxn,i}} \alpha_{i,r} X_r V \forall i \in I_{sp} \quad (6)

where :math:`C_i^0` is the initial concentration of species :math:`i`, :math:`N_{rxn,i}` is the set of all reactions involving species :math:`i`, :math:`X_r`
is the extent of reaction :math:`r`, :math:`I_{aq}` is the set of all aqueous species, :math:`m_i^0` is the initial amount of solid species :math:`i`,
:math:`V` is the volumetric flowrate of solvent, and :math:`I_{sp}` is the set of all precipitates.

[1] Allan M.M. Leal, Dmitrii A. Kulik, William R. Smith, and Martin O. Saar. An overview of computational methods for chemical equilibrium and kinetic calculations for
geochemical and reactive transport modeling. *Pure Appl. Chem.*, 89:597-643, 2017.
"""

# Import Pyomo libraries
import pyomo.environ as pyo
from pyomo.common.config import Bool, ConfigBlock, ConfigValue
from pyomo.environ import units as pyunits

# Import IDAES cores
from idaes.core import (
    ControlVolume0DBlock,
    UnitModelBlockData,
    declare_process_block_class,
    useDefault,
)
from idaes.core.util.config import is_physical_parameter_block
from idaes.core.util.tables import create_stream_table_dataframe


[docs] @declare_process_block_class("Precipitator") class PrecipitatorData(UnitModelBlockData): """ Precipitator Unit Model Class """ CONFIG = UnitModelBlockData.CONFIG() CONFIG.declare( "property_package_aqueous", ConfigValue( default=useDefault, domain=is_physical_parameter_block, description="Property package to use for aqueous control volume", doc="""Property parameter object used to define property calculations, **default** - useDefault. "Valid values:** { **useDefault** - use default package from parent model or flowsheet, **PropertyParameterObject** - a PropertyParameterBlock object.}""", ), ) CONFIG.declare( "property_package_args_aqueous", ConfigBlock( implicit=True, description="Arguments to use for constructing aqueous property packages", doc="""A ConfigBlock with arguments to be passed to a property block(s) and used when constructing these, **default** - None. **Valid values:** { see property package for documentation.}""", ), ) CONFIG.declare( "property_package_precipitate", ConfigValue( default=useDefault, domain=is_physical_parameter_block, description="Property package to use for precipitate control volume", doc="""Property parameter object used to define property calculations, **default** - useDefault. **Valid values:** { **useDefault** - use default package from parent model or flowsheet, **PropertyParameterObject** - a PropertyParameterBlock object.}""", ), ) CONFIG.declare( "property_package_args_precipitate", ConfigBlock( implicit=True, description="Arguments to use for constructing precipitate property packages", doc="""A ConfigBlock with arguments to be passed to a property block(s) and used when constructing these, **default** - None. **Valid values:** { see property package for documentation.}""", ), ) CONFIG.declare( "has_equilibrium_reactions", ConfigValue( default=True, domain=Bool, description="Equilibrium reaction construction flag", doc="""Indicates whether terms for equilibrium controlled reactions should be constructed, **default** - True. **Valid values:** { **True** - include equilibrium reaction terms, **False** - exclude equilibrium reaction terms.}""", ), ) CONFIG.declare( "has_phase_equilibrium", ConfigValue( default=False, domain=Bool, description="Phase equilibrium construction flag", doc="""Indicates whether terms for phase equilibrium should be constructed, **default** = False. **Valid values:** { **True** - include phase equilibrium terms **False** - exclude phase equilibrium terms.}""", ), ) CONFIG.declare( "has_heat_of_reaction", ConfigValue( default=False, domain=Bool, description="Heat of reaction term construction flag", doc="""Indicates whether terms for heat of reaction terms should be constructed, **default** - False. **Valid values:** { **True** - include heat of reaction terms, **False** - exclude heat of reaction terms.}""", ), )
[docs] def build(self): """Building model Args: None Return: None """ # Call UnitModel.build to setup dynamics super(PrecipitatorData, self).build() # Add Control Volumes self.cv_aqueous = ControlVolume0DBlock( dynamic=False, has_holdup=False, property_package=self.config.property_package_aqueous, property_package_args=self.config.property_package_args_aqueous, ) self.cv_precipitate = ControlVolume0DBlock( dynamic=False, has_holdup=False, property_package=self.config.property_package_precipitate, property_package_args=self.config.property_package_args_precipitate, ) # Add inlet and outlet state blocks to control volume self.cv_aqueous.add_state_blocks(has_phase_equilibrium=False) self.cv_precipitate.add_state_blocks(has_phase_equilibrium=False) # add ports self.add_inlet_port(block=self.cv_aqueous, name="aqueous_inlet") self.add_outlet_port(block=self.cv_aqueous, name="aqueous_outlet") self.add_inlet_port(block=self.cv_precipitate, name="precipitate_inlet") self.add_outlet_port(block=self.cv_precipitate, name="precipitate_outlet") prop_aqueous = self.config.property_package_aqueous prop_precipitate = self.config.property_package_precipitate # Params # create a set containing all reaction logkeq values self.merged_logkeq_dict = ( prop_aqueous.logkeq_dict | prop_precipitate.logkeq_dict ) # create a set containing all reactions self.merged_rxns = self.merged_logkeq_dict.keys() # make a param of all the logkeq values (precipitation and aqueous equilibrium) self.log_k = pyo.Param( self.merged_logkeq_dict.keys(), initialize=lambda m, key: self.merged_logkeq_dict[key], within=pyo.Reals, ) # variables self.rxn_extent = pyo.Var( self.merged_rxns, initialize=1, doc="Extent of the reaction.", units=pyunits.mol / pyunits.kg, ) # log(q) for precipitation reactions self.log_q = pyo.Var( prop_precipitate.rxn_set, initialize=1, doc="log(q) var for each reaction", units=pyunits.dimensionless, ) self.m_ref = pyo.Param( initialize=1, doc="Reference molality of 1 to make log(molalities) dimensionless", units=pyunits.mol / pyunits.kg, ) # constraints @self.Constraint( self.flowsheet().time, prop_aqueous.rxn_set, doc="equilibrium reactions (log form) constraints (non-precipitate forming)", ) def log_k_equilibrium_rxn_eqns(blk, t, r): return blk.log_k[r] == sum( prop_aqueous.stoich_dict[r][c] * pyo.log10( blk.cv_aqueous.properties_out[t].molality_aqueous_comp[c] / self.m_ref ) for c in prop_aqueous.stoich_dict[r] ) @self.Constraint( self.flowsheet().time, prop_precipitate.rxn_set, doc="equilibrium reactions (log form) constraints (precipitate forming)", ) def log_q_precipitate_equilibrium_rxn_eqns(blk, t, r): return blk.log_q[r] == sum( prop_aqueous.stoich_dict[r][c] * pyo.log10( blk.cv_aqueous.properties_out[t].molality_aqueous_comp[c] / self.m_ref ) for c in prop_aqueous.stoich_dict[r] ) # log(q) must be <= log(k) for precipitating reactions @self.Constraint( prop_precipitate.rxn_set, doc="log(q) must be less than or equal to log(k) for precipitating reactions.", ) def precip_rxns_log_cons(blk, r): return blk.log_q[r] <= self.log_k[r] # mole balance on all aqueous components @self.Constraint( self.flowsheet().time, prop_aqueous.component_list, doc="Aqueous Components Mole balance equations.", ) def aqueous_mole_balance_eqns(blk, t, comp): return blk.cv_aqueous.properties_out[t].molality_aqueous_comp[ comp ] == blk.cv_aqueous.properties_in[t].molality_aqueous_comp[comp] + sum( prop_aqueous.stoich_dict[r][comp] * blk.rxn_extent[r] for r in self.merged_rxns ) # balance on volume coming in and out @self.Constraint(self.flowsheet().time, doc="volume balance equation.") def vol_balance(blk, t): return ( blk.cv_aqueous.properties_out[t].flow_vol == blk.cv_aqueous.properties_in[t].flow_vol ) # mole balance on all precipitate components @self.Constraint( self.flowsheet().time, prop_precipitate.component_list, doc="Precipitate Components Mole balance equations.", ) def precipitate_mole_balance_eqns(blk, t, comp): return blk.cv_precipitate.properties_out[t].moles_precipitate_comp[ comp ] == blk.cv_precipitate.properties_in[t].moles_precipitate_comp[comp] + sum( prop_precipitate.stoich_dict[r][comp] * blk.rxn_extent[r] * blk.cv_aqueous.properties_out[t].flow_vol for r in prop_precipitate.rxn_set ) # objective function to minimize difference between log(k) and log(q) @self.Objective( doc="objective function minimize difference between log(k) and log(q)" ) def min_logs(blk, r): return sum( (self.log_k[r] - blk.log_q[r]) ** 2 for r in prop_precipitate.rxn_set )
def _get_stream_table_contents(self, time_point=0): return create_stream_table_dataframe( { "Aqueous Inlet": self.aqueous_inlet, "Aqueous Outlet": self.aqueous_outlet, "Precipitate Inlet": self.precipitate_inlet, "Precipitate Outlet": self.precipitate_outlet, }, time_point=time_point, ) def _get_performance_contents(self, time_point=0): var_dict = {} for r in self.merged_rxns: name = f"Reaction {r} extent" rxn_extent = self.rxn_extent[r] var_dict[name] = rxn_extent return {"vars": var_dict}