"""
pyEQL Solution Class.
:copyright: 2013-2023 by Ryan S. Kingsbury
:license: LGPL, see LICENSE for more details.
"""
import math
from functools import lru_cache
from pathlib import Path
from typing import Dict, List, Literal, Optional, Union
from iapws import IAPWS95
from maggma.stores import JSONStore, Store
from monty.dev import deprecated
from monty.json import MSONable
from pint import DimensionalityError, Quantity
from pymatgen.core.ion import Ion
from pyEQL import unit
from pyEQL.engines import EOS, IdealEOS, NativeEOS
# logging system
from pyEQL.logging_system import logger
from pyEQL.salt_ion_match import generate_salt_list, identify_salt
[docs]class Solution(MSONable):
"""
Class representing the properties of a solution. Instances of this class
contain information about the solutes, solvent, and bulk properties.
"""
[docs] def __init__(
self,
solutes: Optional[Union[List[List[str]], Dict[str, str]]] = None,
volume: Optional[str] = None,
temperature: str = "298.15 K",
pressure: str = "1 atm",
pH: float = 7,
pE: float = 8.5,
solvent: Union[str, list] = "H2O",
engine: Literal["native", "ideal"] = "native",
database: Optional[Union[str, Path, Store]] = None,
):
"""
Args:
solutes : dict, optional. Keys must be the chemical formula, while values must be
str Quantity representing the amount. For example:
{"Na+": "0.1 mol/L", "Cl-": "0.1 mol/L"}
Note that an older "list of lists" syntax is also supported; however this
will be deprecated in the future and is no longer recommended. The equivalent
list syntax for the above example is
[["Na+", "0.1 mol/L"], ["Cl-", "0.1 mol/L"]]
Defaults to empty (pure solvent) if omitted
volume : str, optional
Volume of the solvent, including the unit. Defaults to '1 L' if omitted.
Note that the total solution volume will be computed using partial molar
volumes of the respective solutes as they are added to the solution.
temperature : str, optional
The solution temperature, including the unit. Defaults to '25 degC' if omitted.
pressure : Quantity, optional
The ambient pressure of the solution, including the unit.
Defaults to '1 atm' if omitted.
pH : number, optional
Negative log of H+ activity. If omitted, the solution will be
initialized to pH 7 (neutral) with appropriate quantities of
H+ and OH- ions
pe: the pE value (redox potential) of the solution. Lower values = more reducing,
higher values = more oxidizing. At pH 7, water is stable between approximately
-7 to +14. The default value corresponds to a pE value typical of natural
waters in equilibrium with the atmosphere.
solvent: Formula of the solvent. Solvents other than water are not supported at
this time.
engine:
database: path to a .json file (str or Path) or maggma Store instance that
contains serialized SoluteDocs. `None` (default) will use the built-in pyEQL database.
Examples:
>>> s1 = pyEQL.Solution([['Na+','1 mol/L'],['Cl-','1 mol/L']],temperature='20 degC',volume='500 mL')
>>> print(s1)
Components:
['H2O', 'Cl-', 'H+', 'OH-', 'Na+']
Volume: 0.5 l
Density: 1.0383030844030992 kg/l
"""
# create a logger attached to this class
# self.logger = logging.getLogger(type(self).__name__)
# per-instance cache of get_property calls
self.get_property = lru_cache(maxsize=None)(self._get_property)
# initialize the volume recalculation flag
self.volume_update_required = False
# initialize the volume with a flag to distinguish user-specified volume
if volume is not None:
# volume_set = True
self._volume = unit.Quantity(volume).to("L")
else:
# volume_set = False
self._volume = unit.Quantity("1 L")
# store the initial conditions as private variables in case they are
# changed later
self._temperature = unit.Quantity(temperature)
self._pressure = unit.Quantity(pressure)
self._pE = pE
self._pH = pH
self.pE = self._pE
# instantiate a water substance for property retrieval
self.water_substance = IAPWS95(
T=self.temperature.magnitude,
P=self.pressure.to("MPa").magnitude,
)
# create an empty dictionary of components. This dict comprises {formula: moles}
# where moles is the number of moles in the solution.
self.components: dict = {}
# connect to the desired property database
if not isinstance(database, Store):
if database is None:
from pkg_resources import resource_filename
database_dir = resource_filename("pyEQL", "database")
json = Path(database_dir) / "pyeql_db.json"
else:
json = database if isinstance(database, str) else str(database)
db_store = JSONStore(json, key="formula")
logger.info(f"Created maggma JSONStore from .json file {database}")
else:
db_store = database
self.database = db_store
self.database.connect()
logger.info(f"Connected to property database {self.database!s}")
# set the equation of state engine
self._engine = engine
# self.engine: Optional[EOS] = None
if self._engine == "ideal":
self.engine: EOS = IdealEOS()
elif self._engine == "native":
self.engine = NativeEOS()
else:
raise ValueError(f'{engine} is not a valid value for the "engine" kwarg!')
# define the solvent. Allow for list input to support future use of mixed solvents
if not isinstance(solvent, list):
solvent = [solvent]
if len(solvent) > 1:
raise ValueError("Multiple solvents are not yet supported!")
if solvent[0] not in ["H2O", "H2O(aq)", "water", "Water", "HOH"]:
raise ValueError("Non-aqueous solvent detected. These are not yet supported!")
self.solvent = solvent[0]
# TODO - do I need the ability to specify the solvent mass?
# # raise an error if the solvent volume has also been given
# if volume_set is True:
# logger.error(
# "Solvent volume and mass cannot both be specified. Calculating volume based on solvent mass."
# )
# # add the solvent and the mass
# self.add_solvent(self.solvent, kwargs["solvent"][1])
# calculate the moles of solvent (water) on the density and the solution volume
moles = self.volume / unit.Quantity("55.55 mol/L")
self.components["H2O"] = moles.magnitude
# set the pH with H+ and OH-
self.add_solute("H+", str(10 ** (-1 * pH)) + "mol/L")
self.add_solute("OH-", str(10 ** (-1 * (14 - pH))) + "mol/L")
# populate the other solutes
self._solutes = solutes
if self._solutes is None:
self._solutes = {}
if isinstance(self._solutes, dict):
for k, v in self._solutes.items():
self.add_solute(k, v)
elif isinstance(self._solutes, list):
logger.warning(
'List input of solutes (e.g., [["Na+", "0.5 mol/L]]) is deprecated! Use dictionary formatted input (e.g., {"Na+":"0.5 mol/L"} instead.)'
)
for item in self._solutes:
self.add_solute(*item)
elif self._solutes is not None:
raise ValueError("Solutes must be given as a list or dict!")
[docs] def add_solute(self, formula, amount):
"""Primary method for adding substances to a pyEQL solution.
Parameters
----------
formula : str
Chemical formula for the solute.
Charged species must contain a + or - and (for polyvalent solutes) a number representing the net charge (e.g. 'SO4-2').
amount : str
The amount of substance in the specified unit system. The string should contain both a quantity and
a pint-compatible representation of a unit. e.g. '5 mol/kg' or '0.1 g/L'
"""
# if units are given on a per-volume basis,
# iteratively solve for the amount of solute that will preserve the
# original volume and result in the desired concentration
if unit.Quantity(amount).dimensionality in (
"[substance]/[length]**3",
"[mass]/[length]**3",
):
# store the original volume for later
orig_volume = self.volume
# add the new solute
quantity = unit.Quantity(amount)
mw = self.get_property(formula, "molecular_weight") # returns a quantity
target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
self.components[formula] = target_mol.to("moles").magnitude
# calculate the volume occupied by all the solutes
solute_vol = self._get_solute_volume()
# determine the volume of solvent that will preserve the original volume
target_vol = orig_volume - solute_vol
# adjust the amount of solvent
# density is returned in kg/m3 = g/L
target_mass = target_vol.to("L").magnitude * self.water_substance.rho * unit.Quantity("1 g")
# mw = unit.Quantity(self.get_property(self.solvent_name, "molecular_weight"))
mw = self.get_property(self.solvent, "molecular_weight")
if mw is None:
raise ValueError(
f"Molecular weight for solvent {self.solvent} not found in database. This is required to proceed."
)
target_mol = target_mass.to("g") / mw.to("g/mol")
self.components[self.solvent] = target_mol.magnitude
else:
# add the new solute
quantity = unit.Quantity(amount)
mw = unit.Quantity(self.get_property(formula, "molecular_weight"))
target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
self.components[formula] = target_mol.to("moles").magnitude
# update the volume to account for the space occupied by all the solutes
# make sure that there is still solvent present in the first place
if self.solvent_mass <= unit.Quantity("0 kg"):
logger.error("All solvent has been depleted from the solution")
return
# set the volume recalculation flag
self.volume_update_required = True
# TODO - deprecate this method. Solvent should be added to the dict like anything else
# and solvent_name will track which component it is.
[docs] def add_solvent(self, formula, amount):
"""Same as add_solute but omits the need to pass solvent mass to pint."""
quantity = unit.Quantity(amount)
mw = unit.Quantity(self.get_property(formula, "molecular_weight"))
target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
self.components[formula] = target_mol.to("moles").magnitude
@property
def temperature(self) -> Quantity:
"""Return the temperature of the solution in Kelvin."""
return self._temperature.to("K")
@temperature.setter
def temperature(self, temperature: str):
"""
Set the solution temperature.
Args:
temperature: pint-compatible string, e.g. '25 degC'
"""
self._temperature = unit.Quantity(temperature)
# recalculate the volume
self.volume_update_required = True
@property
def pH(self) -> Quantity:
"""Return the pH of the solution."""
return self.p("H+", activity=True)
@property
def pressure(self) -> Quantity:
"""Return the hydrostatic pressure of the solution in atm."""
return self._pressure.to("atm")
@pressure.setter
def pressure(self, pressure: str):
"""
Set the solution pressure.
Args:
pressure: pint-compatible string, e.g. '1.2 atmC'
"""
self._pressure = unit.Quantity(pressure)
# recalculate the volume
self.volume_update_required = True
@property
def solvent_mass(self):
"""
Return the mass of the solvent.
This property is used whenever mol/kg (or similar) concentrations
are requested by get_amount()
Returns
-------
Quantity: the mass of the solvent, in kg
See Also
--------
:py:meth:`get_amount()`
"""
# return the total mass (kg) of the solvent
# mw = self.get_property(self.solvent, "molecular_weight").to("kg/mol").magnitude
# return self.components[self.solvent] * mw * unit.Quantity("1 kg")
return self.get_amount(self.solvent, "kg")
@property
def volume(self) -> Quantity:
"""
Return the volume of the solution.
Returns:
-------
Quantity: the volume of the solution, in L
"""
# if the composition has changed, recalculate the volume first
if self.volume_update_required is True:
self._update_volume()
self.volume_update_required = False
return self._volume.to("L")
@volume.setter
def volume(self, volume: str):
"""Change the total solution volume to volume, while preserving
all component concentrations.
Args:
volume : Total volume of the solution, including the unit, e.g. '1 L'
Examples:
---------
>>> mysol = Solution([['Na+','2 mol/L'],['Cl-','0.01 mol/L']],volume='500 mL')
>>> print(mysol.volume)
0.5000883925072983 l
>>> mysol.list_concentrations()
{'H2O': '55.508435061791985 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
>>> mysol.volume = '200 mL')
>>> print(mysol.volume)
0.2 l
>>> mysol.list_concentrations()
{'H2O': '55.50843506179199 mol/kg', 'Cl-': '0.00992937605907076 mol/kg', 'Na+': '2.0059345573880325 mol/kg'}
"""
# figure out the factor to multiply the old concentrations by
scale_factor = unit.Quantity(volume) / self.volume
# scale down the amount of all the solutes according to the factor
for solute in self.components:
self.components[solute] *= scale_factor.magnitude
# update the solution volume
self._volume *= scale_factor.magnitude
@property
def mass(self) -> Quantity:
"""
Return the total mass of the solution.
The mass is calculated each time this method is called.
Parameters
----------
None
Returns
-------
Quantity: the mass of the solution, in kg
"""
total_mass = 0
for item in self.components:
total_mass += self.get_amount(item, "kg")
return total_mass.to("kg")
@property
def density(self) -> Quantity:
"""
Return the density of the solution.
Density is calculated from the mass and volume each time this method is called.
Returns
-------
Quantity: The density of the solution.
"""
return self.mass / self.volume
@property
def dielectric_constant(self) -> Quantity:
"""
Returns the dielectric constant of the solution.
Parameters
----------
None
Returns
-------
Quantity: the dielectric constant of the solution, dimensionless.
Notes
-----
Implements the following equation as given by Zuber et al.
.. math:: \\epsilon = \\epsilon_{solvent} \\over 1 + \\sum_i \\alpha_i x_i
where :math:`\\alpha_i` is a coefficient specific to the solvent and ion, and :math:`x_i`
is the mole fraction of the ion in solution.
References
----------
.A. Zuber, L. Cardozo-Filho, V.F. Cabral, R.F. Checoni, M. Castier,
An empirical equation for the dielectric constant in aqueous and nonaqueous
electrolyte mixtures, Fluid Phase Equilib. 376 (2014) 116-123.
doi:10.1016/j.fluid.2014.05.037.
"""
di_water = self.water_substance.epsilon
denominator = 1
for item in self.components:
# ignore water
if item != "H2O":
# skip over solutes that don't have parameters
# try:
fraction = self.get_amount(item, "fraction")
coefficient = self.get_property(item, "model_parameters.dielectric_zuber")
if coefficient is not None:
denominator += coefficient * fraction
# except TypeError:
# logger.warning("No dielectric parameters found for species %s." % item)
# continue
return unit.Quantity(di_water / denominator, "dimensionless")
# TODO - need tests for viscosity
@property
def viscosity_dynamic(self) -> Quantity:
"""
Return the dynamic (absolute) viscosity of the solution.
Calculated from the kinematic viscosity
See Also:
--------
viscosity_kinematic
"""
return self.viscosity_kinematic * self.density
# TODO - before deprecating get_viscosity_relative, consider whether the Jones-Dole
# model should be integrated here as a fallback, in case salt parameters for the
# other model are not available.
# if self.ionic_strength.magnitude > 0.2:
# logger.warning('Viscosity calculation has limited accuracy above 0.2m')
# viscosity_rel = 1
# for item in self.components:
# # ignore water
# if item != 'H2O':
# # skip over solutes that don't have parameters
# try:
# conc = self.get_amount(item,'mol/kg').magnitude
# coefficients= self.get_property(item, 'jones_dole_viscosity')
# viscosity_rel += coefficients[0] * conc ** 0.5 + coefficients[1] * conc + \
# coefficients[2] * conc ** 2
# except TypeError:
# continue
# return (
# self.viscosity_dynamic / self.water_substance.mu * unit.Quantity("1 Pa*s")
# )
@property
def viscosity_kinematic(self):
"""
Return the kinematic viscosity of the solution.
Notes
-----
The calculation is based on a model derived from the Eyring equation
and presented in
.. math::
\\ln \\nu = \\ln {\\nu_w MW_w \\over \\sum_i x_i MW_i } +
15 x_+^2 + x_+^3 \\delta G^*_{123} + 3 x_+ \\delta G^*_{23} (1-0.05x_+)
Where:
.. math:: \\delta G^*_{123} = a_o + a_1 (T)^{0.75}
.. math:: \\delta G^*_{23} = b_o + b_1 (T)^{0.5}
In which :math:`\\nu` is the kinematic viscosity, MW is the molecular weight,
:math:`x_{+}` is the mole fraction of cations, and :math:`T` is the temperature in degrees C.
The a and b fitting parameters for a variety of common salts are included in the
database.
References
----------
Vásquez-Castillo, G.; Iglesias-Silva, G. a.; Hall, K. R. An extension of the McAllister model to correlate kinematic viscosity of electrolyte solutions. Fluid Phase Equilib. 2013, 358, 44-49.
See Also:
--------
:py:meth:`viscosity_dynamic`
"""
# identify the main salt in the solution
salt = self.get_salt()
# reverse-convert the sanitized formula back to whatever was in self.components
for i in self.components:
if Ion.from_formula(i).reduced_formula == salt.cation:
cation = i
a0 = a1 = b0 = b1 = 0
# retrieve the parameters for the delta G equations
params = self.get_property(salt.formula, "model_parameters.viscosity_eyring")
if params is not None:
a0 = unit.Quantity(params["a0"]["value"]).magnitude
a1 = unit.Quantity(params["a1"]["value"]).magnitude
b0 = unit.Quantity(params["b0"]["value"]).magnitude
b1 = unit.Quantity(params["b1"]["value"]).magnitude
else:
# proceed with the coefficients equal to zero and log a warning
logger.warning("Viscosity coefficients for %s not found. Viscosity will be approximate." % salt.formula)
# compute the delta G parameters
temperature = self.temperature.to("degC").magnitude
G_123 = a0 + a1 * (temperature) ** 0.75
G_23 = b0 + b1 * (temperature) ** 0.5
# get the kinematic viscosity of water, returned by IAPWS in m2/s
nu_w = self.water_substance.nu
# compute the effective molar mass of the solution
MW = self.mass / (self.get_moles_solvent() + self.get_total_moles_solute())
# get the MW of water
MW_w = unit.Quantity(self.get_property(self.solvent, "molecular_weight"))
# calculate the cation mole fraction
x_cat = self.get_amount(cation, "fraction")
# calculate the kinematic viscosity
nu = math.log(nu_w * MW_w / MW) + 15 * x_cat**2 + x_cat**3 * G_123 + 3 * x_cat * G_23 * (1 - 0.05 * x_cat)
return math.exp(nu) * unit.Quantity("m**2 / s")
# TODO - need tests of conductivity
@property
def conductivity(self):
"""
Compute the electrical conductivity of the solution.
Parameters
----------
None
Returns
-------
Quantity
The electrical conductivity of the solution in Siemens / meter.
Notes
-----
Conductivity is calculated by summing the molar conductivities of the respective
solutes, but they are activity-corrected and adjusted using an empricial exponent.
This approach is used in PHREEQC and Aqion models [aq]_ [hc]_
.. math::
EC = {F^2 \\over R T} \\sum_i D_i z_i ^ 2 \\gamma_i ^ {\\alpha} m_i
Where:
.. math::
\\alpha =
\\begin{cases}
{\\frac{0.6}{\\sqrt{| z_{i} | }}} & {I < 0.36 | z_{i} | }
{\\frac{\\sqrt{I}}{| z_i |}} & otherwise
\\end{cases}
Note: PHREEQC uses the molal rather than molar concentration according to
http://wwwbrr.cr.usgs.gov/projects/GWC_coupled/phreeqc/phreeqc3-html/phreeqc3-43.htm
References
----------
.. [aq] https://www.aqion.de/site/electrical-conductivity
.. [hc] http://www.hydrochemistry.eu/exmpls/sc.html
See Also
--------
:py:attr:`ionic_strength`
:py:meth:`get_molar_conductivity()`
:py:meth:`get_activity_coefficient()`
"""
EC = 0 * unit.Quantity("S/m")
for item in self.components:
z = abs(z=self.get_property(item, "charge"))
# ignore uncharged species
if z != 0:
# determine the value of the exponent alpha
if self.ionic_strength.magnitude < 0.36 * z:
alpha = 0.6 / z**0.5
else:
alpha = self.ionic_strength.magnitude**0.5 / z
diffusion_coefficient = self.get_property(item, "transport.diffusion_coefficient")
molar_cond = (
diffusion_coefficient
* (unit.e * unit.N_A) ** 2
* self.get_property(item, "charge") ** 2
/ (unit.R * self.temperature)
)
EC += molar_cond * self.get_activity_coefficient(item) ** alpha * self.get_amount(item, "mol/L")
return EC.to("S/m")
@property
def ionic_strength(self) -> Quantity:
"""
Return the ionic strength of the solution.
Return the ionic strength of the solution, calculated as 1/2 * sum ( molality * charge ^2) over all the ions.
Molal (mol/kg) scale concentrations are used for compatibility with the activity correction formulas.
Returns
-------
Quantity :
The ionic strength of the parent solution, mol/kg.
See Also:
--------
:py:meth:`get_activity`
:py:meth:`get_water_activity`
Notes
-----
The ionic strength is calculated according to:
.. math:: I = \\sum_i m_i z_i^2
Where :math:`m_i` is the molal concentration and :math:`z_i` is the charge on species i.
Examples:
--------
>>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
>>> s1.ionic_strength
<Quantity(0.20000010029672785, 'mole / kilogram')>
>>> s1 = pyEQL.Solution([['Mg+2','0.3 mol/kg'],['Na+','0.1 mol/kg'],['Cl-','0.7 mol/kg']],temperature='30 degC')
>>> s1.ionic_strength
<Quantity(1.0000001004383303, 'mole / kilogram')>
"""
ionic_strength = 0
for solute in self.components:
ionic_strength += 0.5 * self.get_amount(solute, "mol/kg") * self.get_property(solute, "charge") ** 2
return ionic_strength
@property
def charge_balance(self) -> float:
"""
Return the charge balance of the solution.
Return the charge balance of the solution. The charge balance represents the net electric charge
on the solution and SHOULD equal zero at all times, but due to numerical errors will usually
have a small nonzero value. It is calculated according to:
.. math:: CB = F \\sum_i n_i z_i
where :math:`n_i` is the number of moles, :math:`z_i` is the charge on species i, and :math:`F` is the Faraday constant.
Returns
-------
float :
The charge balance of the solution, in equivalents.
"""
charge_balance = 0
F = (unit.e * unit.N_A).magnitude
for solute in self.components:
charge_balance += self.get_amount(solute, "mol").magnitude * self.get_property(solute, "charge") * F
return charge_balance
# TODO - need tests for alkalinity
@property
def alkalinity(self):
"""
Return the alkalinity or acid neutralizing capacity of a solution.
Returns
-------
Quantity :
The alkalinity of the solution in mg/L as CaCO3
Notes
-----
The alkalinity is calculated according to [stm]_
.. math:: Alk = F \\sum_{i} z_{i} C_{B} - \\sum_{i} z_{i} C_{A}
Where :math:`C_{B}` and :math:`C_{A}` are conservative cations and anions, respectively
(i.e. ions that do not participate in acid-base reactions), and :math:`z_{i}` is their charge.
In this method, the set of conservative cations is all Group I and Group II cations, and the
conservative anions are all the anions of strong acids.
References
----------
.. [stm] Stumm, Werner and Morgan, James J. Aquatic Chemistry, 3rd ed, pp 165. Wiley Interscience, 1996.
"""
alkalinity = 0 * unit.Quantity("mol/L")
equiv_wt_CaCO3 = 100.09 / 2 * unit.Quantity("g/mol")
base_cations = [
"Li+",
"Na+",
"K+",
"Rb+",
"Cs+",
"Fr+",
"Be+2",
"Mg+2",
"Ca+2",
"Sr+2",
"Ba+2",
"Ra+2",
]
acid_anions = ["Cl-", "Br-", "I-", "SO4-2", "NO3-", "ClO4-", "ClO3-"]
for item in self.components:
if item in base_cations:
z = self.get_property(item, "charge")
alkalinity += self.get_amount(item, "mol/L") * z
if item in acid_anions:
z = self.get_property(item, "charge")
alkalinity -= self.get_amount(item, "mol/L") * z
# convert the alkalinity to mg/L as CaCO3
return (alkalinity * equiv_wt_CaCO3).to("mg/L")
@property
def hardness(self):
"""
Return the hardness of a solution.
Hardness is defined as the sum of the equivalent concentrations
of multivalent cations as calcium carbonate.
NOTE: at present pyEQL cannot distinguish between mg/L as CaCO3
and mg/L units. Use with caution.
Parameters
----------
None
Returns
-------
Quantity
The hardness of the solution in mg/L as CaCO3
"""
hardness = 0 * unit.Quantity("mol/L")
equiv_wt_CaCO3 = 100.09 / 2 * unit.Quantity("g/mol")
for item in self.components:
z = self.get_property(item, "charge")
if z > 1:
hardness += z * self.get_amount(item, "mol/L")
# convert the hardness to mg/L as CaCO3
return (hardness * equiv_wt_CaCO3).to("mg/L")
@property
def debye_length(self) -> Quantity:
"""
Return the Debye length of a solution.
Debye length is calculated as [wk3]_
.. math::
\\kappa^{-1} = \\sqrt({\\epsilon_r \\epsilon_o k_B T \\over (2 N_A e^2 I)})
where :math:`I` is the ionic strength, :math:`\\epsilon_r` and :math:`\\epsilon_r`
are the relative permittivity and vacuum permittivity, :math:`k_B` is the
Boltzmann constant, and :math:`T` is the temperature, :math:`e` is the
elementary charge, and :math:`N_A` is Avogadro's number.
Returns The Debye length, in nanometers.
References
.. [wk3] https://en.wikipedia.org/wiki/Debye_length#Debye_length_in_an_electrolyte
See Also:
:attr:`ionic_strength`
:attr:`dielectric_constant`
"""
# to preserve dimensionality, convert the ionic strength into mol/L units
ionic_strength = self.ionic_strength.magnitude * unit.Quantity("mol/L")
dielectric_constant = self.dielectric_constant
debye_length = (
dielectric_constant
* unit.epsilon_0
* unit.k
* self.temperature
/ (2 * unit.N_A * unit.e**2 * ionic_strength)
) ** 0.5
return debye_length.to("nm")
@property
def bjerrum_length(self) -> Quantity:
"""
Return the Bjerrum length of a solution.
Bjerrum length represents the distance at which electrostatic
interactions between particles become comparable in magnitude
to the thermal energy.:math:`\\lambda_B` is calculated as
.. math::
\\lambda_B = {e^2 \\over (4 \\pi \\epsilon_r \\epsilon_o k_B T)}
where :math:`e` is the fundamental charge, :math:`\\epsilon_r` and :math:`\\epsilon_r`
are the relative permittivity and vacuum permittivity, :math:`k_B` is the
Boltzmann constant, and :math:`T` is the temperature.
Parameters
----------
None
Returns
-------
Quantity
The Bjerrum length, in nanometers.
References
----------
https://en.wikipedia.org/wiki/Bjerrum_length
Examples
--------
>>> s1 = pyEQL.Solution()
>>> s1.bjerrum_length
<Quantity(0.7152793009386953, 'nanometer')>
See Also
--------
:attr:`dielectric_constant`
"""
bjerrum_length = unit.e**2 / (
4 * math.pi * self.dielectric_constant * unit.epsilon_0 * unit.k * self.temperature
)
return bjerrum_length.to("nm")
[docs] def get_osmotic_pressure(self):
"""
Return the osmotic pressure of the solution relative to pure water.
Returns
The osmotic pressure of the solution relative to pure water in Pa
See Also:
get_water_activity
get_osmotic_coefficient
get_salt
Notes:
Osmotic pressure is calculated based on the water activity [sata]_ [wk]_
.. math:: \\Pi = \\frac{RT}{V_{w}} \\ln a_{w}
Where :math:`\\Pi` is the osmotic pressure, :math:`V_{w}` is the partial
molar volume of water (18.2 cm**3/mol), and :math:`a_{w}` is the water
activity.
References
.. [sata] Sata, Toshikatsu. Ion Exchange Membranes: Preparation, Characterization, and Modification.
Royal Society of Chemistry, 2004, p. 10.
.. [wk] http://en.wikipedia.org/wiki/Osmotic_pressure#Derivation_of_osmotic_pressure
Examples:
>>> s1=pyEQL.Solution()
>>> s1.get_osmotic_pressure()
0.0
>>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
>>> soln.get_osmotic_pressure()
<Quantity(906516.7318131207, 'pascal')>
"""
# TODO - tie this into parameter() and solvent() objects
partial_molar_volume_water = 1.82e-5 * unit.Quantity("m ** 3/mol")
osmotic_pressure = (
-1 * unit.R * self.temperature / partial_molar_volume_water * math.log(self.get_water_activity())
)
logger.info(
f"Computed osmotic pressure of solution as {osmotic_pressure} Pa at T= {self.temperature} degrees C"
)
return osmotic_pressure.to("Pa")
# Concentration Methods
[docs] def p(self, solute, activity=True):
"""
Return the negative log of the activity of solute.
Generally used for expressing concentration of hydrogen ions (pH)
Parameters
----------
solute : str
String representing the formula of the solute
activity: bool, optional
If False, the function will use the molar concentration rather
than the activity to calculate p. Defaults to True.
Returns
-------
Quantity
The negative log10 of the activity (or molar concentration if
activity = False) of the solute.
Examples:
--------
Todo:
"""
try:
if activity is True:
return -1 * math.log10(self.get_activity(solute))
if activity is False:
return -1 * math.log10(self.get_amount(solute, "mol/L").magnitude)
# if the solute has zero concentration, the log will generate a ValueError
except ValueError:
return 0
[docs] def get_amount(self, solute, units):
"""
Return the amount of 'solute' in the parent solution.
The amount of a solute can be given in a variety of unit types.
1. substance per volume (e.g., 'mol/L')
2. substance per mass of solvent (e.g., 'mol/kg')
3. mass of substance (e.g., 'kg')
4. moles of substance ('mol')
5. mole fraction ('fraction')
6. percent by weight (%)
7. number of molecules ('count')
Parameters
----------
solute : str
String representing the name of the solute of interest
units : str
Units desired for the output. Examples of valid units are
'mol/L','mol/kg','mol', 'kg', and 'g/L'
Use 'fraction' to return the mole fraction.
Use '%' to return the mass percent
Returns
-------
The amount of the solute in question, in the specified units
See Also
--------
add_amount
set_amount
get_total_amount
get_osmolarity
get_osmolality
get_mass
get_total_moles_solute
"""
# retrieve the number of moles of solute and its molecular weight
try:
moles = unit.Quantity(self.components[solute], "mol")
# if the solute is not present in the solution, we'll get a KeyError
# In that case, the amount is zero
except KeyError:
try:
return 0 * unit.Quantity(units)
except DimensionalityError:
logger.error("Unsupported unit specified for get_amount")
return 0
# with pint unit conversions enabled, we just pass the unit to pint
# the logic tests here ensure that only the required arguments are
# passed to pint for the unit conversion. This avoids unnecessary
# function calls.
if units == "count":
return round((moles * unit.N_A).to("dimensionless"), 0)
if units == "fraction":
return moles / (self.get_moles_solvent() + self.get_total_moles_solute())
mw = unit.Quantity(self.get_property(solute, "molecular_weight")).to("g/mol")
if units == "%":
return moles.to("kg", "chem", mw=mw) / self.mass.to("kg") * 100
if unit.Quantity(units).check("[substance]"):
return moles.to(units)
qty = unit.Quantity(units)
if qty.check("[substance]/[length]**3") or qty.check("[mass]/[length]**3"):
return moles.to(units, "chem", mw=mw, volume=self.volume)
if qty.check("[substance]/[mass]") or qty.check("[mass]/[mass]"):
return moles.to(units, "chem", mw=mw, solvent_mass=self.solvent_mass)
if qty.check("[mass]"):
return moles.to(units, "chem", mw=mw)
logger.error("Unsupported unit specified for get_amount")
return None
[docs] def get_total_amount(self, element, units):
"""
Return the total amount of 'element' (across all solutes) in the solution.
Parameters
----------
element : str
String representing the name of the element of interest
units : str
Units desired for the output. Examples of valid units are
'mol/L','mol/kg','mol', 'kg', and 'g/L'
Returns
-------
The total amount of the element in the solution, in the specified units
Notes
-----
There is currently no way to distinguish between different oxidation
states of the same element (e.g. TOTFe(II) vs. TOTFe(III)). This
is planned for a future release. (TODO)
See Also
--------
get_amount
"""
from pymatgen.core import Element
el = str(Element(element))
TOT = 0 * unit.Quantity(units)
# loop through all the solutes, process each one containing element
for item in self.components:
# check whether the solute contains the element
# if ch.contains(item, element):
if el in self.get_property(item, "elements"):
# start with the amount of the solute in the desired units
amt = self.get_amount(item, units)
ion = Ion.from_formula(item)
# convert the solute amount into the amount of element by
# either the mole / mole or weight ratio
if unit.Quantity(units).dimensionality in (
"[substance]",
"[substance]/[length]**3",
"[substance]/[mass]",
):
TOT += amt * ion.get_el_amt_dict[el] # returns {el: mol per formula unit}
elif unit.Quantity(units).dimensionality in (
"[mass]",
"[mass]/[length]**3",
"[mass]/[mass]",
):
TOT += amt * ion.to_weight_dict["el"] # returns {el: wt fraction}
return TOT
[docs] def add_amount(self, solute, amount):
"""
Add the amount of 'solute' to the parent solution.
Parameters
----------
solute : str
String representing the name of the solute of interest
amount : str quantity
String representing the concentration desired, e.g. '1 mol/kg'
If the units are given on a per-volume basis, the solution
volume is not recalculated
If the units are given on a mass, substance, per-mass, or
per-substance basis, then the solution volume is recalculated
based on the new composition
Returns
-------
Nothing. The concentration of solute is modified.
"""
# if units are given on a per-volume basis,
# iteratively solve for the amount of solute that will preserve the
# original volume and result in the desired concentration
if unit.Quantity(amount).dimensionality in (
"[substance]/[length]**3",
"[mass]/[length]**3",
):
# store the original volume for later
orig_volume = self.volume
# change the amount of the solute present to match the desired amount
self.components[solute] += (
unit.Quantity(amount)
.to(
"moles",
"chem",
mw=unit.Quantity(self.get_property(solute, "molecular_weight")),
volume=self.volume,
solvent_mass=self.solvent_mass,
)
.magnitude
)
# set the amount to zero and log a warning if the desired amount
# change would result in a negative concentration
if self.get_amount(solute, "mol").magnitude < 0:
logger.warning(
"Attempted to set a negative concentration for solute %s. Concentration set to 0" % solute
)
self.set_amount(solute, "0 mol")
# calculate the volume occupied by all the solutes
solute_vol = self._get_solute_volume()
# determine the volume of solvent that will preserve the original volume
target_vol = orig_volume - solute_vol
# adjust the amount of solvent
# volume in L, density in kg/m3 = g/L
target_mass = target_vol.magnitude * self.water_substance.rho * unit.Quantity("1 g")
mw = unit.Quantity(self.get_property(self.solvent, "molecular_weight"))
target_mol = target_mass / mw
self.components[self.solvent] = target_mol.magnitude
else:
# change the amount of the solute present
self.components[solute] += (
unit.Quantity(amount)
.to(
"moles",
"chem",
mw=unit.Quantity(self.get_property(solute, "molecular_weight")),
volume=self.volume,
solvent_mass=self.solvent_mass,
)
.magnitude
)
# set the amount to zero and log a warning if the desired amount
# change would result in a negative concentration
if self.get_amount(solute, "mol").magnitude < 0:
logger.warning(
"Attempted to set a negative concentration for solute %s. Concentration set to 0" % solute
)
self.set_amount(solute, "0 mol")
# update the volume to account for the space occupied by all the solutes
# make sure that there is still solvent present in the first place
if self.solvent_mass <= unit.Quantity("0 kg"):
logger.error("All solvent has been depleted from the solution")
return
# set the volume recalculation flag
self.volume_update_required = True
[docs] def set_amount(self, solute, amount):
"""
Set the amount of 'solute' in the parent solution.
Parameters
----------
solute : str
String representing the name of the solute of interest
amount : str Quantity
String representing the concentration desired, e.g. '1 mol/kg'
If the units are given on a per-volume basis, the solution
volume is not recalculated and the molar concentrations of
other components in the solution are not altered, while the
molal concentrations are modified.
If the units are given on a mass, substance, per-mass, or
per-substance basis, then the solution volume is recalculated
based on the new composition and the molal concentrations of
other components are not altered, while the molar concentrations
are modified.
Returns
-------
Nothing. The concentration of solute is modified.
"""
# raise an error if a negative amount is specified
if unit.Quantity(amount).magnitude < 0:
logger.error("Negative amount specified for solute %s. Concentration not changed." % solute)
# if units are given on a per-volume basis,
# iteratively solve for the amount of solute that will preserve the
# original volume and result in the desired concentration
elif unit.Quantity(amount).dimensionality in (
"[substance]/[length]**3",
"[mass]/[length]**3",
):
# store the original volume for later
orig_volume = self.volume
# change the amount of the solute present to match the desired amount
self.components[solute] = (
unit.Quantity(amount)
.to(
"moles",
"chem",
mw=unit.Quantity(self.get_property(solute, "molecular_weight")),
volume=self.volume,
solvent_mass=self.solvent_mass,
)
.magnitude
)
# calculate the volume occupied by all the solutes
solute_vol = self._get_solute_volume()
# determine the volume of solvent that will preserve the original volume
target_vol = orig_volume - solute_vol
# adjust the amount of solvent
target_mass = target_vol.magnitude / 1000 * self.water_substance.rho * unit.Quantity("1 kg")
mw = self.get_property(self.solvent, "molecular_weight")
target_mol = target_mass / mw
self.components[self.solvent] = target_mol.to("mol").magnitude
else:
# change the amount of the solute present
self.components[solute] = (
unit.Quantity(amount)
.to(
"moles",
"chem",
mw=unit.Quantity(self.get_property(solute, "molecular_weight")),
volume=self.volume,
solvent_mass=self.solvent_mass,
)
.magnitude
)
# update the volume to account for the space occupied by all the solutes
# make sure that there is still solvent present in the first place
if self.solvent_mass <= unit.Quantity("0 kg"):
logger.error("All solvent has been depleted from the solution")
return
self._update_volume()
[docs] def get_osmolarity(self, activity_correction=False):
"""Return the osmolarity of the solution in Osm/L.
Parameters
----------
activity_correction : bool
If TRUE, the osmotic coefficient is used to calculate the
osmolarity. This correction is appropriate when trying to predict
the osmolarity that would be measured from e.g. freezing point
depression. Defaults to FALSE if omitted.
"""
factor = self.get_osmotic_coefficient() if activity_correction is True else 1
return factor * self.get_total_moles_solute() / self.volume.to("L")
[docs] def get_osmolality(self, activity_correction=False):
"""Return the osmolality of the solution in Osm/kg.
Parameters
----------
activity_correction : bool
If TRUE, the osmotic coefficient is used to calculate the
osmolarity. This correction is appropriate when trying to predict
the osmolarity that would be measured from e.g. freezing point
depression. Defaults to FALSE if omitted.
"""
factor = self.get_osmotic_coefficient() if activity_correction is True else 1
return factor * self.get_total_moles_solute() / self.solvent_mass.to("kg")
[docs] def get_total_moles_solute(self) -> Quantity:
"""Return the total moles of all solute in the solution."""
tot_mol = 0
for item in self.components:
if item != self.solvent:
tot_mol += self.components[item]
return unit.Quantity(tot_mol, "mol")
[docs] def get_moles_solvent(self) -> Quantity:
"""
Return the moles of solvent present in the solution.
Returns
The moles of solvent in the solution.
"""
return self.get_amount(self.solvent, "mol")
[docs] def get_salt(self):
"""
Determine the predominant salt in a solution of ions.
Many empirical equations for solution properties such as activity coefficient,
partial molar volume, or viscosity are based on the concentration of
single salts (e.g., NaCl). When multiple ions are present (e.g., a solution
containing Na+, Cl-, and Mg+2), it is generally not possible to directly model
these quantities. pyEQL works around this problem by treating such solutions
as single salt solutions.
The get_salt() method examines the ionic composition of a solution and returns
an object that identifies the single most predominant salt in the solution, defined
by the cation and anion with the highest mole fraction. The Salt object contains
information about the stoichiometry of the salt to enable its effective concentration
to be calculated (e.g., 1 M MgCl2 yields 1 M Mg+2 and 2 M Cl-).
Parameters
----------
None
Returns
-------
Salt
Salt object containing information about the parent salt.
See Also
--------
:py:meth:`get_activity`
:py:meth:`get_activity_coefficient`
:py:meth:`get_water_activity`
:py:meth:`get_osmotic_coefficient`
:py:meth:`get_osmotic_pressure`
:py:meth:`get_viscosity_kinematic`
Examples
--------
>>> s1 = Solution([['Na+','0.5 mol/kg'],['Cl-','0.5 mol/kg']])
>>> s1.get_salt()
<pyEQL.salt_ion_match.Salt object at 0x7fe6d3542048>
>>> s1.get_salt().formula
'NaCl'
>>> s1.get_salt().nu_cation
1
>>> s1.get_salt().z_anion
-1
>>> s2 = pyEQL.Solution([['Na+','0.1 mol/kg'],['Mg+2','0.2 mol/kg'],['Cl-','0.5 mol/kg']])
>>> s2.get_salt().formula
'MgCl2'
>>> s2.get_salt().nu_anion
2
>>> s2.get_salt().z_cation
2
"""
# identify the predominant salt in the solution
return identify_salt(self)
[docs] def get_salt_list(self):
"""
Determine the predominant salt in a solution of ions.
Many empirical equations for solution properties such as activity coefficient,
partial molar volume, or viscosity are based on the concentration of
single salts (e.g., NaCl). When multiple ions are present (e.g., a solution
containing Na+, Cl-, and Mg+2), it is generally not possible to directly model
these quantities.
The get_salt_list() method examines the ionic composition of a solution and
simplifies it into a list of salts. The method returns a dictionary of
Salt objects where the keys are the salt formulas (e.g., 'NaCl'). The
Salt object contains information about the stoichiometry of the salt to
enable its effective concentration to be calculated
(e.g., 1 M MgCl2 yields 1 M Mg+2 and 2 M Cl-).
Parameters
----------
None
Returns
-------
dict
A dictionary of Salt objects, keyed to the salt formula
See Also:
--------
:py:meth:`get_activity`
:py:meth:`get_activity_coefficient`
:py:meth:`get_water_activity`
:py:meth:`get_osmotic_coefficient`
:py:meth:`get_osmotic_pressure`
:py:meth:`get_viscosity_kinematic`
"""
# identify the predominant salt in the solution
return generate_salt_list(self, unit="mol/kg")
# Activity-related methods
[docs] def get_activity_coefficient(
self,
solute: str,
scale: Literal["molal", "molar", "fugacity", "rational"] = "molal",
verbose: bool = False,
):
"""
Return the activity coefficient of a solute in solution.
The model used to calculte the activity coefficient is determined by the Solution's equation of state
engine.
Args:
solute: The solute for which to retrieve the activity coefficient
scale: The activity coefficient concentration scale
verbose: If True, pyEQL will print a message indicating the parent salt
that is being used for activity calculations. This option is
useful when modeling multicomponent solutions. False by default.
Returns
Quantity: the activity coefficient as a dimensionless pint Quantity
"""
# return unit activity coefficient if the concentration of the solute is zero
if self.get_amount(solute, "mol").magnitude == 0:
return unit.Quantity("1 dimensionless")
try:
# get the molal-scale activity coefficient from the EOS engine
molal = self.engine.get_activity_coefficient(solution=self, solute=solute)
except ValueError:
logger.warning("Calculation unsuccessful. Returning unit activity coefficient.")
return unit.Quantity("1 dimensionless")
# if necessary, convert the activity coefficient to another scale, and return the result
if scale == "molal":
return molal
if scale == "molar":
total_molality = self.get_total_moles_solute() / self.solvent_mass
total_molarity = self.get_total_moles_solute() / self.volume
return (molal * self.water_substance.rho * unit.Quantity("1 g/L") * total_molality / total_molarity).to(
"dimensionless"
)
if scale == "rational":
return molal * (1 + unit.Quantity("0.018 kg/mol") * self.get_total_moles_solute() / self.solvent_mass)
logger.warning("Invalid scale argument. Returning molal-scale activity coefficient")
return molal
[docs] def get_activity(
self,
solute: str,
scale: Literal["molal", "molar", "rational"] = "molal",
verbose: bool = False,
):
"""
Return the thermodynamic activity of the solute in solution on the chosen concentration scale.
Args:
solute:
String representing the name of the solute of interest
scale:
The concentration scale for the returned activity.
Valid options are "molal", "molar", and "rational" (i.e., mole fraction).
By default, the molal scale activity is returned.
verbose:
If True, pyEQL will print a message indicating the parent salt
that is being used for activity calculations. This option is
useful when modeling multicomponent solutions. False by default.
Returns
The thermodynamic activity of the solute in question (dimensionless)
Notes:
The thermodynamic activity depends on the concentration scale used [rs]_ .
By default, the ionic strength, activity coefficients, and activities are all
calculated based on the molal (mol/kg) concentration scale.
References:
.. [rs] Robinson, R. A.; Stokes, R. H. Electrolyte Solutions: Second Revised
Edition; Butterworths: London, 1968, p.32.
See Also:
:py:meth:`get_activity_coefficient`
:attr:`ionic_strength`
:py:meth:`get_salt`
"""
# switch to the water activity function if the species is H2O
if solute == "H2O" or solute == "water":
activity = self.get_water_activity()
else:
# determine the concentration units to use based on the desired scale
if scale == "molal":
unit = "mol/kg"
elif scale == "molar":
unit = "mol/L"
elif scale == "rational":
unit = "fraction"
else:
logger.error("Invalid scale argument. Returning molal-scale activity.")
unit = "mol/kg"
scale = "molal"
activity = (
self.get_activity_coefficient(solute, scale=scale, verbose=verbose)
* self.get_amount(solute, unit).magnitude
)
logger.info(f"Calculated {scale} scale activity of solute {solute} as {activity}")
return activity
# TODO - engine method
[docs] def get_osmotic_coefficient(self, scale: Literal["molal", "molar", "rational"] = "molal"):
"""
Return the osmotic coefficient of an aqueous solution.
The method used depends on the Solution object's equation of state engine.
"""
molal_phi = self.engine.get_osmotic_coefficient(self)
if scale == "molal":
return molal_phi
if scale == "rational":
return (
-molal_phi
* unit.Quantity("0.018 kg/mol")
* self.get_total_moles_solute()
/ self.solvent_mass
/ math.log(self.get_amount(self.solvent, "fraction"))
)
if scale == "fugacity":
return math.exp(
-molal_phi * unit.Quantity("0.018 kg/mol") * self.get_total_moles_solute() / self.solvent_mass
- math.log(self.get_amount(self.solvent, "fraction"))
)
logger.warning("Invalid scale argument. Returning molal-scale osmotic coefficient")
return molal_phi
[docs] def get_water_activity(self):
"""
Return the water activity.
Returns
-------
Quantity :
The thermodynamic activity of water in the solution.
See Also
--------
:py:meth:`get_activity_coefficient`
:attr:`ionic_strength`
:py:meth:`get_salt`
Notes
-----
Water activity is related to the osmotic coefficient in a solution containing i solutes by:
.. math:: \\ln a_{w} = - \\Phi M_{w} \\sum_{i} m_{i}
Where :math:`M_{w}` is the molar mass of water (0.018015 kg/mol) and :math:`m_{i}` is the molal concentration
of each species.
If appropriate Pitzer model parameters are not available, the
water activity is assumed equal to the mole fraction of water.
References
----------
Blandamer, Mike J., Engberts, Jan B. F. N., Gleeson, Peter T., Reis, Joao Carlos R., 2005. "Activity of
water in aqueous systems: A frequently neglected property." *Chemical Society Review* 34, 440-458.
Examples:
--------
>>> s1 = pyEQL.Solution([['Na+','0.3 mol/kg'],['Cl-','0.3 mol/kg']])
>>> s1.get_water_activity()
<Quantity(0.9900944932888518, 'dimensionless')>
"""
"""
pseudo code
identify predominant salt for coefficients
check if coefficients exist for that salt
if so => calc osmotic coefficient and log an info message
if not = > return mole fraction and log a warning message
"""
osmotic_coefficient = self.get_osmotic_coefficient()
if osmotic_coefficient == 1:
logger.warning("Pitzer parameters not found. Water activity set equal to mole fraction")
return self.get_amount("H2O", "fraction")
concentration_sum = unit.Quantity("0 mol/kg")
for item in self.components:
if item == "H2O":
pass
else:
# TODO - use magnitude instead of quantity; add unit at end of loop
concentration_sum += self.get_amount(item, "mol/kg")
logger.info("Calculated water activity using osmotic coefficient")
return math.exp(-osmotic_coefficient * 0.018015 * unit.Quantity("kg/mol") * concentration_sum) * unit.Quantity(
"1 dimensionless"
)
[docs] def get_transport_number(self, solute, activity_correction=False):
"""Calculate the transport number of the solute in the solution.
Args:
solute : String identifying the solute for which the transport number is
to be calculated.
activity_correction: If True, the transport number will be corrected for activity following
the same method used for solution conductivity. Defaults to False if omitted.
Returns
The transport number of `solute`
Notes:
Transport number is calculated according to :
.. math::
t_i = {D_i z_i^2 C_i \\over \\sum D_i z_i^2 C_i}
Where :math:`C_i` is the concentration in mol/L, :math:`D_i` is the diffusion
coefficient, and :math:`z_i` is the charge, and the summation extends
over all species in the solution.
If `activity_correction` is True, the contribution of each ion to the
transport number is corrected with an activity factor. See the documentation
for Solution.conductivity for an explanation of this correction.
References:
Geise, G. M.; Cassady, H. J.; Paul, D. R.; Logan, E.; Hickner, M. A. "Specific
ion effects on membrane potential and the permselectivity of ion exchange membranes.""
*Phys. Chem. Chem. Phys.* 2014, 16, 21673-21681.
"""
denominator = unit.Quantity("0 mol / m / s")
numerator = unit.Quantity("0 mol / m / s")
for item in self.components:
z = self.get_property(item, "charge")
# neutral solutes do not contribute to transport number
if z == 0:
continue
term = self.get_property(item, "transport.diffusion_coefficient") * z**2 * self.get_amount(item, "mol/L")
if activity_correction is True:
gamma = self.get_activity_coefficient(item)
if self.ionic_strength.magnitude < 0.36 * z:
alpha = 0.6 / z**0.5
else:
alpha = self.ionic_strength.magnitude**0.5 / z
if item == solute:
numerator = term * gamma**alpha
denominator += term * gamma**alpha
else:
if item == solute:
numerator = term
denominator += term
return (numerator / denominator).to("dimensionless")
[docs] def get_molar_conductivity(self, solute):
"""
Calculate the molar (equivalent) conductivity for a solute.
Args:
solute: String identifying the solute for which the molar conductivity is
to be calculated.
Returns
The molar or equivalent conductivity of the species in the solution.
Zero if the solute is not charged.
Notes:
Molar conductivity is calculated from the Nernst-Einstein relation [smed]_
.. math::
\\kappa_i = {z_i^2 D_i F^2 \\over RT}
Note that the diffusion coefficient is strongly variable with temperature.
References:
.. [smed] Smedley, Stuart. The Interpretation of Ionic Conductivity in Liquids, pp 1-9. Plenum Press, 1980.
"""
D = self.get_property(solute, "transport.diffusion_coefficient")
if D is not None:
molar_cond = (
D * (unit.e * unit.N_A) ** 2 * self.get_property(solute, "charge") ** 2 / (unit.R * self.temperature)
)
else:
molar_cond = unit.Quantity("0 mS / cm / (mol/L)")
logger.info(f"Computed molar conductivity as {molar_cond} from D = {D!s} at T={self.temperature}")
return molar_cond.to("mS / cm / (mol/L)")
[docs] def get_mobility(self, solute):
"""
Calculate the ionic mobility of the solute.
Parameters
----------
solute : str
String identifying the solute for which the mobility is
to be calculated.
Returns
-------
float : The ionic mobility. Zero if the solute is not charged.
Notes
-----
This function uses the Einstein relation to convert a diffusion coefficient
into an ionic mobility [smed]_
.. math::
\\mu_i = {F |z_i| D_i \\over RT}
References
----------
.. [smed] Smedley, Stuart I. The Interpretation of Ionic Conductivity in Liquids. Plenum Press, 1980.
"""
D = self.get_property(solute, "transport.diffusion_coefficient")
mobility = unit.N_A * unit.e * abs(self.get_property(solute, "charge")) * D / (unit.R * self.temperature)
logger.info(f"Computed ionic mobility as {mobility} from D = {D!s} at T={self.temperature}")
return mobility.to("m**2/V/s")
[docs] def _get_property(self, solute: str, name: str) -> Optional[Quantity]:
"""Retrieve a thermodynamic property (such as diffusion coefficient)
for solute, and adjust it from the reference conditions to the conditions
of the solution.
Parameters
----------
solute: str
String representing the chemical formula of the solute species
name: str
The name of the property needed, e.g.
'diffusion coefficient'
Returns
-------
Quantity: The desired parameter or None if not found
"""
base_temperature = unit.Quantity("25 degC")
# base_pressure = unit.Quantity("1 atm")
# query the database using the sanitized formula
rform = Ion.from_formula(solute).reduced_formula
# TODO - there seems to be a bug in mongomock / JSONStore wherein properties does
# not properly return dot-notation fields, e.g. size.molar_volume will not be returned.
# also $exists:True does not properly return dot notated fields.
# for now, just set properties=[] to return everything
# data = list(self.database.query({"formula": rform, name: {"$ne": None}}, properties=["formula", name]))
data = list(self.database.query({"formula": rform, name: {"$ne": None}}))
# formulas should always be unique in the database. len==0 indicates no
# data. len>1 indicates duplicate data.
if len(data) == 0:
# try to determine basic properties using pymatgen
if name == "charge":
return Ion.from_formula(solute).charge
if name == "molecular_weight":
return f"{float(Ion.from_formula(solute).weight)} g/mol" # weight is a FloatWithUnit
logger.warning(f"Property {name} for solute {solute} not found in database. Returning None.")
return None
if len(data) > 1:
logger.warning(f"Duplicate database entries for solute {solute} found!")
data = data[0]
# perform temperature-corrections or other adjustments for certain
# parameter types
if name == "transport.diffusion_coefficient":
base_value = data["transport"]["diffusion_coefficient"]["value"]
# correct for temperature and viscosity
# .. math:: D_1 \over D_2 = T_1 \over T_2 * \mu_2 \over \mu_1
# where :math:`\mu` is the dynamic viscosity
# assume that the base viscosity is that of pure water
return (
unit.Quantity(base_value)
* self.temperature
/ base_temperature
* self.water_substance.mu
* unit.Quantity("1 Pa*s")
/ self.get_viscosity_dynamic()
)
# logger.warning("Diffusion coefficient not found for species %s. Assuming zero." % (solute))
# return unit.Quantity("0 m**2/s")
# just return the base-value molar volume for now; find a way to adjust for
# concentration later
if name == "size.molar_volume":
# calculate the partial molar volume for water since it isn't in the database
if rform == "H2O(aq)":
vol = (
unit.Quantity(self.get_property("H2O", "molecular_weight"))
/ self.water_substance.rho
* unit.Quantity("1 g/L")
)
return vol.to("cm **3 / mol")
base_value = unit.Quantity(data["size"]["molar_volume"]["value"])
if self.temperature != base_temperature:
logger.warning("Partial molar volume for species %s not corrected for temperature" % solute)
return base_value
if name == "model_parameters.dielectric_zuber":
return unit.Quantity(data["model_parameters"]["dielectric_zuber"]["value"])
if name == "model_parameters.activity_pitzer":
# return a dict
if data["model_parameters"]["activity_pitzer"].get("Beta0") is not None:
return data["model_parameters"]["activity_pitzer"]
return None
if name == "model_parameters.molar_volume_pitzer":
# return a dict
if data["model_parameters"]["molar_volume_pitzer"].get("Beta0") is not None:
return data["model_parameters"]["molar_volume_pitzer"]
return None
# for parameters not named above, just return the base value
val = data.get(name) if not isinstance(data.get(name), dict) else data[name].get("value")
# logger.warning("%s has not been corrected for solution conditions" % name)
if val is not None:
return unit.Quantity(val)
return None
[docs] def get_chemical_potential_energy(self, activity_correction=True):
"""
Return the total chemical potential energy of a solution (not including
pressure or electric effects).
Parameters
----------
activity_correction : bool, optional
If True, activities will be used to calculate the true chemical
potential. If False, mole fraction will be used, resulting in
a calculation of the ideal chemical potential.
Returns
-------
Quantity
The actual or ideal chemical potential energy of the solution, in Joules.
Notes
-----
The chemical potential energy (related to the Gibbs mixing energy) is
calculated as follows: [koga]_
.. math:: E = R T \\sum_i n_i \\ln a_i
or
.. math:: E = R T \\sum_i n_i \\ln x_i
Where :math:`n` is the number of moles of substance, :math:`T` is the temperature in kelvin,
:math:`R` the ideal gas constant, :math:`x` the mole fraction, and :math:`a` the activity of
each component.
Note that dissociated ions must be counted as separate components,
so a simple salt dissolved in water is a three component solution (cation,
anion, and water).
References
----------
.. [koga] Koga, Yoshikata, 2007. *Solution Thermodynamics and its Application to Aqueous Solutions: A differential approach.* Elsevier, 2007, pp. 23-37.
"""
E = unit.Quantity("0 J")
# loop through all the components and add their potential energy
for item in self.components:
try:
if activity_correction is True:
E += (
unit.R
* self.temperature.to("K")
* self.get_amount(item, "mol")
* math.log(self.get_activity(item))
)
else:
E += (
unit.R
* self.temperature.to("K")
* self.get_amount(item, "mol")
* math.log(self.get_amount(item, "fraction"))
)
# If we have a solute with zero concentration, we will get a ValueError
except ValueError:
continue
return E.to("J")
[docs] def get_lattice_distance(self, solute):
"""
Calculate the average distance between molecules.
Calculate the average distance between molecules of the given solute,
assuming that the molecules are uniformly distributed throughout the
solution.
Parameters
----------
solute : str
String representing the name of the solute of interest
Returns
-------
Quantity : The average distance between solute molecules
Examples
--------
>>> soln = Solution([['Na+','0.5 mol/kg'],['Cl-','0.5 mol/kg']])
>>> soln.get_lattice_distance('Na+')
1.492964.... nanometer
Notes
-----
The lattice distance is related to the molar concentration as follows:
.. math:: d = ( C_i N_A ) ^ {-{1 \\over 3}}
"""
# calculate the volume per particle as the reciprocal of the molar concentration
# (times avogadro's number). Take the cube root of the volume to get
# the average distance between molecules
distance = (self.get_amount(solute, "mol/L") * unit.N_A) ** (-1 / 3)
return distance.to("nm")
def _update_volume(self):
"""Recalculate the solution volume based on composition."""
self._volume = self._get_solvent_volume() + self._get_solute_volume()
def _get_solvent_volume(self):
"""Return the volume of the pure solvent."""
# calculate the volume of the pure solvent
solvent_vol = self.solvent_mass / (self.water_substance.rho * unit.Quantity("1 g/L"))
return solvent_vol.to("L")
def _get_solute_volume(self):
"""Return the volume of only the solutes."""
return self.engine.get_solute_volume(self)
[docs] def copy(self):
"""Return a copy of the solution.
TODO - clarify whether this is a deep or shallow copy
"""
# prepare to copy the bulk properties
new_temperature = str(self.temperature)
new_pressure = str(self.pressure)
new_solvent = self.solvent
new_solvent_mass = str(self.solvent_mass)
# create a list of solutes
new_solutes = []
for item in self.components:
# ignore the solvent
if item == self.solvent:
pass
else:
new_solutes.append([item, str(self.get_amount(item, "mol"))])
# create the new solution
return Solution(
new_solutes,
solvent=[new_solvent, new_solvent_mass],
temperature=new_temperature,
pressure=new_pressure,
)
[docs] def as_dict(self) -> dict:
"""
Convert the Solution into a dict representation that can be serialized to .json or other format.
"""
# clear the volume update flag, if required
if self.volume_update_required:
self._update_volume()
d = super().as_dict()
# replace solutes with the current composition
d["solutes"] = {k: v * unit.Quantity("1 mol") for k, v in self.components.items()}
# replace the engine with the associated str
d["engine"] = self._engine
return d
[docs] @classmethod
def from_dict(cls, d: dict) -> "Solution":
"""
Instantiate a Solution from a dictionary generated by as_dict().
"""
# because of the automatic volume updating that takes place during the __init__ process,
# care must be taken here to recover the exact quantities of solute and volume
# first we store the volume of the serialized solution
orig_volume = unit.Quantity(d["volume"])
# then instantiate a new one
new_sol = super().from_dict(d)
# now determine how different the new solution volume is from the original
scale_factor = (orig_volume / new_sol.volume).magnitude
# reset the new solution volume to that of the original. In the process of
# doing this, all the solute amounts are scaled by new_sol.volume / volume
new_sol.volume = str(orig_volume)
# undo the scaling by diving by that scale factor
for sol in new_sol.components:
new_sol.components[sol] /= scale_factor
# ensure that another volume update won't be triggered by these changes
# (this line should in principle be unnecessary, but it doesn't hurt anything)
new_sol.volume_update_required = False
return new_sol
# informational methods
[docs] def list_solutes(self):
"""List all the solutes in the solution."""
return list(self.components.keys())
[docs] def list_concentrations(self, unit="mol/kg", decimals=4, type="all"):
"""
List the concentration of each species in a solution.
Parameters
----------
unit: str
String representing the desired concentration unit.
decimals: int
The number of decimal places to display. Defaults to 4.
type : str
The type of component to be sorted. Defaults to 'all' for all
solutes. Other valid arguments are 'cations' and 'anions' which
return lists of cations and anions, respectively.
Returns
-------
dict
Dictionary containing a list of the species in solution paired with their amount in the specified units
"""
result_list = []
# populate a list with component names
if type == "all":
print("Component Concentrations:\n")
print("========================\n")
for item in self.components:
amount = self.get_amount(item, unit)
result_list.append([item, amount])
print(item + ":" + "\t {0:0.{decimals}f~}".format(amount, decimals=decimals))
elif type == "cations":
print("Cation Concentrations:\n")
print("========================\n")
for item in self.components:
if self.components[item].charge > 0:
amount = self.get_amount(item, unit)
result_list.append([item, amount])
print(item + ":" + "\t {0:0.{decimals}f~}".format(amount, decimals=decimals))
elif type == "anions":
print("Anion Concentrations:\n")
print("========================\n")
for item in self.components:
if self.components[item].charge < 0:
amount = self.get_amount(item, unit)
result_list.append([item, amount])
print(item + ":" + "\t {0:0.{decimals}f~}".format(amount, decimals=decimals))
return result_list
[docs] def list_salts(self, unit="mol/kg", decimals=4):
list = generate_salt_list(self, unit)
for item in list:
print(item.formula + "\t {:0.{decimals}f}".format(list[item], decimals=decimals))
[docs] def list_activities(self, decimals=4):
"""
List the activity of each species in a solution.
Parameters
----------
decimals: int
The number of decimal places to display. Defaults to 4.
Returns
-------
dict
Dictionary containing a list of the species in solution paired with their activity
"""
print("Component Activities:\n")
print("=====================\n")
for i in self.components:
print(i + ":" + "\t {0.magnitude:0.{decimals}f}".format(self.get_activity(i), decimals=decimals))
def __str__(self):
# set output of the print() statement for the solution
str1 = f"Volume: {self.volume:.3f~}\n"
str2 = f"Pressure: {self.pressure:.3f~}\n"
str3 = f"Temperature: {self.temperature:.3f~}\n"
str4 = f"Components: {self.list_solutes():}\n"
return str1 + str2 + str3 + str4
"""
Legacy methods to be deprecated in a future release.
"""
@deprecated(
message="get_solute() is deprecated and will be removed in the next release! Access solutes via the Solution.components attribute and their properties via Solution.get_property(solute, ...)"
)
def get_solute(self, i):
"""Return the specified solute object.
:meta private:
"""
return self.components[i]
@deprecated(
message="get_solvent is deprecated and will be removed in the next release! Use Solution.solvent instead."
)
def get_solvent(self):
"""Return the solvent object.
:meta private:
"""
return self.components[self.solvent]
@deprecated(
message="get_temperature() will be removed in the next release. Access the temperature directly via the property Solution.temperature"
)
def get_temperature(self):
"""
Return the temperature of the solution.
Parameters
----------
None
Returns
-------
Quantity: The temperature of the solution, in Kelvin.
:meta private:
"""
return self.temperature
@deprecated(
message="set_temperature() will be removed in the next release. Set the temperature directly via the property Solution.temperature"
)
def set_temperature(self, temperature):
"""
Set the solution temperature.
Parameters
----------
temperature : str
String representing the temperature, e.g. '25 degC'
:meta private:
"""
self.temperature = unit.Quantity(temperature)
# recalculate the volume
self._update_volume()
@deprecated(
message="get_pressure() will be removed in the next release. Access the pressure directly via the property Solution.pressure"
)
def get_pressure(self):
"""
Return the hydrostatic pressure of the solution.
Returns
-------
Quantity: The hydrostatic pressure of the solution, in atm.
:meta private:
"""
return self.pressure
@deprecated(
message="set_pressure() will be removed in the next release. Set the pressure directly via Solution.pressure"
)
def set_pressure(self, pressure):
"""
Set the hydrostatic pressure of the solution.
Parameters
----------
pressure : str
String representing the temperature, e.g. '25 degC'
:meta private:
"""
self._pressure = unit.Quantity(pressure)
@deprecated(
message="get_volume() will be removed in the next release. Access the volume directly via Solution.volume"
)
def get_volume(self):
""" """
return self.volume
@deprecated(
message="set_pressure() will be removed in the next release. Set the pressure directly via Solution.pressure"
)
def set_volume(self, volume: str):
""" """
self.volume = volume
@deprecated(message="get_mass() will be removed in the next release. Use the Solution.mass property instead.")
def get_mass(self):
"""
Return the total mass of the solution.
The mass is calculated each time this method is called.
Parameters
----------
None
Returns
-------
Quantity: the mass of the solution, in kg
:meta private:
"""
return self.mass
@deprecated(message="get_density() will be removed in the next release. Use the Solution.density property instead.")
def get_density(self):
"""
Return the density of the solution.
Density is calculated from the mass and volume each time this method is called.
Returns
-------
Quantity: The density of the solution.
:meta private:
"""
return self.density
@deprecated(message="get_viscosity_relative() will be removed in the next release.")
def get_viscosity_relative(self):
"""
Return the viscosity of the solution relative to that of water.
This is calculated using a simplified form of the Jones-Dole equation:
.. math:: \\eta_{rel} = 1 + \\sum_i B_i m_i
Where :math:`m` is the molal concentration and :math:`B` is an empirical parameter.
See
<http://www.nrcresearchpress.com/doi/pdf/10.1139/v77-148>
:meta private:
"""
# if self.ionic_strength.magnitude > 0.2:
# logger.warning('Viscosity calculation has limited accuracy above 0.2m')
# viscosity_rel = 1
# for item in self.components:
# # ignore water
# if item != 'H2O':
# # skip over solutes that don't have parameters
# try:
# conc = self.get_amount(item,'mol/kg').magnitude
# coefficients= self.get_property(item, 'jones_dole_viscosity')
# viscosity_rel += coefficients[0] * conc ** 0.5 + coefficients[1] * conc + \
# coefficients[2] * conc ** 2
# except TypeError:
# continue
return self.viscosity_dynamic / self.water_substance.mu * unit.Quantity("1 Pa*s")
@deprecated(
message="get_viscosity_dynamic() will be removed in the next release. Access directly via the property Solution.viscosity_dynamic."
)
def get_viscosity_dynamic(self):
"""
Return the dynamic (absolute) viscosity of the solution.
Calculated from the kinematic viscosity
See Also:
--------
get_viscosity_kinematic
:meta private:
"""
return self.viscosity_dynamic
@deprecated(
message="get_viscosity_kinematic() will be removed in the next release. Access directly via the property Solution.viscosity_kinematic."
)
def get_viscosity_kinematic(self):
"""
Return the kinematic viscosity of the solution.
Notes
-----
The calculation is based on a model derived from the Eyring equation
and presented by Vásquez-Castillo et al.
.. math::
\\ln \\nu = \\ln {\\nu_w MW_w \\over \\sum_i x_i MW_i } +
15 x_+^2 + x_+^3 \\delta G^*_{123} + 3 x_+ \\delta G^*_{23} (1-0.05x_+)
Where:
.. math:: \\delta G^*_{123} = a_o + a_1 (T)^{0.75}
.. math:: \\delta G^*_{23} = b_o + b_1 (T)^{0.5}
In which :math:`\\nu` is the kinematic viscosity, MW is the molecular weight,
`x_+` is the mole fraction of cations, and T is the temperature in degrees C.
The a and b fitting parameters for a variety of common salts are included in the
database.
References
----------
Vásquez-Castillo, G.; Iglesias-Silva, G. a.; Hall, K. R. An extension
of the McAllister model to correlate kinematic viscosity of electrolyte solutions.
Fluid Phase Equilib. 2013, 358, 44-49.
See Also:
--------
viscosity_dynamic
:meta private:
"""
return self.viscosity_kinematic
@deprecated(
message="get_conductivity() will be removed in the next release. Access directly via the property Solution.conductivity."
)
def get_conductivity(self):
"""
Compute the electrical conductivity of the solution.
Parameters
----------
None
Returns
-------
Quantity
The electrical conductivity of the solution in Siemens / meter.
Notes
-----
Conductivity is calculated by summing the molar conductivities of the respective
solutes, but they are activity-corrected and adjusted using an empricial exponent.
This approach is used in PHREEQC and Aqion models [#]_ [#]_
.. math::
EC = {F^2 \\over R T} \\sum_i D_i z_i ^ 2 \\gamma_i ^ {\\alpha} m_i
Where:
.. math::
\\alpha = \\begin{cases} {0.6 \\over \\sqrt{|z_i|}} & {I < 0.36|z_i|} \\ {\\sqrt{I} \\over |z_i|} & otherwise \\end{cases}
Note: PHREEQC uses the molal rather than molar concentration according to
http://wwwbrr.cr.usgs.gov/projects/GWC_coupled/phreeqc/phreeqc3-html/phreeqc3-43.htm
References
----------
.. [#] https://www.aqion.de/site/electrical-conductivity
.. [#] http://www.hydrochemistry.eu/exmpls/sc.html
See Also:
--------
ionic_strength
get_molar_conductivity()
get_activity_coefficient()
:meta private:
"""
return self.conductivity
@deprecated(
replacement=get_amount,
message="get_mole_fraction() will be removed in the next release. Use get_amount() with units='fraction' instead.",
)
def get_mole_fraction(self, solute):
"""
Return the mole fraction of 'solute' in the solution.
Notes
-----
This function is DEPRECATED.
Use get_amount() instead and specify 'fraction' as the unit type.
:meta private:
"""
@deprecated(
message="get_ionic_strength() will be removed in the next release. Access directly via the property Solution.ionic_strength"
)
def get_ionic_strength(self):
"""
Return the ionic strength of the solution.
Return the ionic strength of the solution, calculated as 1/2 * sum ( molality * charge ^2) over all the ions.
Molal (mol/kg) scale concentrations are used for compatibility with the activity correction formulas.
Returns
-------
Quantity :
The ionic strength of the parent solution, mol/kg.
See Also:
--------
get_activity
get_water_activity
Notes
-----
The ionic strength is calculated according to:
.. math:: I = \\sum_i m_i z_i^2
Where :math:`m_i` is the molal concentration and :math:`z_i` is the charge on species i.
Examples:
--------
>>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
>>> s1.ionic_strength
<Quantity(0.20000010029672785, 'mole / kilogram')>
>>> s1 = pyEQL.Solution([['Mg+2','0.3 mol/kg'],['Na+','0.1 mol/kg'],['Cl-','0.7 mol/kg']],temperature='30 degC')
>>> s1.ionic_strength
<Quantity(1.0000001004383303, 'mole / kilogram')>
:meta private:
"""
return self.ionic_strength
@deprecated(
message="get_charge_balance() will be removed in the next release. Access directly via the property Solution.charge_balance"
)
def get_charge_balance(self):
"""
Return the charge balance of the solution.
Return the charge balance of the solution. The charge balance represents the net electric charge
on the solution and SHOULD equal zero at all times, but due to numerical errors will usually
have a small nonzero value.
Returns
-------
float :
The charge balance of the solution, in equivalents.
Notes
-----
The charge balance is calculated according to:
.. math:: CB = F \\sum_i n_i z_i
Where :math:`n_i` is the number of moles, :math:`z_i` is the charge on species i, and :math:`F` is the Faraday constant.
:meta private:
"""
return self.charge_balance
@deprecated(
message="get_alkalinity() will be removed in the next release. Access directly via the property Solution.alkalinity"
)
def get_alkalinity(self):
"""
Return the alkalinity or acid neutralizing capacity of a solution.
Returns
-------
Quantity :
The alkalinity of the solution in mg/L as CaCO3
Notes
-----
The alkalinity is calculated according to:
.. math:: Alk = F \\sum_i z_i C_B - \\sum_i z_i C_A
Where :math:`C_B` and :math:`C_A` are conservative cations and anions, respectively
(i.e. ions that do not participate in acid-base reactions), and :math:`z_i` is their charge.
In this method, the set of conservative cations is all Group I and Group II cations, and the conservative anions are all the anions of strong acids.
References
----------
Stumm, Werner and Morgan, James J. Aquatic Chemistry, 3rd ed,
pp 165. Wiley Interscience, 1996.
:meta private:
"""
return self.alkalinity
@deprecated(
message="get_hardness() will be removed in the next release. Access directly via the property Solution.hardness"
)
def get_hardness(self):
"""
Return the hardness of a solution.
Hardness is defined as the sum of the equivalent concentrations
of multivalent cations as calcium carbonate.
NOTE: at present pyEQL cannot distinguish between mg/L as CaCO3
and mg/L units. Use with caution.
Parameters
----------
None
Returns
-------
Quantity
The hardness of the solution in mg/L as CaCO3
:meta private:
"""
return self.hardness
@deprecated(
message="get_debye_length() will be removed in the next release. Access directly via the property Solution.debye_length"
)
def get_debye_length(self):
"""
Return the Debye length of a solution.
Debye length is calculated as
.. math::
\\kappa^{-1} = \\sqrt({\\epsilon_r \\epsilon_o k_B T \\over (2 N_A e^2 I)})
where :math:`I` is the ionic strength, :math:`epsilon_r` and :math:`epsilon_r`
are the relative permittivity and vacuum permittivity, :math:`k_B` is the
Boltzmann constant, and :math:`T` is the temperature, :math:`e` is the
elementary charge, and :math:`N_A` is Avogadro's number.
Parameters
----------
None
Returns
-------
Quantity
The Debye length, in nanometers.
References
----------
https://en.wikipedia.org/wiki/Debye_length#Debye_length_in_an_electrolyte
See Also:
--------
ionic_strength
get_dielectric_constant()
:meta private:
"""
return self.debye_length
@deprecated(
message="get_bjerrum_length() will be removed in the next release. Access directly via the property Solution.bjerrum_length"
)
def get_bjerrum_length(self):
"""
Return the Bjerrum length of a solution.
Bjerrum length represents the distance at which electrostatic
interactions between particles become comparable in magnitude
to the thermal energy.:math:`\\lambda_B` is calculated as
.. math::
\\lambda_B = {e^2 \\over (4 \\pi \\epsilon_r \\epsilon_o k_B T)}
where :math:`e` is the fundamental charge, :math:`epsilon_r` and :math:`epsilon_r`
are the relative permittivity and vacuum permittivity, :math:`k_B` is the
Boltzmann constant, and :math:`T` is the temperature.
Parameters
----------
None
Returns
-------
Quantity
The Bjerrum length, in nanometers.
References
----------
https://en.wikipedia.org/wiki/Bjerrum_length
Examples:
--------
>>> s1 = pyEQL.Solution()
>>> s1.get_bjerrum_length()
<Quantity(0.7152793009386953, 'nanometer')>
See Also:
--------
get_dielectric_constant()
:meta private:
"""
return self.bjerrum_length
@deprecated(
message="get_dielectric_constant() will be removed in the next release. Access directly via the property Solution.dielectric_constant"
)
def get_dielectric_constant(self):
"""
Returns the dielectric constant of the solution.
Parameters
----------
None
Returns
-------
Quantity: the dielectric constant of the solution, dimensionless.
Notes
-----
Implements the following equation as given by [zub]_
.. math:: \\epsilon = \\epsilon_solvent \\over 1 + \\sum_i \\alpha_i x_i
where :math:`\\alpha_i` is a coefficient specific to the solvent and ion, and :math:`x_i`
is the mole fraction of the ion in solution.
References
----------
.. [zub] A. Zuber, L. Cardozo-Filho, V.F. Cabral, R.F. Checoni, M. Castier,
An empirical equation for the dielectric constant in aqueous and nonaqueous
electrolyte mixtures, Fluid Phase Equilib. 376 (2014) 116-123.
doi:10.1016/j.fluid.2014.05.037.
:meta private:
"""
return self.dielectric_constant
@deprecated(
message="solvent_mass will be removed in the next release. Access directly via the property Solution.solvent_mass"
)
def get_solvent_mass(self):
"""
Return the mass of the solvent.
This method is used whenever mol/kg (or similar) concentrations
are requested by get_amount()
Parameters
----------
None
Returns
-------
Quantity: the mass of the solvent, in kg
See Also
--------
:py:meth:`get_amount()`
:meta private:
"""
return self.solvent_mass