#####################################################################################################
# “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"""
Solar Evaporation Pond
======================
Author: Andrew Lee
The Evaporation Pond model is a general purpose model for representing evaporation ponds
(both solar and enhanced). Currently, the model only supports steady-state operation
(although it could be easily extended to dynamics). The model also only supports
material balances with equilibrium reactions, as energy and momentum balances are less
meaningful given the open-air nature of these operations and the long-duration time
constants involved.
Configuration Arguments
-----------------------
When creating an instance of an Evaporation Pond model, the user may specify the name of the solvent phase
used by the associated property package, which is used to determine which component requires an evaporation
term in the material balances. The default name is 'H2O', and only a single solvent is supported.
Evaporation Pond models also require a reaction package to define the precipitation reactions which occur in the
system. However, unlike normal reaction packages, the equilibrium constraints are written by the unit model as
it is necessary to include a conditional check for sub-saturated reactions. Thus, the reaction package should not
create any equilibrium constraints, and need only meet the following conditions:
Reaction Parameter Block:
* Define the set of equilibrium reaction names (``equilibrium_reaction_idx``), and
* define the stoichiometry coefficients for the equilibrium reactions (``equilibrium_reaction_stoichiometry``).
Reaction Block:
* Define the solubility products for each reaction. Each reaction requires a separate Var or Param
named ``solubility_product_X`` where ``X`` is the identifier for each reaction in ``equilibrium_reaction_idx``.
Separate components are required as the units of measurement for each reaction may be different.
* All reactions are assumed to use a standard power law form.
Degrees of Freedom
------------------
Evaporation Pond models have three degrees of freedom in addition to the feed stream state. The most common
variables to specify are:
* the surface area of the evaporation pond, ``surface_area``,
* the average depth of liquid in the pond, ``average_pond_depth``, and
* the rate of evaporation in units of length/time (or volume evaporated per unit area per time), ``evaporation_rate``.
Model Structure
---------------
The Evaporation Pond unit model does not use Control Volumes, and writes custom material balances and
reaction constraints. The Evaporation Pond model has one inlet and one outlet ``Port`` (named
``inlet`` and ``outlet`` respectively).
Variables
---------
Evaporation Pond models have the following Variables.
================= =================== =====================
Variable Name Indexing Set(s)
================= =================== =====================
:math:`A_{t}` surface_area time
:math:`D_{t}` average_pond_depth time
:math:`V_{t}` volume time
:math:`e_{t}` evaporation_rate time
:math:`W_{t}` water_loss_rate time
:math:`P_{t, j}` precipitation_rate time, component list
:math:`X_{t, r}` reaction_extent time, reaction list
================= =================== =====================
Params
------
Evaporation Pond models have the following Params, which are used in the complementarity constraint
for precipitation.
================== ======== ============== =================================================================
Parameter Name Indexing Set Notes
================== ======== ============== =================================================================
:math:`\epsilon` eps Smoothing parameter for smooth_max
:math:`norm_{r}` s_norm reaction list Normalizing factor, should match magnitude of solubility product
:math:`scale_{r}` s_scale reaction list Scaling factor for reaction product, Q = Ksp - f(C)
================== ======== ============== =================================================================
Constraints
-----------
Evaporation Pond models have the following constraints.
Component balances:
For the solvent:
.. math:: 0 = F_{in,t,j} - F_{out,t,j} + P_{t_j} + W_{t}
For other species:
.. math:: 0 = F_{in,t,j} - F_{out,t,j} + P_{t_j}
where :math:`F_{in,t,j}` and :math:`F_{out,t,j}` are the flow rate of component :math:`j` in to and out of
the pond at time :math:`t`.
Stoichiometry Constraint:
.. math:: P_{t, j} = \sum_r{n_{r,j} \times X_{t,r}}
where :math:`n_{r,j}` is the stoichiometric coefficient for component :math:`j` in reaction :math:`r`.
Equilibrium Constraint:
.. math:: Q_{t, r} - \max{0, Q_{t,r}-\bar{X}_{t,r}} = 0
where :math:`Q_{t,r} = \ln{K_{t,r}} - \sum_j{-n_{r,j} \times \ln{C_{t,j}}}` and :math:`K_{t,r}` is the
solubility product for reaction :math:`r` at time :math:`t`, :math:`C_{t,j}` is the concentration
of species :math:`j` at time :math:`t`, and :math:`\bar{X}_{t,r} = scale_{r} \times \frac{X_{t,r}}{X_{t,r} + norm_{r}}`
The math:`max` operator is approximated using a smooth maximum,
Evaporation Constraint:
.. math:: W_{t} = e_{t} \times A_{t}
Volume Constraint:
.. math:: V_{t} = D_{t} \times A_{t}
"""
from pyomo.common.config import ConfigDict, ConfigValue, In
from pyomo.environ import Block, Param, Var, log, units
import idaes.logger as idaeslog
from idaes.core import (
MaterialFlowBasis,
UnitModelBlockData,
declare_process_block_class,
useDefault,
)
from idaes.core.initialization.initializer_base import ModularInitializerBase
from idaes.core.util import StoreSpec, from_json, to_json
from idaes.core.util.config import (
is_physical_parameter_block,
is_reaction_parameter_block,
)
from idaes.core.util.exceptions import ConfigurationError, PropertyPackageError
from idaes.core.util.math import smooth_max
__author__ = "Andrew Lee"
[docs]
class EvaporationPondInitializer(ModularInitializerBase):
"""
Initializer object for EvaporationPond unit models.
This Initializer initializes the inlet StateBlock, maps the solution of
this onto the outlet StateBlock, and then solves the full model.
"""
CONFIG = ModularInitializerBase.CONFIG()
[docs]
def initialization_routine(
self,
model: Block,
plugin_initializer_args: dict = None,
):
"""
Common initialization routine for EvaporationPond unit models.
Args:
model: Pyomo Block to be initialized
plugin_initializer_args: dict-of-dicts containing arguments to be passed to plug-in Initializers.
Keys should be submodel components.
Returns:
Pyomo solver results object
"""
# The default initialization_routine is sufficient
return super().initialization_routine(
model=model,
plugin_initializer_args=plugin_initializer_args,
)
[docs]
def initialize_main_model(
self,
model: Block,
):
"""
Initialization routine for EvaporationPond unit models.
Args:
model: current model being initialized
Returns:
Pyomo solver results object from solve of main model
"""
# Get logger
_log = self.get_logger(model)
# Initialize inlet properties - inlet state should already be fixed
prop_init = self.get_submodel_initializer(model.properties_in)
if prop_init is not None:
prop_init.initialize(
model=model.properties_in,
output_level=self.get_output_level(),
)
_log.info_high("Inlet properties initialization complete.")
# Map solution from inlet properties to outlet properties
state = to_json(
model.properties_in,
wts=StoreSpec().value(),
return_dict=True,
)
from_json(
model.properties_out,
sd=state,
wts=StoreSpec().value(only_not_fixed=True),
)
# Solve main model
solve_log = idaeslog.getSolveLogger(
model.name, self.get_output_level(), tag="unit"
)
with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc:
results = self._get_solver().solve(model, tee=slc.tee)
_log.info_high(
f"Evaporation Pond initialization {idaeslog.condition(results)}."
)
return results
[docs]
@declare_process_block_class("EvaporationPond")
class EvaporationPondData(UnitModelBlockData):
"""
Evaporation Pond Unit Model Class
"""
default_initializer = EvaporationPondInitializer
CONFIG = ConfigDict()
CONFIG.declare(
"dynamic",
ConfigValue(
domain=In([False]),
default=False,
description="Dynamic model flag - must be False",
doc="""Indicates whether this model will be dynamic or not,
**default** = False. Equilibrium Reactors do not support dynamic behavior.""",
),
)
CONFIG.declare(
"has_holdup",
ConfigValue(
default=False,
domain=In([False]),
description="Holdup construction flag - must be False",
doc="""Indicates whether holdup terms should be constructed or not.
**default** - False. Equilibrium reactors do not have defined volume, thus
this must be False.""",
),
)
CONFIG.declare(
"property_package",
ConfigValue(
default=useDefault,
domain=is_physical_parameter_block,
description="Property package to use",
doc="""Property parameter object used to define property calculations,
**default** - useDefault.
**Valid values:** {
**useDefault** - use default package from parent model or flowsheet,
**PhysicalParameterObject** - a PhysicalParameterBlock object.}""",
),
)
CONFIG.declare(
"property_package_args",
ConfigDict(
implicit=True,
description="Dict of arguments to use for constructing property blocks",
doc="""A ConfigDict with arguments to be passed to property block(s)
and used when constructing these,
**default** - None.
**Valid values:** {
see property package for documentation.}""",
),
)
CONFIG.declare(
"reaction_package",
ConfigValue(
domain=is_reaction_parameter_block,
description="Reaction package to use",
doc="""Reaction parameter object used to define precipitation reactions,
**default** - useDefault.
**Valid values:** {
**useDefault** - use default package from parent model or flowsheet,
**ReactionParameterObject** - a ReactionParameterBlock object.}""",
),
)
CONFIG.declare(
"reaction_package_args",
ConfigDict(
implicit=True,
description="Dict of arguments to use for constructing reaction blocks",
doc="""A ConfigDict with arguments to be passed to reaction block(s)
and used when constructing these,
**default** - None.
**Valid values:** {
see reaction package for documentation.}""",
),
)
CONFIG.declare(
"solvent_id",
ConfigValue(
default="H2O",
domain=str,
description="Name of solvent component (default='H2O').",
),
)
[docs]
def build(self):
"""
Build method for EvaporationPond unit model.
"""
super().build()
# Verify solvent ID
if self.config.solvent_id not in self.config.property_package.component_list:
raise ConfigurationError(
f"{self.name} - {self.config.solvent_id} was set as the solvent_id for "
f"EvaporationPond model, however this is not a valid component name "
f"in the property package provided."
)
# Verify single phase
if len(self.config.property_package.phase_list) > 1:
raise ConfigurationError(
f"{self.name} - EvaporationPond model only supports a single phase. However, "
f"the property package provided includes "
f"{len(self.config.property_package.phase_list)} phases."
)
phase_name = self.config.property_package.phase_list.at(1)
# Build property blocks
self.properties_in = self.config.property_package.build_state_block(
self.flowsheet().time,
defined_state=True,
**self.config.property_package_args,
)
self.properties_out = self.config.property_package.build_state_block(
self.flowsheet().time,
defined_state=False,
**self.config.property_package_args,
)
self.reactions = self.config.reaction_package.build_reaction_block(
self.flowsheet().time,
state_block=self.properties_out,
**self.config.reaction_package_args,
)
# Construct Ports
self.add_port("inlet", self.properties_in)
self.add_port("outlet", self.properties_out)
# Get indexing sets
time = self.flowsheet().time
comp_set = self.config.property_package.component_list
pc_set = self.config.property_package.get_phase_component_set()
rxn_basis = self.reactions[time.first()].get_reaction_rate_basis()
# Get units of measurement
flow_basis = self.properties_in[
self.flowsheet().time.first()
].get_material_flow_basis()
uom = self.config.property_package.get_metadata().derived_units
if flow_basis is MaterialFlowBasis.molar:
mb_units = uom.FLOW_MOLE
elif flow_basis is MaterialFlowBasis.mass:
mb_units = uom.FLOW_MASS
else:
raise PropertyPackageError(
f"{self.name} - Property package uses a flow basis of 'other'. "
"EvaporationPond model only supports mass or molar bases."
)
if rxn_basis is MaterialFlowBasis.molar:
rxn_units = uom.FLOW_MOLE
elif rxn_basis is MaterialFlowBasis.mass:
rxn_units = uom.FLOW_MASS
else:
raise PropertyPackageError(
f"{self.name} - Reaction package uses a reaction basis other than mass or "
"mole. EvaporationPond model only supports mass or molar bases."
)
# Build unit model level variables
self.surface_area = Var(
time,
initialize=1000,
bounds=(1, None),
units=uom.AREA,
doc="Surface area of evaporation pond",
)
self.average_pond_depth = Var(
time,
initialize=1,
bounds=(1e-6, None),
units=uom.LENGTH,
doc="Average depth of evaporation pond",
)
self.volume = Var(
time,
initialize=1000,
bounds=(1e-6, None),
units=uom.VOLUME,
doc="Volume of liquid in pond",
)
self.evaporation_rate = Var(
time,
initialize=0,
bounds=(0, None), # TODO: Need to relax this for dynamics
units=units.mm / units.day,
doc="Evaporation rate of water",
)
self.water_loss_rate = Var(
time,
initialize=0,
bounds=(None, 0), # TODO: Need to relax this for dynamics
units=mb_units,
doc="Flowrate of water lost to evaporation",
)
self.precipitation_rate = Var(
time,
comp_set,
initialize=0,
bounds=(None, 0), # TODO: Need to relax this for dynamics
units=mb_units,
doc="Rate of precipitation of species",
)
# Add unit level constraints
@self.Constraint(
time,
doc="Evaporation constraint",
)
def evaporation_constraint(b, t):
rhs = -b.evaporation_rate[t] * b.surface_area[t]
if flow_basis is MaterialFlowBasis.molar:
rhs = rhs * 1000 * units.kg / units.m**3 / (18 * units.g / units.mol)
elif flow_basis is MaterialFlowBasis.mass:
rhs = rhs * 1000 * units.kg / units.m**3
# No action if flow basis other
return b.water_loss_rate[t] == units.convert(
rhs,
to_units=mb_units,
)
# Add constraint relating area to volume
@self.Constraint(time, doc="Volume constraint")
def volume_constraint(b, t):
return b.volume[t] == b.surface_area[t] * b.average_pond_depth[t]
# Material balances
# TODO: For dynamics, need to include tracking of solids
@self.Constraint(time, pc_set, doc="Component balances")
def component_balances(b, t, p, j):
rhs = (
b.properties_in[t].get_material_flow_terms(p, j)
- b.properties_out[t].get_material_flow_terms(p, j)
+ b.precipitation_rate[t, j]
)
if j == self.config.solvent_id:
rhs += b.water_loss_rate[t]
# TODO: Only support steady-state for now
lhs = 0
return lhs == rhs
# Equilibrium reactions and precipitation rate
# Reaction parameters
rxn_idx = self.config.reaction_package.equilibrium_reaction_idx
rxn_stoic = self.config.reaction_package.equilibrium_reaction_stoichiometry
self.reaction_extent = Var(
time,
rxn_idx,
initialize=0,
bounds=(0, None), # TODO: Need to relax this for dynamics
units=rxn_units,
doc="Extent of equilibrium reaction",
)
self.eps = Param(
mutable=True,
initialize=1e-4,
units=units.dimensionless,
doc="Smoothing parameter for smooth maximum",
)
self.s_norm = Param(
rxn_idx,
mutable=True,
initialize=1,
units=rxn_units,
doc="Normalizing factor for solid precipitation term. Should match magnitude of Ksp",
)
self.s_scale = Param(
rxn_idx,
mutable=True,
initialize=1,
units=units.dimensionless,
doc="Scaling factor for solid precipitation term w.r.t saturated status Q = Ksp - f(C)",
)
@self.Constraint(time, pc_set, doc="Stoichiometry constraints")
def stoichiometry_constraint(b, t, p, j):
# Catch inerts and avoid sum expression
if all(rxn_stoic[r, p, j] == 0 for r in rxn_idx):
return b.precipitation_rate[t, j] == 0 * mb_units
exp = sum(
rxn_stoic[r, p, j] * b.reaction_extent[t, r]
for r in rxn_idx
if rxn_stoic[r, p, j] != 0
)
# Convert units if required
if (
flow_basis is MaterialFlowBasis.mass
and rxn_basis is MaterialFlowBasis.molar
):
exp = units.convert(exp * b.properties_out[t].mw[j], to_units=mb_units)
elif (
flow_basis is MaterialFlowBasis.molar
and rxn_basis is MaterialFlowBasis.mass
):
exp = units.convert(exp / b.properties_out[t].mw[j], to_units=mb_units)
return b.precipitation_rate[t, j] == exp
@self.Constraint(time, rxn_idx, doc="Equilibrium constraint")
def equilibrium_constraint(b, t, r):
Ksp = getattr(b.reactions[t], "solubility_product_" + r)
k_units = Ksp.get_units()
if rxn_basis == MaterialFlowBasis.molar:
conc = b.properties_out[t].conc_mole_comp
else:
conc = b.properties_out[t].conc_mass_comp
# Equilibrium expression
# Assume equilibrium reactions will always be defined on a molar basis
e = sum(
-rxn_stoic[r, phase_name, j] * log(conc[j] / units.get_units(conc[j]))
for j in comp_set
if rxn_stoic[r, phase_name, j] != 0.0
)
# Complementarity formulation to support conditional precipitation
k = log(Ksp / k_units)
# TODO: For steady-state, it is sufficient to use extent of precipitation reactions
# TODO: For dynamics, need to track amount of solids in basin and use that instead
# This is because solids can redissolve in dynamic mode, thus we need to check
# for the presence of solids, not just the occurrence of precipitation
s = (
b.s_scale[r]
* b.reaction_extent[t, r]
/ (b.reaction_extent[t, r] + b.s_norm[r])
)
Q = k - e
return Q - smooth_max(0, Q - s, b.eps) == 0
# TODO: Energy and momentum balances
# TODO: Rate based reactions?
def _get_performance_contents(self, time_point=0):
var_dict = {}
var_dict["Surface Area"] = self.surface_area[time_point]
var_dict["Average Depth"] = self.average_pond_depth[time_point]
var_dict["Volume"] = self.volume[time_point]
var_dict["Evaporation Rate"] = self.evaporation_rate[time_point]
var_dict["Water Loss Rate"] = self.water_loss_rate[time_point]
for j in self.properties_out.component_list:
var_dict[f"Precipitation Rate {j}"] = self.precipitation_rate[time_point, j]
# TODO: For dynamics, show inventory of solids as well
return {"vars": var_dict}