-
Notifications
You must be signed in to change notification settings - Fork 15
/
solution.py
2763 lines (2269 loc) · 113 KB
/
solution.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
pyEQL Solution Class.
:copyright: 2013-2024 by Ryan S. Kingsbury
:license: LGPL, see LICENSE for more details.
"""
from __future__ import annotations
import logging
import math
import os
import warnings
from functools import lru_cache
from importlib.resources import files
from pathlib import Path
from typing import Any, Literal
import numpy as np
from maggma.stores import JSONStore, Store
from monty.dev import deprecated
from monty.json import MontyDecoder, MSONable
from monty.serialization import dumpfn, loadfn
from pint import DimensionalityError, Quantity
from pymatgen.core import Element
from pymatgen.core.ion import Ion
from pyEQL import IonDB, ureg
from pyEQL.activity_correction import _debye_parameter_activity, _debye_parameter_B
from pyEQL.engines import EOS, IdealEOS, NativeEOS, PhreeqcEOS
from pyEQL.salt_ion_match import Salt
from pyEQL.solute import Solute
from pyEQL.utils import FormulaDict, create_water_substance, interpret_units, standardize_formula
EQUIV_WT_CACO3 = ureg.Quantity(100.09 / 2, "g/mol")
# string to denote unknown oxidation states
UNKNOWN_OXI_STATE = "unk"
class Solution(MSONable):
"""
Class representing the properties of a solution. Instances of this class
contain information about the solutes, solvent, and bulk properties.
"""
def __init__(
self,
solutes: list[list[str]] | dict[str, str] | None = None,
volume: str | None = None,
temperature: str = "298.15 K",
pressure: str = "1 atm",
pH: float = 7,
pE: float = 8.5,
balance_charge: str | None = None,
solvent: str | list = "H2O",
engine: EOS | Literal["native", "ideal", "phreeqc"] = "native",
database: str | Path | Store | None = None,
default_diffusion_coeff: float = 1.6106e-9,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = "ERROR",
):
"""
Instantiate a Solution from a composition.
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 ureg. 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.
balance_charge: The strategy for balancing charge during init and equilibrium calculations. Valid options
are 'pH', which will adjust the solution pH to balance charge, 'pE' which will adjust the
redox equilibrium to balance charge, or the name of a dissolved species e.g. 'Ca+2' or 'Cl-'
that will be added/subtracted to balance charge. If set to None, no charge balancing will be
performed either on init or when equilibrate() is called. Note that in this case, equilibrate()
can distort the charge balance!
solvent: Formula of the solvent. Solvents other than water are not supported at this time.
engine: Electrolyte modeling engine to use. See documentation for details on the available engines.
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.
log_level: Log messages of this or higher severity will be printed to stdout. Defaults to 'ERROR', meaning
that ERROR and CRITICAL messages will be shown, while WARNING, INFO, and DEBUG messages are not. If set to None, nothing will be printed.
default_diffusion_coeff: Diffusion coefficient value in m^2/s to use in
calculations when there is no diffusion coefficient for a species in the database. This affects several
important property calculations including conductivity and transport number, which are related to the
weighted sums of diffusion coefficients of all species. Setting this argument to zero will exclude any
species that does not have a tabulated diffusion coefficient from these calculations, possibly resulting
in underestimation of the conductivity and/or inaccurate transport numbers.
Missing diffusion coefficients are especially likely in complex electrolytes containing, for example,
complexes or paired species such as NaSO4[-1]. In such cases, setting default_diffusion_coeff to zero
is likely to result in the above errors.
By default, this argument is set to the diffusion coefficient of NaCl salt, 1.61x10^-9 m2/s.
Examples:
>>> s1 = pyEQL.Solution({'Na+': '1 mol/L','Cl-': '1 mol/L'},temperature='20 degC',volume='500 mL')
>>> print(s1)
Components:
Volume: 0.500 l
Pressure: 1.000 atm
Temperature: 293.150 K
Components: ['H2O(aq)', 'H[+1]', 'OH[-1]', 'Na[+1]', 'Cl[-1]']
"""
# create a logger and attach it to this class
self.log_level = log_level.upper()
self.logger = logging.getLogger("pyEQL")
if self.log_level is not None:
# set the level of the module logger
self.logger.setLevel(self.log_level)
# clear handlers and add a StreamHandler
self.logger.handlers.clear()
# use rich for pretty log formatting, if installed
try:
from rich.logging import RichHandler
sh = RichHandler(rich_tracebacks=True)
except ImportError:
sh = logging.StreamHandler()
# the formatter determines what our logs will look like
formatter = logging.Formatter("[%(asctime)s] [%(levelname)8s] --- %(message)s (%(filename)s:%(lineno)d)")
sh.setFormatter(formatter)
self.logger.addHandler(sh)
# per-instance cache of get_property and other calls that do not depend
# on composition
# see https://rednafi.com/python/lru_cache_on_methods/
self.get_property = lru_cache()(self._get_property)
self.get_molar_conductivity = lru_cache()(self._get_molar_conductivity)
self.get_mobility = lru_cache()(self._get_mobility)
self.default_diffusion_coeff = default_diffusion_coeff
self.get_diffusion_coefficient = lru_cache()(self._get_diffusion_coefficient)
# 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 = ureg.Quantity(volume).to("L")
else:
# volume_set = False
self._volume = ureg.Quantity(1, "L")
# store the initial conditions as private variables in case they are
# changed later
self._temperature = ureg.Quantity(temperature)
self._pressure = ureg.Quantity(pressure)
self._pE = pE
self._pH = pH
self.pE = self._pE
if isinstance(balance_charge, str) and balance_charge not in ["pH", "pE"]:
self.balance_charge = standardize_formula(balance_charge)
else:
self.balance_charge = balance_charge #: Standardized formula of the species used for charge balancing.
# instantiate a water substance for property retrieval
self.water_substance = create_water_substance(self.temperature, self.pressure)
"""IAPWS instance describing water properties."""
# create an empty dictionary of components. This dict comprises {formula: moles}
# where moles is the number of moles in the solution.
self.components = FormulaDict({})
"""Special dictionary where keys are standardized formula and values are the moles present in Solution."""
# connect to the desired property database
if database is None:
# load the default database, which is a JSONStore
db_store = IonDB
elif isinstance(database, (str, Path)):
db_store = JSONStore(str(database), key="formula")
self.logger.debug(f"Created maggma JSONStore from .json file {database}")
else:
db_store = database
self.database = db_store
"""`Store` instance containing the solute property database."""
self.database.connect()
self.logger.debug(f"Connected to property database {self.database!s}")
# set the equation of state engine
self._engine = engine
# self.engine: Optional[EOS] = None
if isinstance(self._engine, EOS):
self.engine: EOS = self._engine
elif self._engine == "ideal":
self.engine = IdealEOS()
elif self._engine == "native":
self.engine = NativeEOS()
elif self._engine == "phreeqc":
self.engine = PhreeqcEOS()
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 = standardize_formula(solvent[0])
"""Formula of the component that is set as the solvent (currently only H2O(aq) is supported)."""
# 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:
# self.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.magnitude / 55.55 # molarity of pure water
self.components["H2O"] = moles
# 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):
msg = (
'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.)'
)
self.logger.warning(msg)
warnings.warn(msg, DeprecationWarning)
for item in self._solutes:
self.add_solute(*item)
# adjust the charge balance, if necessary
cb = self.charge_balance
if not np.isclose(cb, 0, atol=1e-8) and self.balance_charge is not None:
balanced = False
self.logger.info(
f"Solution is not electroneutral (C.B. = {cb} eq/L). Adding {balance_charge} to compensate."
)
if self.balance_charge == "pH":
self.components["H+"] += (
-1 * cb * self.volume.to("L").magnitude
) # if C.B. is negative, we need to add cations. H+ is 1 eq/mol
balanced = True
elif self.balance_charge == "pE":
raise NotImplementedError("Balancing charge via redox (pE) is not yet implemented!")
else:
ions = set().union(*[self.cations, self.anions]) # all ions
if self.balance_charge not in ions:
raise ValueError(
f"Charge balancing species {self.balance_charge} was not found in the solution!. "
f"Species {ions} were found."
)
z = self.get_property(balance_charge, "charge")
self.components[balance_charge] += -1 * cb / z * self.volume.to("L").magnitude
balanced = True
if not balanced:
warnings.warn(f"Unable to balance charge using species {self.balance_charge}")
@property
def mass(self) -> Quantity:
"""
Return the total mass of the solution.
The mass is calculated each time this method is called.
Returns: The mass of the solution, in kg
"""
mass = np.sum([self.get_amount(item, "kg").magnitude for item in self.components])
return ureg.Quantity(mass, "kg")
@property
def solvent_mass(self) -> Quantity:
"""
Return the mass of the solvent.
This property is used whenever mol/kg (or similar) concentrations
are requested by get_amount()
Returns:
The mass of the solvent, in kg
See Also:
:py:meth:`get_amount()`
"""
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 = ureg.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 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 = ureg.Quantity(temperature)
# update the water substance
self.water_substance = create_water_substance(self.temperature, self.pressure)
# recalculate the volume
self.volume_update_required = True
# clear any cached solute properties that may depend on temperature
self.get_property.cache_clear()
self.get_molar_conductivity.cache_clear()
self.get_mobility.cache_clear()
self.get_diffusion_coefficient.cache_clear()
@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 = ureg.Quantity(pressure)
# update the water substance
self.water_substance = create_water_substance(self.temperature, self.pressure)
# recalculate the volume
self.volume_update_required = True
@property
def pH(self) -> float | None:
"""Return the pH of the solution."""
return self.p("H+", activity=False)
def p(self, solute: str, activity=True) -> float | None:
"""
Return the negative log of the activity of solute.
Generally used for expressing concentration of hydrogen ions (pH)
Args:
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.
"""
try:
if activity is True:
return -1 * math.log10(self.get_activity(solute))
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
@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:
r"""
Returns the dielectric constant of the solution.
Args:
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(aq)":
# 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:
# self.logger.warning("No dielectric parameters found for species %s." % item)
# continue
return ureg.Quantity(di_water / denominator, "dimensionless")
@property
def chemical_system(self) -> str:
"""
Return the chemical system of the Solution as a "-" separated list of elements, sorted alphabetically. For
example, a solution containing CaCO3 would have a chemical system of "C-Ca-H-O".
"""
return "-".join(self.elements)
@property
def elements(self) -> list:
"""
Return a list of elements that are present in the solution.
For example, a solution containing CaCO3 would return ["C", "Ca", "H", "O"]
"""
els = []
for s in self.components:
els.extend(self.get_property(s, "elements"))
return sorted(set(els))
@property
def cations(self) -> dict[str, float]:
"""
Returns the subset of `components` that are cations.
The returned dict is sorted by amount in descending order.
"""
return {k: v for k, v in self.components.items() if self.get_property(k, "charge") > 0}
@property
def anions(self) -> dict[str, float]:
"""
Returns the subset of `components` that are anions.
The returned dict is sorted by amount in descending order.
"""
return {k: v for k, v in self.components.items() if self.get_property(k, "charge") < 0}
@property
def neutrals(self) -> dict[str, float]:
"""
Returns the subset of `components` that are neutral (not charged).
The returned dict is sorted by amount in descending order.
"""
return {k: v for k, v in self.components.items() if self.get_property(k, "charge") == 0}
# 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:
:attr:`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:
# self.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 * ureg.Quantity("1 Pa*s")
# )
@property
def viscosity_kinematic(self) -> Quantity:
r"""
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()
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 = ureg.Quantity(params["a0"]["value"]).magnitude
a1 = ureg.Quantity(params["a1"]["value"]).magnitude
b0 = ureg.Quantity(params["b0"]["value"]).magnitude
b1 = ureg.Quantity(params["b1"]["value"]).magnitude
# 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
else:
# TODO - fall back to the Jones-Dole model! There are currently no eyring parameters in the database!
# proceed with the coefficients equal to zero and log a warning
self.logger.warning(f"Viscosity coefficients for {salt.formula} not found. Viscosity will be approximate.")
G_123 = G_23 = 0
# 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
total_moles = np.sum([v for k, v in self.components.items()])
MW = self.mass.to("g").magnitude / total_moles
# get the MW of water
MW_w = self.get_property(self.solvent, "molecular_weight").magnitude
# calculate the cation mole fraction
# x_cat = self.get_amount(cation, "fraction")
x_cat = self.get_amount(salt.cation, "fraction").magnitude
# 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 ureg.Quantity(np.exp(nu), "m**2 / s")
@property
def conductivity(self) -> Quantity:
r"""
Compute the electrical conductivity of the solution.
Returns:
The electrical conductivity of the solution in Siemens / meter.
Notes:
Conductivity is calculated by summing the molar conductivities of the respective
solutes.
.. math::
EC = {F^2 \over R T} \sum_i D_i z_i ^ 2 m_i = \sum_i \lambda_i m_i
Where :math:`D_i` is the diffusion coefficient, :math:`m_i` is the molal concentration,
:math:`z_i` is the charge, and the summation extends over all species in the solution.
Alternatively, :math:`\lambda_i` is the molar conductivity of solute i.
Diffusion coefficients :math:`D_i` (and molar conductivities :math:`\lambda_i`) are
adjusted for the effects of temperature and ionic strength using the method implemented
in PHREEQC >= 3.4. [aq]_ [hc]_ See `get_diffusion_coefficient for` further details.
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_diffusion_coefficient`
:py:meth:`get_molar_conductivity`
"""
EC = ureg.Quantity(
np.asarray(
[
self.get_molar_conductivity(i).to("S*L/mol/m").magnitude * self.get_amount(i, "mol/L").magnitude
for i in self.components
]
),
"S/m",
)
return np.sum(EC)
@property
def ionic_strength(self) -> Quantity:
r"""
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')>
"""
# compute using magnitudes only, for performance reasons
ionic_strength = np.sum(
[mol * self.get_property(solute, "charge") ** 2 for solute, mol in self.components.items()]
)
ionic_strength /= self.solvent_mass.to("kg").magnitude # convert to mol/kg
ionic_strength *= 0.5
return ureg.Quantity(ionic_strength, "mol/kg")
@property
def charge_balance(self) -> float:
r"""
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 = \sum_i C_i z_i
where :math:`C_i` is the molar concentration, and :math:`z_i` is the charge on species i.
Returns:
float :
The charge balance of the solution, in equivalents (mol of charge) per L.
"""
charge_balance = 0
for solute in self.components:
charge_balance += self.get_amount(solute, "eq/L").magnitude
return charge_balance
# TODO - consider adding guard statements to prevent alkalinity from being negative
@property
def alkalinity(self) -> Quantity:
r"""
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 = \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 signed 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 = ureg.Quantity(0, "mol/L")
base_cations = {
"Li[+1]",
"Na[+1]",
"K[+1]",
"Rb[+1]",
"Cs[+1]",
"Fr[+1]",
"Be[+2]",
"Mg[+2]",
"Ca[+2]",
"Sr[+2]",
"Ba[+2]",
"Ra[+2]",
}
acid_anions = {"Cl[-1]", "Br[-1]", "I[-1]", "SO4[-2]", "NO3[-1]", "ClO4[-1]", "ClO3[-1]"}
for item in self.components:
if item in base_cations.union(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) -> Quantity:
"""
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.
Returns:
Quantity:
The hardness of the solution in mg/L as CaCO3
"""
hardness = ureg.Quantity(0, "mol/L")
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 total_dissolved_solids(self) -> Quantity:
"""
Total dissolved solids in mg/L (equivalent to ppm) including both charged and uncharged species.
The TDS is defined as the sum of the concentrations of all aqueous solutes (not including the solvent),
except for H[+1] and OH[-1]].
"""
tds = ureg.Quantity(0, "mg/L")
for s in self.components:
# ignore pure water and dissolved gases, but not CO2
if s in ["H2O(aq)", "H[+1]", "OH[-1]"]:
continue
tds += self.get_amount(s, "mg/L")
return tds
@property
def TDS(self) -> Quantity:
"""Alias of :py:meth:`total_dissolved_solids`."""
return self.total_dissolved_solids
@property
def debye_length(self) -> Quantity:
r"""
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 = ureg.Quantity(self.ionic_strength.magnitude, "mol/L")
dielectric_constant = self.dielectric_constant
debye_length = (
dielectric_constant
* ureg.epsilon_0
* ureg.k
* self.temperature
/ (2 * ureg.N_A * ureg.e**2 * ionic_strength)
) ** 0.5
return debye_length.to("nm")
@property
def bjerrum_length(self) -> Quantity:
r"""
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.
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 = ureg.e**2 / (
4 * math.pi * self.dielectric_constant * ureg.epsilon_0 * ureg.k * self.temperature
)
return bjerrum_length.to("nm")
@property
def osmotic_pressure(self) -> Quantity:
r"""
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:
:attr:`get_water_activity`
:attr:`get_osmotic_coefficient`
:attr:`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.osmotic_pressure
<Quantity(0.495791416, 'pascal')>
>>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
>>> soln.osmotic_pressure
<Quantity(906516.7318131207, 'pascal')>
"""
partial_molar_volume_water = self.get_property(self.solvent, "size.molar_volume")
osmotic_pressure = (
-1 * ureg.R * self.temperature / partial_molar_volume_water * math.log(self.get_water_activity())
)
self.logger.debug(
f"Calculated osmotic pressure of solution as {osmotic_pressure} Pa at T= {self.temperature} degrees C"
)
return osmotic_pressure.to("Pa")
# Concentration Methods
def get_amount(self, solute: str, units: str = "mol/L") -> Quantity:
"""
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', 'M')
2. equivalents (i.e., moles of charge) per volume (e.g., 'eq/L', 'meq/L')
3. substance per mass of solvent (e.g., 'mol/kg', 'm')
4. mass of substance (e.g., 'kg')
5. moles of substance ('mol')
6. mole fraction ('fraction')
7. percent by weight (%)
8. number of molecules ('count')
9. "parts-per-x" units, where ppm = mg/L, ppb = ug/L ppt = ng/L