pyEQL 1.4.0rc9__cp313-cp313-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (491) hide show
  1. pyEQL/__init__.py +50 -0
  2. pyEQL/_phreeqc.cpython-313-darwin.so +0 -0
  3. pyEQL/activity_correction.py +879 -0
  4. pyEQL/database/geothermal.dat +5693 -0
  5. pyEQL/database/llnl.dat +19305 -0
  6. pyEQL/database/phreeqc_license.txt +54 -0
  7. pyEQL/database/pyeql_db.json +35607 -0
  8. pyEQL/engines.py +1153 -0
  9. pyEQL/equilibrium.py +227 -0
  10. pyEQL/functions.py +281 -0
  11. pyEQL/phreeqc/__init__.py +5 -0
  12. pyEQL/phreeqc/bindings.cpp +84 -0
  13. pyEQL/phreeqc/core.py +239 -0
  14. pyEQL/phreeqc/database/Amm.dat +1968 -0
  15. pyEQL/phreeqc/database/CMakeLists.txt +32 -0
  16. pyEQL/phreeqc/database/ColdChem.dat +267 -0
  17. pyEQL/phreeqc/database/Concrete_PHR.dat +158 -0
  18. pyEQL/phreeqc/database/Concrete_PZ.dat +195 -0
  19. pyEQL/phreeqc/database/Kinec.v2.dat +12039 -0
  20. pyEQL/phreeqc/database/Kinec_v3.dat +12159 -0
  21. pyEQL/phreeqc/database/Makefile.am +28 -0
  22. pyEQL/phreeqc/database/Makefile.in +530 -0
  23. pyEQL/phreeqc/database/PHREEQC_ThermoddemV1.10_15Dec2020.dat +12965 -0
  24. pyEQL/phreeqc/database/Tipping_Hurley.dat +4137 -0
  25. pyEQL/phreeqc/database/__init__.py +0 -0
  26. pyEQL/phreeqc/database/core10.dat +6824 -0
  27. pyEQL/phreeqc/database/frezchem.dat +634 -0
  28. pyEQL/phreeqc/database/iso.dat +7235 -0
  29. pyEQL/phreeqc/database/llnl.dat +19310 -0
  30. pyEQL/phreeqc/database/minteq.dat +5654 -0
  31. pyEQL/phreeqc/database/minteq.v4.dat +13212 -0
  32. pyEQL/phreeqc/database/phreeqc.dat +1972 -0
  33. pyEQL/phreeqc/database/phreeqc_rates.dat +3158 -0
  34. pyEQL/phreeqc/database/pitzer.dat +1044 -0
  35. pyEQL/phreeqc/database/sit.dat +14348 -0
  36. pyEQL/phreeqc/database/wateq4f.dat +4036 -0
  37. pyEQL/phreeqc/ext/README.md +10 -0
  38. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/CMakeLists.txt +476 -0
  39. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/INSTALL +302 -0
  40. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/IPhreeqc.rc +61 -0
  41. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/IPhreeqcConfig.cmake.in +4 -0
  42. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/Makefile.am +8 -0
  43. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/Makefile.in +816 -0
  44. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/aclocal.m4 +1217 -0
  45. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/CTestScript.cmake +167 -0
  46. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/CSelectedOutput.cpp.o +0 -0
  47. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/IPhreeqc.cpp.o +0 -0
  48. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/IPhreeqcLib.cpp.o +0 -0
  49. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/IPhreeqc_interface_F.cpp.o +0 -0
  50. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/Var.c.o +0 -0
  51. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Dictionary.cpp.o +0 -0
  52. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/ExchComp.cxx.o +0 -0
  53. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Exchange.cxx.o +0 -0
  54. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/GasComp.cxx.o +0 -0
  55. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/GasPhase.cxx.o +0 -0
  56. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/ISolution.cxx.o +0 -0
  57. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/ISolutionComp.cxx.o +0 -0
  58. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/KineticsComp.cxx.o +0 -0
  59. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/NameDouble.cxx.o +0 -0
  60. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/NumKeyword.cxx.o +0 -0
  61. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/PBasic.cpp.o +0 -0
  62. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/PHRQ_io_output.cpp.o +0 -0
  63. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/PPassemblage.cxx.o +0 -0
  64. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/PPassemblageComp.cxx.o +0 -0
  65. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Phreeqc.cpp.o +0 -0
  66. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/PhreeqcKeywords/Keywords.cpp.o +0 -0
  67. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Pressure.cxx.o +0 -0
  68. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Reaction.cxx.o +0 -0
  69. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/ReadClass.cxx.o +0 -0
  70. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/SS.cxx.o +0 -0
  71. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/SSassemblage.cxx.o +0 -0
  72. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/SScomp.cxx.o +0 -0
  73. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/SelectedOutput.cpp.o +0 -0
  74. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Serializer.cxx.o +0 -0
  75. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Solution.cxx.o +0 -0
  76. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/SolutionIsotope.cxx.o +0 -0
  77. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/StorageBin.cxx.o +0 -0
  78. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/StorageBinList.cpp.o +0 -0
  79. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Surface.cxx.o +0 -0
  80. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/SurfaceCharge.cxx.o +0 -0
  81. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/SurfaceComp.cxx.o +0 -0
  82. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/System.cxx.o +0 -0
  83. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Temperature.cxx.o +0 -0
  84. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/Use.cpp.o +0 -0
  85. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/UserPunch.cpp.o +0 -0
  86. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/advection.cpp.o +0 -0
  87. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/basicsubs.cpp.o +0 -0
  88. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/cl1.cpp.o +0 -0
  89. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/common/PHRQ_base.cxx.o +0 -0
  90. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/common/PHRQ_io.cpp.o +0 -0
  91. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/common/Parser.cxx.o +0 -0
  92. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/common/Utils.cxx.o +0 -0
  93. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/cvdense.cpp.o +0 -0
  94. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/cvode.cpp.o +0 -0
  95. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/cxxKinetics.cxx.o +0 -0
  96. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/cxxMix.cxx.o +0 -0
  97. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/dense.cpp.o +0 -0
  98. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/dumper.cpp.o +0 -0
  99. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/gases.cpp.o +0 -0
  100. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/input.cpp.o +0 -0
  101. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/integrate.cpp.o +0 -0
  102. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/inverse.cpp.o +0 -0
  103. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/isotopes.cpp.o +0 -0
  104. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/kinetics.cpp.o +0 -0
  105. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/mainsubs.cpp.o +0 -0
  106. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/model.cpp.o +0 -0
  107. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/nvector.cpp.o +0 -0
  108. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/nvector_serial.cpp.o +0 -0
  109. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/parse.cpp.o +0 -0
  110. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/phqalloc.cpp.o +0 -0
  111. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/pitzer.cpp.o +0 -0
  112. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/pitzer_structures.cpp.o +0 -0
  113. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/prep.cpp.o +0 -0
  114. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/print.cpp.o +0 -0
  115. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/read.cpp.o +0 -0
  116. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/readtr.cpp.o +0 -0
  117. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/runner.cpp.o +0 -0
  118. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/sit.cpp.o +0 -0
  119. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/smalldense.cpp.o +0 -0
  120. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/spread.cpp.o +0 -0
  121. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/step.cpp.o +0 -0
  122. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/structures.cpp.o +0 -0
  123. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/sundialsmath.cpp.o +0 -0
  124. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/tally.cpp.o +0 -0
  125. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/tidy.cpp.o +0 -0
  126. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/transport.cpp.o +0 -0
  127. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CMakeFiles/IPhreeqc.dir/src/phreeqcpp/utilities.cpp.o +0 -0
  128. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/CTestTestfile.cmake +6 -0
  129. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/DartConfiguration.tcl +109 -0
  130. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/cmake_install.cmake +45 -0
  131. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/build/libIPhreeqc.a +0 -0
  132. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/config/ar-lib +270 -0
  133. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/config/compile +347 -0
  134. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/config/config.guess +1441 -0
  135. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/config/config.sub +1813 -0
  136. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/config/depcomp +791 -0
  137. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/config/install-sh +508 -0
  138. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/config/ltmain.sh +11156 -0
  139. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/config/missing +215 -0
  140. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/config/test-driver +148 -0
  141. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/configure +23867 -0
  142. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/configure.ac +136 -0
  143. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/Amm.dat +1968 -0
  144. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/CMakeLists.txt +32 -0
  145. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/ColdChem.dat +267 -0
  146. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/Concrete_PHR.dat +158 -0
  147. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/Concrete_PZ.dat +195 -0
  148. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/Kinec.v2.dat +12039 -0
  149. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/Kinec_v3.dat +12159 -0
  150. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/Makefile.am +28 -0
  151. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/Makefile.in +530 -0
  152. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/PHREEQC_ThermoddemV1.10_15Dec2020.dat +12965 -0
  153. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/Tipping_Hurley.dat +4137 -0
  154. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/core10.dat +6824 -0
  155. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/frezchem.dat +634 -0
  156. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/iso.dat +7235 -0
  157. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/llnl.dat +19310 -0
  158. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/minteq.dat +5654 -0
  159. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/minteq.v4.dat +13212 -0
  160. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/phreeqc.dat +1972 -0
  161. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/phreeqc_rates.dat +3158 -0
  162. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/pitzer.dat +1044 -0
  163. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/sit.dat +14348 -0
  164. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/database/wateq4f.dat +4036 -0
  165. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/CMakeLists.txt +35 -0
  166. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/IPhreeqc.pdf +0 -0
  167. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/Makefile.am +24 -0
  168. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/Makefile.in +545 -0
  169. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/NOTICE +51 -0
  170. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/Phreeqc_2_1999_manual.pdf +0 -0
  171. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/Phreeqc_3_2013_manual.pdf +0 -0
  172. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/README +428 -0
  173. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/RELEASE +7294 -0
  174. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/IPhreeqc_8h.html +5096 -0
  175. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/IPhreeqc_8h_source.html +389 -0
  176. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/IPhreeqc_8hpp.html +83 -0
  177. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/IPhreeqc_8hpp_source.html +478 -0
  178. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/Var_8h.html +318 -0
  179. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/Var_8h_source.html +200 -0
  180. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/bc_s.png +0 -0
  181. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/bdwn.png +0 -0
  182. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/classIPhreeqc.html +2274 -0
  183. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/classIPhreeqc.png +0 -0
  184. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/classIPhreeqcStop.html +69 -0
  185. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/classIPhreeqcStop.png +0 -0
  186. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/closed.png +0 -0
  187. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/dir_68267d1309a1af8e8297ef4c3efbcdba.html +68 -0
  188. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/doxygen.css +1440 -0
  189. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/doxygen.png +0 -0
  190. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/dynsections.js +97 -0
  191. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2blank.png +0 -0
  192. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2doc.png +0 -0
  193. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2folderclosed.png +0 -0
  194. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2folderopen.png +0 -0
  195. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2lastnode.png +0 -0
  196. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2link.png +0 -0
  197. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2mlastnode.png +0 -0
  198. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2mnode.png +0 -0
  199. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2node.png +0 -0
  200. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2plastnode.png +0 -0
  201. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2pnode.png +0 -0
  202. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2splitbar.png +0 -0
  203. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/ftv2vertline.png +0 -0
  204. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/index.html +58 -0
  205. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/jquery.js +31 -0
  206. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/nav_f.png +0 -0
  207. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/nav_g.png +0 -0
  208. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/nav_h.png +0 -0
  209. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/open.png +0 -0
  210. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/structVAR.html +143 -0
  211. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/sync_off.png +0 -0
  212. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/sync_on.png +0 -0
  213. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/tab_a.png +0 -0
  214. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/tab_b.png +0 -0
  215. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/tab_h.png +0 -0
  216. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/tab_s.png +0 -0
  217. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/html/tabs.css +60 -0
  218. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/doc/phreeqc3.chm +0 -0
  219. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/CMakeLists.txt +11 -0
  220. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/Makefile.am +88 -0
  221. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/Makefile.in +696 -0
  222. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/c/CMakeLists.txt +1 -0
  223. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/c/advect/CMakeLists.txt +35 -0
  224. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/c/advect/CMakeLists.txt.in +21 -0
  225. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/c/advect/README.txt +44 -0
  226. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/c/advect/advect.c +101 -0
  227. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/c/advect/ic +17 -0
  228. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/c/advect/phreeqc.dat +1579 -0
  229. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/CMakeLists.txt +10 -0
  230. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/README.txt +3 -0
  231. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/excel/CMakeLists.txt +9 -0
  232. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/excel/phreeqc.dat +1582 -0
  233. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/excel/runphreeqc.xls +0 -0
  234. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/excel/withcallback.xls +0 -0
  235. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/python/CMakeLists.txt +11 -0
  236. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/python/Gypsum.py +52 -0
  237. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/python/parallel_advect.py +465 -0
  238. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/python/phreeqc.dat +1582 -0
  239. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/python/pitzer.dat +790 -0
  240. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/com/python/wateq4f.dat +3846 -0
  241. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/cpp/CMakeLists.txt +1 -0
  242. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/cpp/advect/CMakeLists.txt +35 -0
  243. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/cpp/advect/CMakeLists.txt.in +20 -0
  244. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/cpp/advect/README.txt +45 -0
  245. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/cpp/advect/advect.cpp +110 -0
  246. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/cpp/advect/ic +17 -0
  247. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/cpp/advect/phreeqc.dat +1579 -0
  248. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/fortran/CMakeLists.txt +1 -0
  249. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/fortran/advect/CMakeLists.txt +44 -0
  250. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/fortran/advect/CMakeLists.txt.in +24 -0
  251. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/fortran/advect/README.txt +45 -0
  252. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/fortran/advect/advect.F90 +102 -0
  253. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/fortran/advect/ic +17 -0
  254. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/fortran/advect/phreeqc.dat +1579 -0
  255. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/using-cmake/CMakeLists.txt +26 -0
  256. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/using-cmake/CMakeLists.txt.in +20 -0
  257. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/using-cmake/README.txt +37 -0
  258. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/using-cmake/ex2 +26 -0
  259. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/using-cmake/main.cpp +20 -0
  260. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/using-cmake/phreeqc.dat +1837 -0
  261. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/examples/using-cmake/post-install.cmake.in +7 -0
  262. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/CMakeLists.txt +185 -0
  263. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/FileTest.cpp +171 -0
  264. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/FileTest.h +34 -0
  265. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/Makefile.am +18 -0
  266. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/Makefile.in +466 -0
  267. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/TestCVar.cpp +9 -0
  268. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/TestIPhreeqc.cpp +4901 -0
  269. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/TestIPhreeqcLib.cpp +4644 -0
  270. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/TestSelectedOutput.cpp +669 -0
  271. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/TestVar.cpp +10 -0
  272. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/conv_fail.in +11 -0
  273. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/dump +42 -0
  274. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/iso.dat +7231 -0
  275. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/kinn20140218 +349 -0
  276. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/missing_e.dat +1556 -0
  277. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/multi_punch +105 -0
  278. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/multi_punch_no_set +102 -0
  279. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/phreeqc.dat.90a6449 +1935 -0
  280. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/gtest/phreeqc.dat.old +1556 -0
  281. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/m4/libtool.m4 +8388 -0
  282. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/m4/ltoptions.m4 +437 -0
  283. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/m4/ltsugar.m4 +124 -0
  284. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/m4/ltversion.m4 +23 -0
  285. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/m4/lt~obsolete.m4 +99 -0
  286. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/resource.h +14 -0
  287. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/CSelectedOutput.cpp +401 -0
  288. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/CSelectedOutput.hxx +77 -0
  289. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/CVar.hxx +162 -0
  290. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/Debug.h +12 -0
  291. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/ErrorReporter.hxx +70 -0
  292. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqc.cpp +1889 -0
  293. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqc.f.inc +91 -0
  294. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqc.f90.inc +603 -0
  295. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqc.h +2182 -0
  296. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqc.hpp +1027 -0
  297. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqcCallbacks.h +19 -0
  298. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqcF.f +653 -0
  299. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqcLib.cpp +1098 -0
  300. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqc_interface.F90 +1283 -0
  301. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqc_interface_F.cpp +535 -0
  302. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/IPhreeqc_interface_F.h +162 -0
  303. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/Makefile.am +210 -0
  304. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/Makefile.in +1294 -0
  305. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/README.Fortran +17 -0
  306. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/Var.c +84 -0
  307. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/Var.h +152 -0
  308. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/Version.h +36 -0
  309. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fimpl.h +282 -0
  310. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap.cpp +646 -0
  311. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap.h +163 -0
  312. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap1.cpp +24 -0
  313. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap2.cpp +24 -0
  314. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap3.cpp +24 -0
  315. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap4.cpp +24 -0
  316. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap5.cpp +24 -0
  317. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap6.cpp +25 -0
  318. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap7.cpp +25 -0
  319. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/fwrap8.cpp +24 -0
  320. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ChartHandler.cpp +225 -0
  321. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ChartHandler.h +59 -0
  322. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ChartObject.cpp +1382 -0
  323. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ChartObject.h +444 -0
  324. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/CurveObject.cpp +42 -0
  325. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/CurveObject.h +79 -0
  326. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Dictionary.cpp +41 -0
  327. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Dictionary.h +28 -0
  328. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ExchComp.cxx +398 -0
  329. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ExchComp.h +117 -0
  330. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Exchange.cxx +466 -0
  331. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Exchange.h +74 -0
  332. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Form1.h +1184 -0
  333. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Form1.resX +36 -0
  334. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/GasComp.cxx +265 -0
  335. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/GasComp.h +59 -0
  336. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/GasPhase.cxx +659 -0
  337. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/GasPhase.h +103 -0
  338. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ISolution.cxx +40 -0
  339. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ISolution.h +53 -0
  340. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ISolutionComp.cxx +202 -0
  341. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ISolutionComp.h +138 -0
  342. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/KineticsComp.cxx +318 -0
  343. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/KineticsComp.h +81 -0
  344. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/NA.h +1 -0
  345. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/NameDouble.cxx +537 -0
  346. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/NameDouble.h +66 -0
  347. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/NumKeyword.cxx +190 -0
  348. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/NumKeyword.h +67 -0
  349. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/PBasic.cpp +8350 -0
  350. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/PBasic.h +572 -0
  351. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/PHRQ_io_output.cpp +411 -0
  352. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/PPassemblage.cxx +375 -0
  353. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/PPassemblage.h +70 -0
  354. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/PPassemblageComp.cxx +441 -0
  355. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/PPassemblageComp.h +83 -0
  356. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Phreeqc.cpp +2087 -0
  357. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Phreeqc.h +2164 -0
  358. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/PhreeqcKeywords/Keywords.cpp +242 -0
  359. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/PhreeqcKeywords/Keywords.h +104 -0
  360. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Pressure.cxx +417 -0
  361. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Pressure.h +43 -0
  362. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Reaction.cxx +284 -0
  363. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Reaction.h +57 -0
  364. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ReadClass.cxx +1150 -0
  365. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SS.cxx +609 -0
  366. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SS.h +128 -0
  367. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SSassemblage.cxx +317 -0
  368. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SSassemblage.h +59 -0
  369. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SScomp.cxx +297 -0
  370. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SScomp.h +66 -0
  371. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SelectedOutput.cpp +115 -0
  372. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SelectedOutput.h +209 -0
  373. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Serializer.cxx +213 -0
  374. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Serializer.h +42 -0
  375. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Solution.cxx +1795 -0
  376. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Solution.h +154 -0
  377. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SolutionIsotope.cxx +333 -0
  378. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SolutionIsotope.h +85 -0
  379. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/StorageBin.cxx +1507 -0
  380. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/StorageBin.h +141 -0
  381. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/StorageBinList.cpp +358 -0
  382. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/StorageBinList.h +81 -0
  383. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Surface.cxx +837 -0
  384. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Surface.h +108 -0
  385. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SurfaceCharge.cxx +617 -0
  386. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SurfaceCharge.h +137 -0
  387. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SurfaceComp.cxx +509 -0
  388. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/SurfaceComp.h +70 -0
  389. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/System.cxx +103 -0
  390. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/System.h +89 -0
  391. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Temperature.cxx +423 -0
  392. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Temperature.h +42 -0
  393. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Use.cpp +78 -0
  394. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/Use.h +159 -0
  395. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/UserPunch.cpp +32 -0
  396. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/UserPunch.h +39 -0
  397. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/ZedGraph.dll +0 -0
  398. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/advection.cpp +140 -0
  399. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/basicsubs.cpp +4333 -0
  400. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/cl1.cpp +881 -0
  401. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/PHRQ_base.cxx +117 -0
  402. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/PHRQ_base.h +48 -0
  403. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/PHRQ_exports.h +20 -0
  404. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/PHRQ_io.cpp +914 -0
  405. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/PHRQ_io.h +207 -0
  406. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/Parser.cxx +1331 -0
  407. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/Parser.h +310 -0
  408. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/Utils.cxx +263 -0
  409. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/Utils.h +29 -0
  410. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/common/phrqtype.h +18 -0
  411. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/cvdense.cpp +566 -0
  412. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/cvdense.h +267 -0
  413. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/cvode.cpp +3939 -0
  414. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/cvode.h +940 -0
  415. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/cxxKinetics.cxx +617 -0
  416. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/cxxKinetics.h +78 -0
  417. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/cxxMix.cxx +154 -0
  418. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/cxxMix.h +58 -0
  419. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/dense.cpp +175 -0
  420. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/dense.h +341 -0
  421. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/dumper.cpp +277 -0
  422. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/dumper.h +60 -0
  423. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/gases.cpp +748 -0
  424. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/global_structures.h +1672 -0
  425. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/input.cpp +133 -0
  426. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/integrate.cpp +1219 -0
  427. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/inverse.cpp +5135 -0
  428. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/isotopes.cpp +1813 -0
  429. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/kinetics.cpp +3180 -0
  430. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/mainsubs.cpp +2320 -0
  431. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/model.cpp +5843 -0
  432. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/nvector.cpp +272 -0
  433. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/nvector.h +485 -0
  434. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/nvector_serial.cpp +1032 -0
  435. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/nvector_serial.h +369 -0
  436. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/parse.cpp +1044 -0
  437. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/phqalloc.cpp +316 -0
  438. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/phqalloc.h +47 -0
  439. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/pitzer.cpp +2709 -0
  440. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/pitzer_structures.cpp +225 -0
  441. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/prep.cpp +6267 -0
  442. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/print.cpp +3673 -0
  443. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/read.cpp +10245 -0
  444. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/readtr.cpp +1495 -0
  445. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/runner.cpp +158 -0
  446. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/runner.h +33 -0
  447. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/sit.cpp +1684 -0
  448. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/smalldense.cpp +324 -0
  449. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/smalldense.h +261 -0
  450. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/spread.cpp +1309 -0
  451. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/step.cpp +1566 -0
  452. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/structures.cpp +3381 -0
  453. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/sundialsmath.cpp +133 -0
  454. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/sundialsmath.h +162 -0
  455. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/sundialstypes.h +183 -0
  456. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/tally.cpp +1288 -0
  457. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/tidy.cpp +5600 -0
  458. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/transport.cpp +6403 -0
  459. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/phreeqcpp/utilities.cpp +1339 -0
  460. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/src/thread.h +64 -0
  461. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/CMakeLists.txt +133 -0
  462. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/Makefile.am +45 -0
  463. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/Makefile.in +1128 -0
  464. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/ex2.in +26 -0
  465. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/main.f90 +31 -0
  466. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/main77.f +6 -0
  467. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/main_fortran.cxx +8 -0
  468. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/phreeqc.dat.in +1556 -0
  469. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/test_c.c +148 -0
  470. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/test_cxx.cxx +152 -0
  471. pyEQL/phreeqc/ext/iphreeqc-3.8.6-17100/tests/test_f90.F90 +328 -0
  472. pyEQL/phreeqc/iphreeqc_wrapper.cpp +75 -0
  473. pyEQL/phreeqc/solution.py +74 -0
  474. pyEQL/phreeqc/var.py +50 -0
  475. pyEQL/presets/Ringers lactate.yaml +20 -0
  476. pyEQL/presets/__init__.py +17 -0
  477. pyEQL/presets/normal saline.yaml +17 -0
  478. pyEQL/presets/rainwater.yaml +17 -0
  479. pyEQL/presets/seawater.yaml +29 -0
  480. pyEQL/presets/urine.yaml +26 -0
  481. pyEQL/presets/wastewater.yaml +21 -0
  482. pyEQL/py.typed +0 -0
  483. pyEQL/salt_ion_match.py +112 -0
  484. pyEQL/solute.py +163 -0
  485. pyEQL/solution.py +2714 -0
  486. pyEQL/utils.py +237 -0
  487. pyeql-1.4.0rc9.dist-info/METADATA +130 -0
  488. pyeql-1.4.0rc9.dist-info/RECORD +491 -0
  489. pyeql-1.4.0rc9.dist-info/WHEEL +6 -0
  490. pyeql-1.4.0rc9.dist-info/licenses/AUTHORS.md +21 -0
  491. pyeql-1.4.0rc9.dist-info/licenses/LICENSE.txt +20 -0
pyEQL/solution.py ADDED
@@ -0,0 +1,2714 @@
1
+ """
2
+ pyEQL Solution Class.
3
+
4
+ :copyright: 2013-2024 by Ryan S. Kingsbury
5
+ :license: LGPL, see LICENSE for more details.
6
+
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import warnings
14
+ from functools import lru_cache
15
+ from importlib.resources import files
16
+ from pathlib import Path
17
+ from typing import Any, Literal
18
+
19
+ import numpy as np
20
+ from maggma.stores import JSONStore, Store
21
+ from monty.dev import deprecated
22
+ from monty.json import MontyDecoder, MSONable
23
+ from monty.serialization import dumpfn, loadfn
24
+ from pint import DimensionalityError, Quantity
25
+ from pymatgen.core import Element
26
+ from pymatgen.core.ion import Ion
27
+
28
+ from pyEQL import IonDB, ureg
29
+ from pyEQL.activity_correction import _debye_parameter_activity, _debye_parameter_B
30
+ from pyEQL.engines import EOS, IdealEOS, NativeEOS, Phreeqc2026EOS, PhreeqcEOS
31
+ from pyEQL.salt_ion_match import Salt
32
+ from pyEQL.solute import Solute
33
+ from pyEQL.utils import FormulaDict, create_water_substance, interpret_units, standardize_formula
34
+
35
+ EQUIV_WT_CACO3 = ureg.Quantity(100.09 / 2, "g/mol")
36
+ # string to denote unknown oxidation states
37
+ UNKNOWN_OXI_STATE = "unk"
38
+ K_W = 1e-14 # ion product of water at 25 degC
39
+
40
+
41
+ class Solution(MSONable):
42
+ """
43
+ Class representing the properties of a solution. Instances of this class
44
+ contain information about the solutes, solvent, and bulk properties.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ solutes: list[list[str]] | dict[str, str] | None = None,
50
+ volume: str | None = None,
51
+ temperature: str = "298.15 K",
52
+ pressure: str = "1 atm",
53
+ pH: float = 7,
54
+ pE: float = 8.5,
55
+ balance_charge: str | None = None,
56
+ solvent: str | list = "H2O",
57
+ engine: EOS | Literal["native", "ideal", "phreeqc", "phreeqc2026"] = "native",
58
+ database: str | Path | Store | None = None,
59
+ default_diffusion_coeff: float = 1.6106e-9,
60
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = "ERROR",
61
+ ) -> None:
62
+ r"""
63
+ Instantiate a Solution from a composition.
64
+
65
+ Args:
66
+ solutes: dict, optional. Keys must be the chemical formula, while values must be
67
+ str Quantity representing the amount. For example:
68
+
69
+ {"Na+": "0.1 mol/L", "Cl-": "0.1 mol/L"}
70
+
71
+ Note that an older "list of lists" syntax is also supported; however this
72
+ will be deprecated in the future and is no longer recommended. The equivalent
73
+ list syntax for the above example is
74
+
75
+ [["Na+", "0.1 mol/L"], ["Cl-", "0.1 mol/L"]]
76
+
77
+ Defaults to empty (pure solvent) if omitted
78
+ volume: str, optional
79
+ Volume of the solvent, including the unit. Defaults to '1 L' if omitted.
80
+ Note that the total solution volume will be computed using partial molar
81
+ volumes of the respective solutes as they are added to the solution.
82
+ temperature: str, optional
83
+ The solution temperature, including the ureg. Defaults to '25 degC' if omitted.
84
+ pressure: Quantity, optional
85
+ The ambient pressure of the solution, including the unit.
86
+ Defaults to '1 atm' if omitted.
87
+ pH: number, optional
88
+ Negative log of H+ activity. If omitted, the solution will be
89
+ initialized to pH 7 (neutral) with appropriate quantities of
90
+ H+ and OH- ions
91
+ pE: the :math:`pe` value of the solution. :math:`pe` measures the relative abundance of electrons
92
+ analogous to how pH measures the relative abundance of protons. Specifically, :math:`pe` is defined in
93
+ terms of the activity of electrons :math:`[e^{-}]`:
94
+
95
+ .. math:: pe = - \log [e^{-}]
96
+
97
+ The relationship between the redox potential :math:`Eh` and :math:`pe` can be illustrated by considering
98
+ the general redox reaction,
99
+
100
+ .. math::
101
+
102
+ \begin{gather*}
103
+ \text{A}^x \pm ne^{-} \longrightarrow \text{A}^{x \mp n} \quad\quad
104
+ K = \frac{[\text{A}^{x \mp n}]}{[\text{A}^x][e^{-}]^{\pm n}}
105
+ \end{gather*}
106
+
107
+ Writing :math:`pe` in terms of the equilibrium constant :math:`K` and the activities,
108
+ :math:`[\text{A}^{x}]` and :math:`[\text{A}^{x \mp n}]`, we have:
109
+
110
+ .. math::
111
+
112
+ \begin{gather*}
113
+ pe = -\log[e^{-}] = \mp \frac{1}{n} \log\left(\frac{1}{K} \frac{[\text{A}^{x \mp n}]}{[\text{A}^x]}\right)
114
+ = \mp \frac{\Delta G}{nRT \ln 10} = \frac{FEh}{RT \ln 10}
115
+ \end{gather*}
116
+
117
+ Thus, the redox potential :math:`Eh` is then related to :math:`pe` via:
118
+
119
+ .. math:: Eh = 2.303 \frac{RT}{F}pe
120
+
121
+ where :math:`F` is Faraday's constant. Note that lower values of ``pE`` (and thus :math:`Eh`)
122
+ correspond to more reducing environments, while higher values = more oxidizing. At pH 7, water is stable
123
+ between approximately -7 to +14. The default value corresponds to a :math:`pe` value typical of natural
124
+ waters in equilibrium with the atmosphere.
125
+ balance_charge: The strategy for balancing charge during init and equilibrium calculations. Valid options
126
+ are
127
+
128
+ - 'pH', which will adjust the solution pH to balance charge,
129
+ - 'auto' which will use the majority cation or anion (i.e., that with the largest concentration)
130
+ as needed,
131
+ - 'pE' (not currently implemented) which will adjust the redox equilibrium to balance charge, or
132
+ the name of a dissolved species e.g. 'Ca+2' or 'Cl-' that will be added/subtracted to balance
133
+ charge.
134
+ - None (default), in which case no charge balancing will be performed either on init or when
135
+ equilibrate() is called. Note that in this case, equilibrate() can distort the charge balance!
136
+
137
+ solvent: Formula of the solvent. Solvents other than water are not supported at this time.
138
+ engine: Electrolyte modeling engine to use. See documentation for details on the available engines.
139
+ database: path to a .json file (str or Path) or maggma Store instance that
140
+ contains serialized SoluteDocs. `None` (default) will use the built-in pyEQL database.
141
+ log_level: Log messages of this or higher severity will be printed to stdout. Defaults to 'ERROR', meaning
142
+ that ERROR and CRITICAL messages will be shown, while WARNING, INFO, and DEBUG messages are not. If set
143
+ to None, nothing will be printed.
144
+ default_diffusion_coeff: Diffusion coefficient value in m^2/s to use in
145
+ calculations when there is no diffusion coefficient for a species in the database. This affects several
146
+ important property calculations including conductivity and transport number, which are related to the
147
+ weighted sums of diffusion coefficients of all species. Setting this argument to zero will exclude any
148
+ species that does not have a tabulated diffusion coefficient from these calculations, possibly resulting
149
+ in underestimation of the conductivity and/or inaccurate transport numbers.
150
+
151
+ Missing diffusion coefficients are especially likely in complex electrolytes containing, for example,
152
+ complexes or paired species such as NaSO4[-1]. In such cases, setting default_diffusion_coeff to zero
153
+ is likely to result in the above errors.
154
+
155
+ By default, this argument is set to the diffusion coefficient of NaCl salt, 1.61x10^-9 m2/s.
156
+
157
+ Examples:
158
+ >>> s1 = pyEQL.Solution({'Na+': '1 mol/L','Cl-': '1 mol/L'},temperature='20 degC',volume='500 mL')
159
+ >>> print(s1)
160
+ Components:
161
+ Volume: 0.500 l
162
+ Pressure: 1.000 atm
163
+ Temperature: 293.150 K
164
+ Components: ['H2O(aq)', 'H[+1]', 'OH[-1]', 'Na[+1]', 'Cl[-1]']
165
+ """
166
+ # create a logger and attach it to this class
167
+ self.log_level = log_level.upper()
168
+ self.logger = logging.getLogger("pyEQL")
169
+ if self.log_level is not None:
170
+ # set the level of the module logger
171
+ self.logger.setLevel(self.log_level)
172
+ # clear handlers and add a StreamHandler
173
+ self.logger.handlers.clear()
174
+ # use rich for pretty log formatting, if installed
175
+ try:
176
+ from rich.logging import RichHandler # noqa: PLC0415
177
+
178
+ sh = RichHandler(rich_tracebacks=True)
179
+ except ImportError:
180
+ sh = logging.StreamHandler()
181
+ # the formatter determines what our logs will look like
182
+ formatter = logging.Formatter("[%(asctime)s] [%(levelname)8s] --- %(message)s (%(filename)s:%(lineno)d)")
183
+ sh.setFormatter(formatter)
184
+ self.logger.addHandler(sh)
185
+
186
+ # per-instance cache of get_property and other calls that do not depend
187
+ # on composition
188
+ # see https://rednafi.com/python/lru_cache_on_methods/
189
+ self.get_property = lru_cache()(self._get_property)
190
+ self.get_molar_conductivity = lru_cache()(self._get_molar_conductivity)
191
+ self.get_mobility = lru_cache()(self._get_mobility)
192
+ self.default_diffusion_coeff = default_diffusion_coeff
193
+ self.get_diffusion_coefficient = lru_cache()(self._get_diffusion_coefficient)
194
+
195
+ # initialize the volume recalculation flag
196
+ self.volume_update_required = False
197
+
198
+ # initialize the volume with a flag to distinguish user-specified volume
199
+ if volume is not None:
200
+ # volume_set = True
201
+ self._volume = ureg.Quantity(volume).to("L")
202
+ else:
203
+ # volume_set = False
204
+ self._volume = ureg.Quantity(1, "L")
205
+ # store the initial conditions as private variables in case they are
206
+ # changed later
207
+ self._temperature = ureg.Quantity(temperature)
208
+ self._pressure = ureg.Quantity(pressure)
209
+ self._pE = pE
210
+ self._pH = pH
211
+ self.pE = self._pE
212
+ if isinstance(balance_charge, str) and balance_charge not in ["pH", "pE", "auto"]:
213
+ self.balance_charge = standardize_formula(balance_charge)
214
+ else:
215
+ self.balance_charge = balance_charge #: Standardized formula of the species used for charge balancing.
216
+
217
+ # instantiate a water substance for property retrieval
218
+ self.water_substance = create_water_substance(self.temperature, self.pressure)
219
+ """IAPWS instance describing water properties."""
220
+
221
+ # create an empty dictionary of components. This dict comprises {formula: moles}
222
+ # where moles is the number of moles in the solution.
223
+ self.components = FormulaDict({})
224
+ """Special dictionary where keys are standardized formula and values are the moles present in Solution."""
225
+
226
+ # connect to the desired property database
227
+ if database is None:
228
+ # load the default database, which is a JSONStore
229
+ db_store = IonDB
230
+ elif isinstance(database, str | Path):
231
+ db_store = JSONStore(str(database), key="formula")
232
+ self.logger.debug(f"Created maggma JSONStore from .json file {database}")
233
+ else:
234
+ db_store = database
235
+ self.database = db_store
236
+ """`Store` instance containing the solute property database."""
237
+ self.database.connect()
238
+ self.logger.debug(f"Connected to property database {self.database!s}")
239
+
240
+ if engine == "native":
241
+ warnings.warn(
242
+ 'In the next release, the default engine ("native") will'
243
+ "transition to a new version of the PHREEQC wrapper for"
244
+ "speciation calculations. No change in your script is"
245
+ "required, but if you call .equilibrate(), compare results"
246
+ "carefully between releases.",
247
+ DeprecationWarning,
248
+ stacklevel=2,
249
+ )
250
+
251
+ # set the equation of state engine
252
+ self._engine = engine
253
+ # self.engine: Optional[EOS] = None
254
+ if isinstance(self._engine, EOS):
255
+ self.engine: EOS = self._engine
256
+ elif self._engine == "ideal":
257
+ self.engine = IdealEOS()
258
+ elif self._engine == "native":
259
+ self.engine = NativeEOS()
260
+ elif self._engine == "phreeqc":
261
+ self.engine = PhreeqcEOS()
262
+ elif self._engine == "phreeqc2026":
263
+ self.engine = Phreeqc2026EOS()
264
+ else:
265
+ raise ValueError(f'{engine} is not a valid value for the "engine" kwarg!')
266
+
267
+ # define the solvent. Allow for list input to support future use of mixed solvents
268
+ if not isinstance(solvent, list):
269
+ solvent = [solvent]
270
+ if len(solvent) > 1:
271
+ raise ValueError("Multiple solvents are not yet supported!")
272
+ if solvent[0] not in ["H2O", "H2O(aq)", "water", "Water", "HOH"]:
273
+ raise ValueError("Non-aqueous solvent detected. These are not yet supported!")
274
+ self.solvent = standardize_formula(solvent[0])
275
+ """Formula of the component that is set as the solvent (currently only H2O(aq) is supported)."""
276
+
277
+ # calculate the moles of solvent (water) on the density and the solution volume
278
+ moles = self.volume.magnitude / 55.55 # molarity of pure water
279
+ self.components["H2O"] = moles
280
+
281
+ # set the pH with H+ and OH-
282
+ self.add_solute("H+", str(10 ** (-1 * pH)) + "mol/L")
283
+ self.add_solute("OH-", str(K_W / (10 ** (-1 * pH))) + "mol/L")
284
+
285
+ # populate the other solutes
286
+ self._solutes = solutes
287
+ if self._solutes is None:
288
+ self._solutes = {}
289
+
290
+ if isinstance(self._solutes, dict):
291
+ for k, v in self._solutes.items():
292
+ self.add_solute(k, v)
293
+ # if user has specified H+ in solutes, check consistency with pH kwarg
294
+ if standardize_formula(k) == "H[+1]":
295
+ # if user has not specified pH (default value), override the pH argument
296
+ if self._pH == 7:
297
+ self.logger.warning(f"H[+1] = {v} found in solutes. Overriding default pH with this value.")
298
+ # if user specifies non-default pH that does not match the supplied H+, raise an error
299
+ elif not np.isclose(self.pH, self._pH, atol=1e-4):
300
+ raise ValueError(
301
+ "Cannot specify both a non-default pH and H+ at the same time. Please provide only one."
302
+ )
303
+ elif isinstance(self._solutes, list):
304
+ msg = (
305
+ 'List input of solutes (e.g., [["Na+", "0.5 mol/L]]) is deprecated! Use dictionary formatted input '
306
+ '(e.g., {"Na+":"0.5 mol/L"} instead.)'
307
+ )
308
+ self.logger.warning(msg)
309
+ warnings.warn(msg, DeprecationWarning)
310
+ for item in self._solutes:
311
+ self.add_solute(*item)
312
+
313
+ # determine the species that will be used for charge balancing, when needed.
314
+ # this is necessary to do even if the composition is already electroneutral,
315
+ # because the appropriate species also needs to be passed to equilibrate
316
+ # to keep from distorting the charge balance.
317
+ cb = self.charge_balance
318
+ if self.balance_charge is None:
319
+ self._cb_species = None
320
+ elif self.balance_charge == "pH":
321
+ self._cb_species = "H[+1]"
322
+ elif self.balance_charge == "pE":
323
+ raise NotImplementedError("Balancing charge via redox (pE) is not yet implemented!")
324
+ elif self.balance_charge == "auto":
325
+ # add the most abundant ion of the opposite charge
326
+ if cb <= 0:
327
+ self._cb_species = max(self.cations, key=self.cations.get)
328
+ elif cb > 0:
329
+ self._cb_species = max(self.anions, key=self.anions.get)
330
+ else:
331
+ ions = set().union(*[self.cations, self.anions]) # all ions
332
+ self._cb_species = self.balance_charge
333
+ if self._cb_species not in ions:
334
+ raise ValueError(
335
+ f"Charge balancing species {self._cb_species} was not found in the solution!. "
336
+ f"Species {ions} were found."
337
+ )
338
+
339
+ # adjust charge balance, if necessary
340
+ self._adjust_charge_balance()
341
+
342
+ @property
343
+ def mass(self) -> Quantity:
344
+ """
345
+ Return the total mass of the solution.
346
+
347
+ The mass is calculated each time this method is called.
348
+
349
+ Returns: The mass of the solution, in kg
350
+
351
+ """
352
+ mass = np.sum([self.get_amount(item, "kg").magnitude for item in self.components])
353
+ return ureg.Quantity(mass, "kg")
354
+
355
+ @property
356
+ def solvent_mass(self) -> Quantity:
357
+ """
358
+ Return the mass of the solvent.
359
+
360
+ This property is used whenever mol/kg (or similar) concentrations
361
+ are requested by get_amount()
362
+
363
+ Returns:
364
+ The mass of the solvent, in kg
365
+
366
+ See Also:
367
+ :py:meth:`get_amount()`
368
+ """
369
+ return self.get_amount(self.solvent, "kg")
370
+
371
+ @property
372
+ def volume(self) -> Quantity:
373
+ """
374
+ Return the volume of the solution.
375
+
376
+ Returns:
377
+ Quantity: the volume of the solution, in L
378
+ """
379
+ # if the composition has changed, recalculate the volume first
380
+ if self.volume_update_required is True:
381
+ self._update_volume()
382
+ self.volume_update_required = False
383
+
384
+ return self._volume.to("L")
385
+
386
+ @volume.setter
387
+ def volume(self, volume: str):
388
+ """Change the total solution volume to volume, while preserving
389
+ all component concentrations.
390
+
391
+ Args:
392
+ volume : Total volume of the solution, including the unit, e.g. '1 L'
393
+
394
+ Examples:
395
+ >>> mysol = Solution([['Na+','2 mol/L'],['Cl-','0.01 mol/L']],volume='500 mL')
396
+ >>> print(mysol.volume)
397
+ 0.5000883925072983 l
398
+
399
+ """
400
+ # figure out the factor to multiply the old concentrations by
401
+ scale_factor = ureg.Quantity(volume) / self.volume
402
+
403
+ # scale down the amount of all the solutes according to the factor
404
+ for solute in self.components:
405
+ self.components[solute] *= scale_factor.magnitude
406
+
407
+ # update the solution volume
408
+ self._volume *= scale_factor.magnitude
409
+
410
+ @property
411
+ def temperature(self) -> Quantity:
412
+ """Return the temperature of the solution in Kelvin."""
413
+ return self._temperature.to("K")
414
+
415
+ @temperature.setter
416
+ def temperature(self, temperature: str):
417
+ """
418
+ Set the solution temperature.
419
+
420
+ Args:
421
+ temperature: pint-compatible string, e.g. '25 degC'
422
+ """
423
+ self._temperature = ureg.Quantity(temperature)
424
+
425
+ # update the water substance
426
+ self.water_substance = create_water_substance(self.temperature, self.pressure)
427
+
428
+ # recalculate the volume
429
+ self.volume_update_required = True
430
+
431
+ # clear any cached solute properties that may depend on temperature
432
+ self.get_property.cache_clear()
433
+ self.get_molar_conductivity.cache_clear()
434
+ self.get_mobility.cache_clear()
435
+ self.get_diffusion_coefficient.cache_clear()
436
+
437
+ @property
438
+ def pressure(self) -> Quantity:
439
+ """Return the hydrostatic pressure of the solution in atm."""
440
+ return self._pressure.to("atm")
441
+
442
+ @pressure.setter
443
+ def pressure(self, pressure: str):
444
+ """
445
+ Set the solution pressure.
446
+
447
+ Args:
448
+ pressure: pint-compatible string, e.g. '1.2 atmC'
449
+ """
450
+ self._pressure = ureg.Quantity(pressure)
451
+
452
+ # update the water substance
453
+ self.water_substance = create_water_substance(self.temperature, self.pressure)
454
+
455
+ # recalculate the volume
456
+ self.volume_update_required = True
457
+
458
+ @property
459
+ def pH(self) -> float:
460
+ """Return the pH of the solution."""
461
+ return self.p("H+", activity=False)
462
+
463
+ def p(self, solute: str, activity=True) -> float:
464
+ """
465
+ Return the negative log of the activity of solute.
466
+
467
+ Generally used for expressing concentration of hydrogen ions (pH)
468
+
469
+ Args:
470
+ solute : str
471
+ String representing the formula of the solute
472
+ activity: bool, optional
473
+ If False, the function will use the molar concentration rather
474
+ than the activity to calculate p. Defaults to True.
475
+
476
+ Returns:
477
+ Quantity
478
+ The negative log10 of the activity (or molar concentration if
479
+ activity = False) of the solute. If the solute has zero concentration
480
+ then np.nan (not a number) is returned.
481
+ """
482
+ try:
483
+ if activity is True:
484
+ amt = self.get_activity(solute).magnitude
485
+ else:
486
+ amt = self.get_amount(solute, "mol/L").magnitude
487
+ return float(-1 * np.log10(amt))
488
+ # if the solute has zero or negative concentration, np.log10 raises a RuntimeWarning
489
+ except RuntimeWarning:
490
+ return np.nan
491
+
492
+ @property
493
+ def density(self) -> Quantity:
494
+ """
495
+ Return the density of the solution.
496
+
497
+ Density is calculated from the mass and volume each time this method is called.
498
+
499
+ Returns:
500
+ Quantity: The density of the solution.
501
+ """
502
+ return self.mass / self.volume
503
+
504
+ @property
505
+ def dielectric_constant(self) -> Quantity:
506
+ r"""
507
+ Returns the dielectric constant of the solution.
508
+
509
+ Args:
510
+ None
511
+
512
+ Returns:
513
+ Quantity: the dielectric constant of the solution, dimensionless.
514
+
515
+ Notes:
516
+ Implements the following equation as given by Zuber et al.
517
+
518
+ .. math:: \epsilon = \epsilon_{solvent} \over 1 + \sum_i \alpha_i x_i
519
+
520
+ where :math:`\alpha_i` is a coefficient specific to the solvent and ion, and :math:`x_i`
521
+ is the mole fraction of the ion in solution.
522
+
523
+
524
+ References:
525
+ A. Zuber, L. Cardozo-Filho, V.F. Cabral, R.F. Checoni, M. Castier,
526
+ An empirical equation for the dielectric constant in aqueous and nonaqueous
527
+ electrolyte mixtures, Fluid Phase Equilib. 376 (2014) 116-123.
528
+ doi:10.1016/j.fluid.2014.05.037.
529
+ """
530
+ di_water = self.water_substance.epsilon
531
+
532
+ denominator = 1
533
+ for item in self.components:
534
+ # ignore water
535
+ if item != "H2O(aq)":
536
+ # skip over solutes that don't have parameters
537
+ # try:
538
+ fraction = self.get_amount(item, "fraction")
539
+ coefficient = self.get_property(item, "model_parameters.dielectric_zuber")
540
+ if coefficient is not None:
541
+ denominator += coefficient * fraction
542
+ # except TypeError:
543
+ # self.logger.warning("No dielectric parameters found for species %s." % item)
544
+ # continue
545
+
546
+ return ureg.Quantity(di_water / denominator, "dimensionless")
547
+
548
+ @property
549
+ def chemical_system(self) -> str:
550
+ """
551
+ Return the chemical system of the Solution as a "-" separated list of elements, sorted alphabetically. For
552
+ example, a solution containing CaCO3 would have a chemical system of "C-Ca-H-O".
553
+ """
554
+ return "-".join(self.elements)
555
+
556
+ @property
557
+ def elements(self) -> list:
558
+ """
559
+ Return a list of elements that are present in the solution.
560
+
561
+ For example, a solution containing CaCO3 would return ["C", "Ca", "H", "O"]
562
+ """
563
+ els = []
564
+ for s in self.components:
565
+ els.extend(self.get_property(s, "elements"))
566
+ return sorted(set(els))
567
+
568
+ @property
569
+ def cations(self) -> dict[str, float]:
570
+ """
571
+ Returns the subset of `components` that are cations.
572
+
573
+ The returned dict is sorted by amount in descending order.
574
+ """
575
+ return {k: v for k, v in self.components.items() if self.get_property(k, "charge") > 0}
576
+
577
+ @property
578
+ def anions(self) -> dict[str, float]:
579
+ """
580
+ Returns the subset of `components` that are anions.
581
+
582
+ The returned dict is sorted by amount in descending order.
583
+ """
584
+ return {k: v for k, v in self.components.items() if self.get_property(k, "charge") < 0}
585
+
586
+ @property
587
+ def neutrals(self) -> dict[str, float]:
588
+ """
589
+ Returns the subset of `components` that are neutral (not charged).
590
+
591
+ The returned dict is sorted by amount in descending order.
592
+ """
593
+ return {k: v for k, v in self.components.items() if self.get_property(k, "charge") == 0}
594
+
595
+ # TODO - need tests for viscosity
596
+ @property
597
+ def viscosity_dynamic(self) -> Quantity:
598
+ """
599
+ Return the dynamic (absolute) viscosity of the solution.
600
+
601
+ Calculated from the kinematic viscosity
602
+
603
+ See Also:
604
+ :attr:`viscosity_kinematic`
605
+ """
606
+ return self.viscosity_kinematic * self.density
607
+
608
+ # TODO - before deprecating get_viscosity_relative, consider whether the Jones-Dole
609
+ # model should be integrated here as a fallback, in case salt parameters for the
610
+ # other model are not available.
611
+ # if self.ionic_strength.magnitude > 0.2:
612
+ # self.logger.warning('Viscosity calculation has limited accuracy above 0.2m')
613
+
614
+ # viscosity_rel = 1
615
+ # for item in self.components:
616
+ # # ignore water
617
+ # if item != 'H2O':
618
+ # # skip over solutes that don't have parameters
619
+ # try:
620
+ # conc = self.get_amount(item,'mol/kg').magnitude
621
+ # coefficients= self.get_property(item, 'jones_dole_viscosity')
622
+ # viscosity_rel += coefficients[0] * conc ** 0.5 + coefficients[1] * conc + \
623
+ # coefficients[2] * conc ** 2
624
+ # except TypeError:
625
+ # continue
626
+ # return (
627
+ # self.viscosity_dynamic / self.water_substance.mu * ureg.Quantity("1 Pa*s")
628
+ # )
629
+ @property
630
+ def viscosity_kinematic(self) -> Quantity:
631
+ r"""
632
+ Return the kinematic viscosity of the solution.
633
+
634
+ Notes:
635
+ The calculation is based on a model derived from the Eyring equation
636
+ and presented in
637
+
638
+ .. math::
639
+
640
+ \ln \nu = \ln {\nu_w MW_w \over \sum_i x_i MW_i } +
641
+ 15 x_+^2 + x_+^3 \delta G^*_{123} + 3 x_+ \delta G^*_{23} (1-0.05x_+)
642
+
643
+ Where:
644
+
645
+ .. math:: \delta G^*_{123} = a_o + a_1 (T)^{0.75}
646
+ .. math:: \delta G^*_{23} = b_o + b_1 (T)^{0.5}
647
+
648
+ In which :math:`\nu` is the kinematic viscosity, MW is the molecular weight,
649
+ :math:`x_{+}` is the mole fraction of cations, and :math:`T` is the temperature in degrees C.
650
+
651
+ The a and b fitting parameters for a variety of common salts are included in the
652
+ database.
653
+
654
+ References:
655
+ Vásquez-Castillo, G.; Iglesias-Silva, G. a.; Hall, K. R. An extension of the McAllister model to correlate
656
+ kinematic viscosity of electrolyte solutions. Fluid Phase Equilib. 2013, 358, 44-49.
657
+
658
+ See Also:
659
+ :py:meth:`viscosity_dynamic`
660
+ """
661
+ # identify the main salt in the solution
662
+ salt = self.get_salt()
663
+
664
+ a0 = a1 = b0 = b1 = 0
665
+
666
+ # retrieve the parameters for the delta G equations
667
+ params = None if salt is None else self.get_property(salt.formula, "model_parameters.viscosity_eyring")
668
+ if params is not None:
669
+ a0 = ureg.Quantity(params["a0"]["value"]).magnitude
670
+ a1 = ureg.Quantity(params["a1"]["value"]).magnitude
671
+ b0 = ureg.Quantity(params["b0"]["value"]).magnitude
672
+ b1 = ureg.Quantity(params["b1"]["value"]).magnitude
673
+
674
+ # compute the delta G parameters
675
+ temperature = self.temperature.to("degC").magnitude
676
+ G_123 = a0 + a1 * (temperature) ** 0.75
677
+ G_23 = b0 + b1 * (temperature) ** 0.5
678
+
679
+ # calculate the cation mole fraction
680
+ # x_cat = self.get_amount(cation, "fraction")
681
+ x_cat = self.get_amount(salt.cation, "fraction").magnitude
682
+ else:
683
+ # TODO - fall back to the Jones-Dole model! There are currently no eyring parameters in the database!
684
+ # proceed with the coefficients equal to zero and log a warning
685
+ self.logger.warning("Appropriate viscosity coefficients were not found. Viscosity will be approximate.")
686
+ G_123 = G_23 = 0
687
+ x_cat = 0
688
+
689
+ # get the kinematic viscosity of water, returned by IAPWS in m2/s
690
+ nu_w = self.water_substance.nu
691
+
692
+ # compute the effective molar mass of the solution
693
+ total_moles = np.sum([v for k, v in self.components.items()])
694
+ MW = self.mass.to("g").magnitude / total_moles
695
+
696
+ # get the MW of water
697
+ MW_w = self.get_property(self.solvent, "molecular_weight").magnitude
698
+
699
+ # calculate the kinematic viscosity
700
+ nu = np.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)
701
+
702
+ return ureg.Quantity(np.exp(nu), "m**2 / s")
703
+
704
+ @property
705
+ def conductivity(self) -> Quantity:
706
+ r"""
707
+ Compute the electrical conductivity of the solution.
708
+
709
+ Returns:
710
+ The electrical conductivity of the solution in Siemens / meter.
711
+
712
+ Notes:
713
+ Conductivity is calculated by summing the molar conductivities of the respective
714
+ solutes.
715
+
716
+ .. math::
717
+
718
+ EC = {F^2 \over R T} \sum_i D_i z_i ^ 2 m_i = \sum_i \lambda_i m_i
719
+
720
+ Where :math:`D_i` is the diffusion coefficient, :math:`m_i` is the molal concentration,
721
+ :math:`z_i` is the charge, and the summation extends over all species in the solution.
722
+ Alternatively, :math:`\lambda_i` is the molar conductivity of solute i.
723
+
724
+ Diffusion coefficients :math:`D_i` (and molar conductivities :math:`\lambda_i`) are
725
+ adjusted for the effects of temperature and ionic strength using the method implemented
726
+ in PHREEQC >= 3.4. [aq]_ [hc]_ See `get_diffusion_coefficient for` further details.
727
+
728
+ References:
729
+ .. [aq] https://www.aqion.de/site/electrical-conductivity
730
+ .. [hc] https://www.hydrochemistry.eu/exmpls/sc.html
731
+
732
+ See Also:
733
+ :py:attr:`ionic_strength`
734
+ :py:meth:`get_diffusion_coefficient`
735
+ :py:meth:`get_molar_conductivity`
736
+ """
737
+ EC = ureg.Quantity(
738
+ np.asarray(
739
+ [
740
+ self.get_molar_conductivity(i).to("S*L/mol/m").magnitude * self.get_amount(i, "mol/L").magnitude
741
+ for i in self.components
742
+ ]
743
+ ),
744
+ "S/m",
745
+ )
746
+ return np.sum(EC)
747
+
748
+ @property
749
+ def ionic_strength(self) -> Quantity:
750
+ r"""
751
+ Return the ionic strength of the solution.
752
+
753
+ Return the ionic strength of the solution, calculated as 1/2 * sum ( molality * charge ^2) over all the ions.
754
+
755
+ Molal (mol/kg) scale concentrations are used for compatibility with the activity correction formulas.
756
+
757
+ Returns:
758
+ Quantity:
759
+ The ionic strength of the parent solution, mol/kg.
760
+
761
+ See Also:
762
+ :py:meth:`get_activity`
763
+ :py:meth:`get_water_activity`
764
+
765
+ Notes:
766
+ The ionic strength is calculated according to:
767
+
768
+ .. math:: I = \sum_i m_i z_i^2
769
+
770
+ Where :math:`m_i` is the molal concentration and :math:`z_i` is the charge on species i.
771
+
772
+ Examples:
773
+ >>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
774
+ >>> s1.ionic_strength
775
+ <Quantity(0.20000010029672785, 'mole / kilogram')>
776
+
777
+ >>> s1 = pyEQL.Solution([['Mg+2','0.3 mol/kg'],['Na+','0.1 mol/kg'],['Cl-','0.7 mol/kg']],temperature='30 degC')
778
+ >>> s1.ionic_strength
779
+ <Quantity(1.0000001004383303, 'mole / kilogram')>
780
+ """
781
+ # compute using magnitudes only, for performance reasons
782
+ ionic_strength = np.sum(
783
+ [mol * self.get_property(solute, "charge") ** 2 for solute, mol in self.components.items()]
784
+ )
785
+ ionic_strength /= self.solvent_mass.to("kg").magnitude # convert to mol/kg
786
+ ionic_strength *= 0.5
787
+ return ureg.Quantity(ionic_strength, "mol/kg")
788
+
789
+ @property
790
+ def charge_balance(self) -> float:
791
+ r"""
792
+ Return the charge balance of the solution.
793
+
794
+ Return the charge balance of the solution. The charge balance represents the net electric charge
795
+ on the solution and SHOULD equal zero at all times, but due to numerical errors will usually
796
+ have a small nonzero value. It is calculated according to:
797
+
798
+ .. math:: CB = \sum_i C_i z_i
799
+
800
+ where :math:`C_i` is the molar concentration, and :math:`z_i` is the charge on species i.
801
+
802
+ Returns:
803
+ float :
804
+ The charge balance of the solution, in equivalents (mol of charge) per L.
805
+
806
+ """
807
+ charge_balance = 0
808
+ for solute in self.components:
809
+ charge_balance += self.get_amount(solute, "eq/L").magnitude
810
+
811
+ return charge_balance
812
+
813
+ # TODO - consider adding guard statements to prevent alkalinity from being negative
814
+ @property
815
+ def alkalinity(self) -> Quantity:
816
+ r"""
817
+ Return the alkalinity or acid neutralizing capacity of a solution.
818
+
819
+ Returns:
820
+ Quantity: The alkalinity of the solution in mg/L as CaCO3
821
+
822
+ Notes:
823
+ The alkalinity is calculated according to [stm]_
824
+
825
+ .. math:: Alk = \sum_{i} z_{i} C_{B} + \sum_{i} z_{i} C_{A}
826
+
827
+ Where :math:`C_{B}` and :math:`C_{A}` are conservative cations and anions, respectively
828
+ (i.e. ions that do not participate in acid-base reactions), and :math:`z_{i}` is their signed charge.
829
+ In this method, the set of conservative cations is all Group I and Group II cations, and the
830
+ conservative anions are all the anions of strong acids.
831
+
832
+ References:
833
+ .. [stm] Stumm, Werner and Morgan, James J. Aquatic Chemistry, 3rd ed, pp 165. Wiley Interscience, 1996.
834
+
835
+ """
836
+ alkalinity = ureg.Quantity(0, "mol/L")
837
+
838
+ base_cations = {
839
+ "Li[+1]",
840
+ "Na[+1]",
841
+ "K[+1]",
842
+ "Rb[+1]",
843
+ "Cs[+1]",
844
+ "Fr[+1]",
845
+ "Be[+2]",
846
+ "Mg[+2]",
847
+ "Ca[+2]",
848
+ "Sr[+2]",
849
+ "Ba[+2]",
850
+ "Ra[+2]",
851
+ }
852
+ acid_anions = {"Cl[-1]", "Br[-1]", "I[-1]", "SO4[-2]", "NO3[-1]", "ClO4[-1]", "ClO3[-1]"}
853
+
854
+ for item in self.components:
855
+ if item in base_cations.union(acid_anions):
856
+ z = self.get_property(item, "charge")
857
+ alkalinity += self.get_amount(item, "mol/L") * z
858
+
859
+ # convert the alkalinity to mg/L as CaCO3
860
+ return (alkalinity * EQUIV_WT_CACO3).to("mg/L")
861
+
862
+ @property
863
+ def hardness(self) -> Quantity:
864
+ """
865
+ Return the hardness of a solution.
866
+
867
+ Hardness is defined as the sum of the equivalent concentrations
868
+ of multivalent cations as calcium carbonate.
869
+
870
+ NOTE: at present pyEQL cannot distinguish between mg/L as CaCO3
871
+ and mg/L units. Use with caution.
872
+
873
+ Returns:
874
+ Quantity:
875
+ The hardness of the solution in mg/L as CaCO3
876
+
877
+ """
878
+ hardness = ureg.Quantity(0, "mol/L")
879
+
880
+ for item in self.components:
881
+ z = self.get_property(item, "charge")
882
+ if z > 1:
883
+ hardness += z * self.get_amount(item, "mol/L")
884
+
885
+ # convert the hardness to mg/L as CaCO3
886
+ return (hardness * EQUIV_WT_CACO3).to("mg/L")
887
+
888
+ @property
889
+ def total_dissolved_solids(self) -> Quantity:
890
+ """
891
+ Total dissolved solids in mg/L (equivalent to ppm) including both charged and uncharged species.
892
+
893
+ The TDS is defined as the sum of the concentrations of all aqueous solutes (not including the solvent),
894
+ except for H[+1] and OH[-1]].
895
+ """
896
+ tds = ureg.Quantity(0, "mg/L")
897
+ for s in self.components:
898
+ # ignore pure water and dissolved gases, but not CO2
899
+ if s in ["H2O(aq)", "H[+1]", "OH[-1]"]:
900
+ continue
901
+ tds += self.get_amount(s, "mg/L")
902
+
903
+ return tds
904
+
905
+ @property
906
+ def TDS(self) -> Quantity:
907
+ """Alias of :py:meth:`total_dissolved_solids`."""
908
+ return self.total_dissolved_solids
909
+
910
+ @property
911
+ def debye_length(self) -> Quantity:
912
+ r"""
913
+ Return the Debye length of a solution.
914
+
915
+ Debye length is calculated as [wk3]_
916
+
917
+ .. math::
918
+
919
+ \kappa^{-1} = \sqrt({\epsilon_r \epsilon_o k_B T \over (2 N_A e^2 I)})
920
+
921
+ where :math:`I` is the ionic strength, :math:`\epsilon_r` and :math:`\epsilon_r`
922
+ are the relative permittivity and vacuum permittivity, :math:`k_B` is the
923
+ Boltzmann constant, and :math:`T` is the temperature, :math:`e` is the
924
+ elementary charge, and :math:`N_A` is Avogadro's number.
925
+
926
+ Returns The Debye length, in nanometers.
927
+
928
+ References:
929
+ .. [wk3] https://en.wikipedia.org/wiki/Debye_length#In_an_electrolyte_solution
930
+
931
+ See Also:
932
+ :attr:`ionic_strength`
933
+ :attr:`dielectric_constant`
934
+
935
+ """
936
+ # to preserve dimensionality, convert the ionic strength into mol/L units
937
+ ionic_strength = ureg.Quantity(self.ionic_strength.magnitude, "mol/L")
938
+ dielectric_constant = self.dielectric_constant
939
+
940
+ debye_length = (
941
+ dielectric_constant
942
+ * ureg.epsilon_0
943
+ * ureg.k
944
+ * self.temperature
945
+ / (2 * ureg.N_A * ureg.e**2 * ionic_strength)
946
+ ) ** 0.5
947
+
948
+ return debye_length.to("nm")
949
+
950
+ @property
951
+ def bjerrum_length(self) -> Quantity:
952
+ r"""
953
+ Return the Bjerrum length of a solution.
954
+
955
+ Bjerrum length represents the distance at which electrostatic
956
+ interactions between particles become comparable in magnitude
957
+ to the thermal energy.:math:`\lambda_B` is calculated as
958
+
959
+ .. math::
960
+
961
+ \lambda_B = {e^2 \over (4 \pi \epsilon_r \epsilon_o k_B T)}
962
+
963
+ where :math:`e` is the fundamental charge, :math:`\epsilon_r` and :math:`\epsilon_r`
964
+ are the relative permittivity and vacuum permittivity, :math:`k_B` is the
965
+ Boltzmann constant, and :math:`T` is the temperature.
966
+
967
+ Returns:
968
+ Quantity:
969
+ The Bjerrum length, in nanometers.
970
+
971
+ References:
972
+ https://en.wikipedia.org/wiki/Bjerrum_length
973
+
974
+ Examples:
975
+ >>> s1 = pyEQL.Solution()
976
+ >>> s1.bjerrum_length
977
+ <Quantity(0.7152793009386953, 'nanometer')>
978
+
979
+ See Also:
980
+ :attr:`dielectric_constant`
981
+
982
+ """
983
+ bjerrum_length = ureg.e**2 / (4 * np.pi * self.dielectric_constant * ureg.epsilon_0 * ureg.k * self.temperature)
984
+ return bjerrum_length.to("nm")
985
+
986
+ @property
987
+ def osmotic_pressure(self) -> Quantity:
988
+ r"""
989
+ Return the osmotic pressure of the solution relative to pure water.
990
+
991
+ Returns:
992
+ The osmotic pressure of the solution relative to pure water in Pa
993
+
994
+ See Also:
995
+ :attr:`get_water_activity`
996
+ :attr:`get_osmotic_coefficient`
997
+ :attr:`get_salt`
998
+
999
+ Notes:
1000
+ Osmotic pressure is calculated based on the water activity [sata]_ [wk]_
1001
+
1002
+ .. math:: \Pi = -\frac{RT}{V_{w}} \ln a_{w}
1003
+
1004
+ Where :math:`\Pi` is the osmotic pressure, :math:`V_{w}` is the partial
1005
+ molar volume of water (18.2 cm**3/mol), and :math:`a_{w}` is the water
1006
+ activity.
1007
+
1008
+ References:
1009
+ .. [sata] Sata, Toshikatsu. Ion Exchange Membranes: Preparation, Characterization, and Modification.
1010
+ Royal Society of Chemistry, 2004, p. 10.
1011
+
1012
+ .. [wk] https://en.wikipedia.org/wiki/Osmotic_pressure#Derivation_of_the_van_'t_Hoff_formula
1013
+
1014
+ Examples:
1015
+ >>> s1=pyEQL.Solution()
1016
+ >>> s1.osmotic_pressure
1017
+ <Quantity(0.495791416, 'pascal')>
1018
+
1019
+ >>> s1 = pyEQL.Solution([['Na+','0.2 mol/kg'],['Cl-','0.2 mol/kg']])
1020
+ >>> soln.osmotic_pressure
1021
+ <Quantity(906516.7318131207, 'pascal')>
1022
+ """
1023
+ partial_molar_volume_water = self.get_property(self.solvent, "size.molar_volume")
1024
+
1025
+ osmotic_pressure = (
1026
+ -1 * ureg.R * self.temperature / partial_molar_volume_water * np.log(self.get_water_activity())
1027
+ )
1028
+ self.logger.debug(
1029
+ f"Calculated osmotic pressure of solution as {osmotic_pressure} Pa at T= {self.temperature} degrees C"
1030
+ )
1031
+ return osmotic_pressure.to("Pa")
1032
+
1033
+ # Concentration Methods
1034
+
1035
+ def get_amount(self, solute: str, units: str = "mol/L") -> Quantity:
1036
+ """
1037
+ Return the amount of 'solute' in the parent solution.
1038
+
1039
+ The amount of a solute can be given in a variety of unit types.
1040
+ 1. substance per volume (e.g., 'mol/L', 'M')
1041
+ 2. equivalents (i.e., moles of charge) per volume (e.g., 'eq/L', 'meq/L')
1042
+ 3. substance per mass of solvent (e.g., 'mol/kg', 'm')
1043
+ 4. mass of substance (e.g., 'kg')
1044
+ 5. moles of substance ('mol')
1045
+ 6. mole fraction ('fraction')
1046
+ 7. percent by weight (%)
1047
+ 8. number of molecules ('count')
1048
+ 9. "parts-per-x" units, where ppm = mg/L, ppb = ug/L ppt = ng/L
1049
+
1050
+ Args:
1051
+ solute : str
1052
+ String representing the name of the solute of interest
1053
+ units : str
1054
+ Units desired for the output. Examples of valid units are
1055
+ 'mol/L','mol/kg','mol', 'kg', and 'g/L'
1056
+ Use 'fraction' to return the mole fraction.
1057
+ Use '%' to return the mass percent
1058
+
1059
+ Returns:
1060
+ The amount of the solute in question, in the specified units
1061
+
1062
+ See Also:
1063
+ :attr:`mass`
1064
+ :meth:`add_amount`
1065
+ :meth:`set_amount`
1066
+ :meth:`get_total_amount`
1067
+ :meth:`get_osmolarity`
1068
+ :meth:`get_osmolality`
1069
+ :meth:`get_total_moles_solute`
1070
+ :func:`pyEQL.utils.interpret_units`
1071
+ """
1072
+ z = 1
1073
+ # sanitized unit to be passed to pint
1074
+ if "eq" in units:
1075
+ _units = units.replace("eq", "mol")
1076
+ z = self.get_property(solute, "charge")
1077
+ if z == 0: # uncharged solutes have zero equiv concentration
1078
+ return ureg.Quantity(0, _units)
1079
+ else:
1080
+ _units = interpret_units(units)
1081
+
1082
+ # retrieve the number of moles of solute and its molecular weight
1083
+ try:
1084
+ moles = ureg.Quantity(self.components[solute], "mol")
1085
+ # if the solute is not present in the solution, we'll get a KeyError
1086
+ # In that case, the amount is zero
1087
+ except KeyError:
1088
+ try:
1089
+ return ureg.Quantity(0, _units)
1090
+ except DimensionalityError:
1091
+ self.logger.error(
1092
+ f"Unsupported unit {units} specified for zero-concentration solute {solute}. Returned 0."
1093
+ )
1094
+ return ureg.Quantity(0, "dimensionless")
1095
+
1096
+ # with pint unit conversions enabled, we just pass the unit to pint
1097
+ # the logic tests here ensure that only the required arguments are
1098
+ # passed to pint for the unit conversion. This avoids unnecessary
1099
+ # function calls.
1100
+ if units == "count":
1101
+ return round((moles * ureg.N_A).to("dimensionless"), 0)
1102
+ if units == "fraction":
1103
+ return moles / (self.get_moles_solvent() + self.get_total_moles_solute())
1104
+ mw = self.get_property(solute, "molecular_weight").to("g/mol")
1105
+ if units == "%":
1106
+ return moles.to("kg", "chem", mw=mw) / self.mass.to("kg") * 100
1107
+ qty = ureg.Quantity(_units)
1108
+ if _units in ["eq", "mol", "moles"] or qty.check("[substance]"):
1109
+ return z * moles.to(_units)
1110
+ if (
1111
+ _units in ["mol/L", "eq/L", "g/L", "mg/L", "ug/L"]
1112
+ or qty.check("[substance]/[length]**3")
1113
+ or qty.check("[mass]/[length]**3")
1114
+ ):
1115
+ return z * moles.to(_units, "chem", mw=mw, volume=self.volume)
1116
+ if _units in ["mol/kg"] or qty.check("[substance]/[mass]") or qty.check("[mass]/[mass]"):
1117
+ return z * moles.to(_units, "chem", mw=mw, solvent_mass=self.solvent_mass)
1118
+ if _units in ["kg", "g"] or qty.check("[mass]"):
1119
+ return moles.to(_units, "chem", mw=mw)
1120
+
1121
+ raise ValueError(f"Unsupported unit {units} specified for get_amount")
1122
+
1123
+ def get_components_by_element(
1124
+ self, nested: bool = False
1125
+ ) -> dict[str, list[str]] | dict[str, dict[float | str, list[str]]]:
1126
+ """
1127
+ Return a list of all species associated with a given element.
1128
+
1129
+ Args:
1130
+ nested : bool
1131
+ Whether to return a nested dictionary of <element>
1132
+ to <valence> => <list of species> mapping. False by default.
1133
+
1134
+ Returns:
1135
+ A mapping of element to a list of species in the solution.
1136
+
1137
+ If nested is False (default), elements (keys) are suffixed with
1138
+ their oxidation state in parentheses, e.g.,
1139
+
1140
+ {"Na(1.0)":["Na[+1]", "NaOH(aq)"]}
1141
+
1142
+ If nested is True, the dictionary is nested, e.g.,
1143
+
1144
+ {"Na": [{1:["Na[+1]", "NaOH(aq)"]}]}.
1145
+
1146
+ Note that the valence may be a string, assuming the value "unk"
1147
+ denoting an unknown oxidation state.
1148
+
1149
+ Species associated with each element are sorted in descending order of the amount
1150
+ present (i.e., the first species listed is the most abundant).
1151
+ """
1152
+ d = {}
1153
+ # by sorting the components according to amount, we ensure that the species
1154
+ # are sorted in descending order of concentration in the resulting dict
1155
+ for s in self.components:
1156
+ # determine the element and oxidation state
1157
+ elements = self.get_property(s, "elements")
1158
+
1159
+ for el in elements:
1160
+ try:
1161
+ oxi_states = self.get_property(s, "oxi_state_guesses")
1162
+ oxi_state = oxi_states.get(el, UNKNOWN_OXI_STATE)
1163
+ except (TypeError, IndexError):
1164
+ self.logger.error(f"No oxidation state found for element {el}. Assigning '{UNKNOWN_OXI_STATE}'")
1165
+ oxi_state = UNKNOWN_OXI_STATE
1166
+ if d.get(el):
1167
+ if d[el].get(oxi_state):
1168
+ d[el][oxi_state].append(s)
1169
+ else:
1170
+ d[el][oxi_state] = [s]
1171
+ else:
1172
+ d[el] = {oxi_state: [s]}
1173
+
1174
+ if nested:
1175
+ return d
1176
+ return {f"{el}({val})": species for el, val_dict in d.items() for val, species in val_dict.items()}
1177
+
1178
+ def get_el_amt_dict(self, nested: bool = False) -> dict[str, float] | dict[str, dict[float | str, float]]:
1179
+ """
1180
+ Return a dict of Element: amount in mol.
1181
+
1182
+ Args:
1183
+ nested : bool
1184
+ Whether to return a nested dictionary of <element>
1185
+ to <valence> => amount mapping. False by default.
1186
+
1187
+ Returns:
1188
+ A mapping of element to its amount in moles in the solution.
1189
+
1190
+ If nested is False (default), elements (keys) are suffixed with
1191
+ their oxidation state in parentheses, e.g.,
1192
+
1193
+ {"Fe(2.0)": 0.354, "Cl(-1.0)": 0.708}
1194
+
1195
+ If nested is True, the dictionary is nested, e.g.,
1196
+
1197
+ {"Fe": {2.0: 0.354}, "Cl": {-1.0: 0.708}}.}
1198
+
1199
+ Note that the valence may be a string, assuming the value "unk"
1200
+ denoting an unknown oxidation state.
1201
+ """
1202
+ d = {}
1203
+ for s, mol in self.components.items():
1204
+ elements = self.get_property(s, "elements")
1205
+ pmg_ion_dict = self.get_property(s, "pmg_ion")
1206
+ oxi_states = self.get_property(s, "oxi_state_guesses")
1207
+
1208
+ for el in elements:
1209
+ # stoichiometric coefficient, mol element per mol solute
1210
+ stoich = pmg_ion_dict.get(el)
1211
+ try:
1212
+ oxi_states = self.get_property(s, "oxi_state_guesses")
1213
+ oxi_state = oxi_states.get(el, UNKNOWN_OXI_STATE)
1214
+ except (TypeError, IndexError):
1215
+ self.logger.error(f"No oxidation state found for element {el}. Assigning '{UNKNOWN_OXI_STATE}'")
1216
+ oxi_state = UNKNOWN_OXI_STATE
1217
+ if d.get(el):
1218
+ if d[el].get(oxi_state):
1219
+ d[el][oxi_state] += stoich * mol
1220
+ else:
1221
+ d[el][oxi_state] = stoich * mol
1222
+ else:
1223
+ d[el] = {oxi_state: stoich * mol}
1224
+
1225
+ if nested:
1226
+ return d
1227
+ return {f"{el}({val})": amount for el, val_dict in d.items() for val, amount in val_dict.items()}
1228
+
1229
+ def get_total_amount(self, element: str, units: str) -> Quantity:
1230
+ """
1231
+ Return the total amount of 'element' (across all solutes) in the solution.
1232
+
1233
+ Args:
1234
+ element: The symbol of the element of interest. The symbol can optionally be followed by the
1235
+ oxidation state in parentheses, e.g., "Na(1.0)", "Fe(2.0)", or "O(0.0)". If no oxidation state
1236
+ is given, the total concentration of the element (over all oxidation states) is returned.
1237
+ units : str
1238
+ Units desired for the output. Any unit understood by `get_amount` can be used. Examples of valid
1239
+ units are 'mol/L','mol/kg','mol', 'kg', and 'g/L'.
1240
+
1241
+ Returns:
1242
+ The total amount of the element in the solution, in the specified units
1243
+
1244
+ See Also:
1245
+ :meth:`get_amount`
1246
+ :func:`pyEQL.utils.interpret_units`
1247
+ """
1248
+ _units = interpret_units(units)
1249
+ TOT: Quantity = ureg.Quantity(0, _units)
1250
+
1251
+ # standardize the element formula and units
1252
+ el = str(Element(element.split("(")[0]))
1253
+ units = interpret_units(units)
1254
+
1255
+ # enumerate the species whose concentrations we need
1256
+ comp_by_element = self.get_components_by_element()
1257
+
1258
+ # compile list of species in different ways depending whether there is an oxidation state
1259
+ if "(" in element and UNKNOWN_OXI_STATE not in element:
1260
+ ox = float(element.split("(")[-1].split(")")[0])
1261
+ key = f"{el}({ox})"
1262
+ species = comp_by_element.get(key, [])
1263
+ else:
1264
+ species = []
1265
+ for k, v in comp_by_element.items():
1266
+ if k.split("(")[0] == el:
1267
+ species.extend(v)
1268
+
1269
+ # loop through the species of interest, adding moles of element
1270
+ for item, amt in self.components.items():
1271
+ if item in species:
1272
+ amt = self.get_amount(item, units)
1273
+ ion = Ion.from_formula(item)
1274
+
1275
+ # convert the solute amount into the amount of element by
1276
+ # either the mole / mole or weight ratio
1277
+ if ureg.Quantity(units).dimensionality in (
1278
+ "[substance]",
1279
+ "[substance]/[length]**3",
1280
+ "[substance]/[mass]",
1281
+ ):
1282
+ TOT += amt * ion.get_el_amt_dict()[el] # returns {el: mol per formula unit}
1283
+
1284
+ elif ureg.Quantity(units).dimensionality in (
1285
+ "[mass]",
1286
+ "[mass]/[length]**3",
1287
+ "[mass]/[mass]",
1288
+ ):
1289
+ TOT += amt * ion.to_weight_dict[el] # returns {el: wt fraction}
1290
+
1291
+ return TOT
1292
+
1293
+ def add_solute(self, formula: str, amount: str):
1294
+ """Primary method for adding substances to a pyEQL solution.
1295
+
1296
+ Args:
1297
+ formula (str): Chemical formula for the solute. Charged species must contain a + or - and
1298
+ (for polyvalent solutes) a number representing the net charge (e.g. 'SO4-2').
1299
+ amount (str): The amount of substance in the specified unit system. The string should contain
1300
+ both a quantity and a pint-compatible representation of a ureg. e.g. '5 mol/kg' or '0.1 g/L'.
1301
+ """
1302
+ # if units are given on a per-volume basis,
1303
+ # iteratively solve for the amount of solute that will preserve the
1304
+ # original volume and result in the desired concentration
1305
+ if ureg.Quantity(amount).dimensionality in (
1306
+ "[substance]/[length]**3",
1307
+ "[mass]/[length]**3",
1308
+ ):
1309
+ # store the original volume for later
1310
+ orig_volume = self.volume
1311
+
1312
+ # add the new solute
1313
+ quantity = ureg.Quantity(amount)
1314
+ mw = self.get_property(formula, "molecular_weight") # returns a quantity
1315
+ target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
1316
+ self.components[formula] = target_mol.to("moles").magnitude
1317
+
1318
+ # calculate the volume occupied by all the solutes
1319
+ solute_vol = self._get_solute_volume()
1320
+
1321
+ # determine the volume of solvent that will preserve the original volume
1322
+ target_vol = orig_volume - solute_vol
1323
+
1324
+ # adjust the amount of solvent
1325
+ # density is returned in kg/m3 = g/L
1326
+ target_mass = target_vol * ureg.Quantity(self.water_substance.rho, "g/L")
1327
+ # mw = ureg.Quantity(self.get_property(self.solvent_name, "molecular_weight"))
1328
+ mw = self.get_property(self.solvent, "molecular_weight")
1329
+ if mw is None:
1330
+ raise ValueError(f"Molecular weight for solvent {self.solvent} not found in database. Cannot proceed.")
1331
+ target_mol = target_mass.to("g") / mw.to("g/mol")
1332
+ self.components[self.solvent] = target_mol.magnitude
1333
+
1334
+ else:
1335
+ # add the new solute
1336
+ quantity = ureg.Quantity(amount)
1337
+ mw = ureg.Quantity(self.get_property(formula, "molecular_weight"))
1338
+ target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
1339
+ self.components[formula] = target_mol.to("moles").magnitude
1340
+
1341
+ # update the volume to account for the space occupied by all the solutes
1342
+ # make sure that there is still solvent present in the first place
1343
+ if self.solvent_mass <= ureg.Quantity(0, "kg"):
1344
+ self.logger.error("All solvent has been depleted from the solution")
1345
+ return
1346
+ # set the volume recalculation flag
1347
+ self.volume_update_required = True
1348
+
1349
+ def add_amount(self, solute: str, amount: str):
1350
+ """
1351
+ Add the amount of 'solute' to the parent solution.
1352
+
1353
+ Args:
1354
+ solute : str
1355
+ String representing the name of the solute of interest
1356
+ amount : str quantity
1357
+ String representing the concentration desired, e.g. '1 mol/kg'
1358
+ If the units are given on a per-volume basis, the solution
1359
+ volume is not recalculated
1360
+ If the units are given on a mass, substance, per-mass, or
1361
+ per-substance basis, then the solution volume is recalculated
1362
+ based on the new composition
1363
+
1364
+ Returns:
1365
+ Nothing. The concentration of solute is modified.
1366
+ """
1367
+ # Get the current amount of the solute
1368
+ current_amt = self.get_amount(solute, amount.split(" ")[1])
1369
+ if current_amt.magnitude == 0:
1370
+ self.logger.warning(f"Add new solute {solute} to the solution")
1371
+ new_amt = ureg.Quantity(amount) + current_amt
1372
+ self.set_amount(solute, new_amt)
1373
+
1374
+ def set_amount(self, solute: str, amount: str):
1375
+ """
1376
+ Set the amount of 'solute' in the parent solution.
1377
+
1378
+ Args:
1379
+ solute : str
1380
+ String representing the name of the solute of interest
1381
+ amount : str Quantity
1382
+ String representing the concentration desired, e.g. '1 mol/kg'
1383
+ If the units are given on a per-volume basis, the solution
1384
+ volume is not recalculated and the molar concentrations of
1385
+ other components in the solution are not altered, while the
1386
+ molal concentrations are modified.
1387
+
1388
+ If the units are given on a mass, substance, per-mass, or
1389
+ per-substance basis, then the solution volume is recalculated
1390
+ based on the new composition and the molal concentrations of
1391
+ other components are not altered, while the molar concentrations
1392
+ are modified.
1393
+
1394
+ Returns:
1395
+ Nothing. The concentration of solute is modified.
1396
+
1397
+ """
1398
+ # raise an error if a negative amount is specified
1399
+ if ureg.Quantity(amount).magnitude < 0:
1400
+ raise ValueError(f"Negative amount specified for solute {solute}. Concentration not changed.")
1401
+
1402
+ # if units are given on a per-volume basis,
1403
+ # iteratively solve for the amount of solute that will preserve the
1404
+ # original volume and result in the desired concentration
1405
+ if ureg.Quantity(amount).dimensionality in (
1406
+ "[substance]/[length]**3",
1407
+ "[mass]/[length]**3",
1408
+ ):
1409
+ # store the original volume for later
1410
+ orig_volume = self.volume
1411
+
1412
+ # change the amount of the solute present to match the desired amount
1413
+ self.components[solute] = (
1414
+ ureg.Quantity(amount)
1415
+ .to(
1416
+ "moles",
1417
+ "chem",
1418
+ mw=ureg.Quantity(self.get_property(solute, "molecular_weight")),
1419
+ volume=self.volume,
1420
+ solvent_mass=self.solvent_mass,
1421
+ )
1422
+ .magnitude
1423
+ )
1424
+
1425
+ # calculate the volume occupied by all the solutes
1426
+ solute_vol = self._get_solute_volume()
1427
+
1428
+ # determine the volume of solvent that will preserve the original volume
1429
+ target_vol = orig_volume - solute_vol
1430
+
1431
+ # adjust the amount of solvent
1432
+ target_mass = target_vol * ureg.Quantity(self.water_substance.rho, "g/L")
1433
+ mw = self.get_property(self.solvent, "molecular_weight")
1434
+ target_mol = target_mass / mw
1435
+ self.components[self.solvent] = target_mol.to("mol").magnitude
1436
+
1437
+ else:
1438
+ # change the amount of the solute present
1439
+ self.components[solute] = (
1440
+ ureg.Quantity(amount)
1441
+ .to(
1442
+ "moles",
1443
+ "chem",
1444
+ mw=ureg.Quantity(self.get_property(solute, "molecular_weight")),
1445
+ volume=self.volume,
1446
+ solvent_mass=self.solvent_mass,
1447
+ )
1448
+ .magnitude
1449
+ )
1450
+
1451
+ # update the volume to account for the space occupied by all the solutes
1452
+ # make sure that there is still solvent present in the first place
1453
+ if self.solvent_mass <= ureg.Quantity(0, "kg"):
1454
+ self.logger.critical("All solvent has been depleted from the solution")
1455
+ return
1456
+
1457
+ self._update_volume()
1458
+
1459
+ def get_total_moles_solute(self) -> Quantity:
1460
+ """Return the total moles of all solute in the solution."""
1461
+ tot_mol = 0
1462
+ for item in self.components:
1463
+ if item != self.solvent:
1464
+ tot_mol += self.components[item]
1465
+ return ureg.Quantity(tot_mol, "mol")
1466
+
1467
+ def get_moles_solvent(self) -> Quantity:
1468
+ """
1469
+ Return the moles of solvent present in the solution.
1470
+
1471
+ Returns:
1472
+ The moles of solvent in the solution.
1473
+
1474
+ """
1475
+ return self.get_amount(self.solvent, "mol")
1476
+
1477
+ def get_osmolarity(self, activity_correction=False) -> Quantity:
1478
+ """Return the osmolarity of the solution in Osm/L.
1479
+
1480
+ Args:
1481
+ activity_correction : bool
1482
+ If TRUE, the osmotic coefficient is used to calculate the
1483
+ osmolarity. This correction is appropriate when trying to predict
1484
+ the osmolarity that would be measured from e.g. freezing point
1485
+ depression. Defaults to FALSE if omitted.
1486
+ """
1487
+ factor = self.get_osmotic_coefficient() if activity_correction is True else 1
1488
+ return factor * self.get_total_moles_solute() / self.volume.to("L")
1489
+
1490
+ def get_osmolality(self, activity_correction=False) -> Quantity:
1491
+ """Return the osmolality of the solution in Osm/kg.
1492
+
1493
+ Args:
1494
+ activity_correction : bool
1495
+ If TRUE, the osmotic coefficient is used to calculate the
1496
+ osmolarity. This correction is appropriate when trying to predict
1497
+ the osmolarity that would be measured from e.g. freezing point
1498
+ depression. Defaults to FALSE if omitted.
1499
+ """
1500
+ factor = self.get_osmotic_coefficient() if activity_correction is True else 1
1501
+ return factor * self.get_total_moles_solute() / self.solvent_mass.to("kg")
1502
+
1503
+ def get_salt(self) -> Salt:
1504
+ """
1505
+ Determine the predominant salt in a solution of ions.
1506
+
1507
+ Many empirical equations for solution properties such as activity coefficient,
1508
+ partial molar volume, or viscosity are based on the concentration of
1509
+ single salts (e.g., NaCl). When multiple ions are present (e.g., a solution
1510
+ containing Na+, Cl-, and Mg+2), it is generally not possible to directly model
1511
+ these quantities. pyEQL works around this problem by treating such solutions
1512
+ as single salt solutions.
1513
+
1514
+ The get_salt() method examines the ionic composition of a solution and returns
1515
+ an object that identifies the single most predominant salt in the solution, defined
1516
+ by the cation and anion with the highest mole fraction. The Salt object contains
1517
+ information about the stoichiometry of the salt to enable its effective concentration
1518
+ to be calculated (e.g., if a solution contains 0.5 mol/kg of Na+ and Cl-, plus traces
1519
+ of H+ and OH-, the matched salt is 0.5 mol/kg NaCl).
1520
+
1521
+ Returns:
1522
+ Salt object containing information about the parent salt.
1523
+
1524
+ See Also:
1525
+ :py:meth:`get_activity`
1526
+ :py:meth:`get_activity_coefficient`
1527
+ :py:meth:`get_water_activity`
1528
+ :py:meth:`get_osmotic_coefficient`
1529
+ :py:attr:`osmotic_pressure`
1530
+ :py:attr:`viscosity_kinematic`
1531
+
1532
+ Examples:
1533
+ >>> s1 = Solution([['Na+','0.5 mol/kg'],['Cl-','0.5 mol/kg']])
1534
+ >>> s1.get_salt()
1535
+ <pyEQL.salt_ion_match.Salt object at 0x7fe6d3542048>
1536
+ >>> s1.get_salt().formula
1537
+ 'NaCl'
1538
+ >>> s1.get_salt().nu_cation
1539
+ 1
1540
+ >>> s1.get_salt().z_anion
1541
+ -1
1542
+
1543
+ >>> s2 = pyEQL.Solution([['Na+','0.1 mol/kg'],['Mg+2','0.2 mol/kg'],['Cl-','0.5 mol/kg']])
1544
+ >>> s2.get_salt().formula
1545
+ 'MgCl2'
1546
+ >>> s2.get_salt().nu_anion
1547
+ 2
1548
+ >>> s2.get_salt().z_cation
1549
+ 2
1550
+ """
1551
+ try:
1552
+ salt: Salt = next(d["salt"] for d in self.get_salt_dict().values())
1553
+ return salt
1554
+ except StopIteration:
1555
+ return None
1556
+
1557
+ # TODO - modify? deprecate? make a salts property?
1558
+ def get_salt_dict(self, cutoff: float = 1e-6, use_totals: bool = True) -> dict[str, dict[str, float | Salt]]:
1559
+ """
1560
+ Returns a dict that represents the salts of the Solution by pairing anions and cations.
1561
+
1562
+ The ``get_salt_dict()`` method examines the ionic composition of a solution and approximates it as a set of
1563
+ salts instead of individual ions. The method returns a dictionary of Salt objects where the keys are the salt
1564
+ formulas (e.g., 'NaCl'). The Salt object contains information about the stoichiometry of the salt to
1565
+ enable its effective concentration to be calculated (e.g., 1 M MgCl2 yields 1 M Mg+2 and 2 M Cl-).
1566
+
1567
+ Args:
1568
+ cutoff: Lowest molal concentration to consider. No salts below this value will be included in the output.
1569
+ Useful for excluding analysis of trace anions. Defaults to 1e-6 (1 part per million).
1570
+ use_totals: Whether or not to base the analysis on the concentration of the predominant species of each
1571
+ element. Note that species in which a given element assumes a different oxidation state are always
1572
+ treated separately.
1573
+
1574
+ Returns:
1575
+ dict
1576
+ A dictionary of representing salts in the solution, keyed by the salt formula.
1577
+
1578
+ Notes:
1579
+ The dict maps salt formulas to dictionaries containing their amounts and composition. The amount is stored
1580
+ in moles under the key "mol", and a :class:`pyEQL.salt_ion_match.Salt` object stored under the "salt" key
1581
+ represents the composition. Salts are identified by pairing the predominant cations and anions in the
1582
+ solution, in descending order of their respective equivalent amounts.
1583
+
1584
+ Many empirical equations for solution properties such as activity coefficient, partial molar volume, or
1585
+ viscosity are based on the concentration of single salts (e.g., NaCl). When multiple ions are present
1586
+ (e.g., a solution containing Na+, Cl-, and Mg+2), it is generally not possible to directly model
1587
+ these quantities.
1588
+
1589
+ Examples:
1590
+ >>> from pyEQL import Solution
1591
+ >>> from pyEQL.salt_ion_match import Salt
1592
+ >>> s1 = Solution(
1593
+ ... solutes={
1594
+ ... 'Na[+1]': '1 mol/L',
1595
+ ... 'Cl[-1]': '1 mol/L',
1596
+ ... 'Ca[+2]': '0.01 mol/kg',
1597
+ ... 'HCO3[-1]': '0.007 mol/kg',
1598
+ ... 'CO3[-2]': '0.001 mol/kg',
1599
+ ... 'ClO[-1]': '0.001 mol/kg',
1600
+ ... }
1601
+ ... )
1602
+ >>> salt_dict = s1.get_salt_dict()
1603
+ >>> list(salt_dict) # Only returns salts with concentrations > 1e-3 m
1604
+ ['NaCl', 'Ca(HCO3)2']
1605
+ >>> salt_dict['NaCl']['salt']
1606
+ <pyEQL.salt_ion_match.Salt object at ...>
1607
+ >>> salt_dict['NaCl']['mol']
1608
+ 1.0
1609
+ >>> salt_dict = s1.get_salt_dict(cutoff=1e-4)
1610
+ >>> list(salt_dict) # Returns 'Ca(ClO)2' because of reduced cutoff and Cl has different oxidation state
1611
+ ['NaCl', 'Ca(HCO3)2', 'Ca(ClO)2']
1612
+ >>> salt_dict = s1.get_salt_dict(cutoff=1e-4, use_totals=False)
1613
+ >>> list(salt_dict) # Returns salts with minor (same oxidation state) species since use_totals=False
1614
+ ['NaCl', 'Ca(HCO3)2', 'CaCO3', 'Ca(ClO)2']
1615
+
1616
+ See Also:
1617
+ :attr:`components`
1618
+ :attr:`cations`
1619
+ :attr:`anions`
1620
+ :class:`pyEQL.salt_ion_match.Salt`
1621
+ :py:meth:`get_activity_coefficient`
1622
+ :py:meth:`get_water_activity`
1623
+ :py:meth:`get_osmotic_coefficient`
1624
+ """
1625
+ salt_dict: dict[str, dict[str, float | Salt]] = {}
1626
+
1627
+ if use_totals:
1628
+ # # use only the predominant species for each element
1629
+ components = {}
1630
+ for el, lst in self.get_components_by_element().items():
1631
+ component = lst[0]
1632
+ ion = Ion.from_formula(component)
1633
+ el_no_oxi_state = el.split("(")[0]
1634
+ nu_el = ion.get_el_amt_dict()[el_no_oxi_state]
1635
+ components[component] = self.get_total_amount(el, "mol").magnitude / nu_el
1636
+ # add H+ and OH-, which would otherwise be excluded
1637
+ for k in ["H[+1]", "OH[-1]"]:
1638
+ if self.components.get(k):
1639
+ components[k] = self.components[k]
1640
+ else:
1641
+ components = self.components
1642
+ components = dict(sorted(components.items(), key=lambda x: x[1], reverse=True))
1643
+
1644
+ # warn if something other than water is the predominant component
1645
+ if next(iter(components)) != "H2O(aq)":
1646
+ self.logger.warning("H2O(aq) is not the most prominent component in this Solution!")
1647
+
1648
+ # equivalents (charge-weighted moles) of cations and anions
1649
+ cations = set(self.cations.keys()).intersection(components.keys())
1650
+ anions = set(self.anions.keys()).intersection(components.keys())
1651
+
1652
+ # calculate the charge-weighted (equivalent) concentration of each ion
1653
+ cation_equiv = {k: self.get_property(k, "charge") * components[k] for k in cations}
1654
+ anion_equiv = {
1655
+ k: self.get_property(k, "charge") * components[k] * -1 for k in anions
1656
+ } # make sure amounts are positive
1657
+
1658
+ # sort in descending order of equivalent concentration
1659
+ cation_equiv = dict(sorted(cation_equiv.items(), key=lambda x: x[1], reverse=True))
1660
+ anion_equiv = dict(sorted(anion_equiv.items(), key=lambda x: x[1], reverse=True))
1661
+
1662
+ len_cat = len(cation_equiv)
1663
+ len_an = len(anion_equiv)
1664
+
1665
+ # start with the first cation and anion
1666
+ index_cat = 0
1667
+ index_an = 0
1668
+
1669
+ # list(dict) returns a list of [[key, value],]
1670
+ cation_list = [[k, v] for k, v in cation_equiv.items()]
1671
+ anion_list = [[k, v] for k, v in anion_equiv.items()]
1672
+ solvent_mass = self.solvent_mass.to("kg").m
1673
+ # tolerance for detecting edge cases where equilibrate() slightly changes the
1674
+ # total amount of a solute
1675
+ _atol = 1e-16
1676
+
1677
+ while index_cat < len_cat and index_an < len_an:
1678
+ c1 = cation_list[index_cat][-1]
1679
+ a1 = anion_list[index_an][-1]
1680
+ salt = Salt(cation_list[index_cat][0], anion_list[index_an][0])
1681
+
1682
+ # Use the smaller of the two amounts
1683
+ equivs_consumed = min(c1, a1)
1684
+ cation_list[index_cat][-1] -= equivs_consumed
1685
+ anion_list[index_an][-1] -= equivs_consumed
1686
+ index_an += 1 if a1 == equivs_consumed else 0
1687
+ index_cat += 1 if c1 == equivs_consumed else 0
1688
+ mol = equivs_consumed / (salt.z_cation * salt.nu_cation)
1689
+
1690
+ # filter out water and zero, effectively zero, and sub-cutoff salt amounts
1691
+ if salt.formula != "HOH" and (mol / solvent_mass + _atol) >= cutoff:
1692
+ salt_dict[salt.formula] = {"salt": salt, "mol": mol}
1693
+
1694
+ return dict(sorted(salt_dict.items(), key=lambda x: x[1]["mol"], reverse=True))
1695
+
1696
+ def equilibrate(self, **kwargs) -> None:
1697
+ """
1698
+ Update the composition of the Solution using the thermodynamic engine.
1699
+
1700
+ Any kwargs specified are passed through to self.engine.equilibrate()
1701
+
1702
+ Returns:
1703
+ Nothing. The .components attribute of the Solution is updated.
1704
+ """
1705
+ self.engine.equilibrate(self, **kwargs)
1706
+
1707
+ # Activity-related methods
1708
+ def get_activity_coefficient(
1709
+ self,
1710
+ solute: str,
1711
+ scale: Literal["molal", "molar", "fugacity", "rational"] = "molal",
1712
+ ) -> Quantity:
1713
+ """
1714
+ Return the activity coefficient of a solute in solution.
1715
+
1716
+ The model used to calculate the activity coefficient is determined by the Solution's equation of state
1717
+ engine.
1718
+
1719
+ Args:
1720
+ solute: The solute for which to retrieve the activity coefficient
1721
+ scale: The activity coefficient concentration scale
1722
+ verbose: If True, pyEQL will print a message indicating the parent salt
1723
+ that is being used for activity calculations. This option is
1724
+ useful when modeling multicomponent solutions. False by default.
1725
+
1726
+ Returns:
1727
+ Quantity: the activity coefficient as a dimensionless pint Quantity
1728
+ """
1729
+ # return unit activity coefficient if the concentration of the solute is zero
1730
+ if self.get_amount(solute, "mol").magnitude == 0:
1731
+ return ureg.Quantity(1, "dimensionless")
1732
+
1733
+ try:
1734
+ # get the molal-scale activity coefficient from the EOS engine
1735
+ molal = self.engine.get_activity_coefficient(solution=self, solute=solute)
1736
+ except (ValueError, ZeroDivisionError):
1737
+ self.logger.error("Calculation unsuccessful. Returning unit activity coefficient.", exc_info=True)
1738
+ return ureg.Quantity(1, "dimensionless")
1739
+
1740
+ # if necessary, convert the activity coefficient to another scale, and return the result
1741
+ if scale == "molal":
1742
+ return molal
1743
+ if scale == "molar":
1744
+ total_molality = self.get_total_moles_solute() / self.solvent_mass
1745
+ total_molarity = self.get_total_moles_solute() / self.volume
1746
+ return (molal * ureg.Quantity(self.water_substance.rho, "g/L") * total_molality / total_molarity).to(
1747
+ "dimensionless"
1748
+ )
1749
+ if scale == "rational":
1750
+ return molal * (1 + ureg.Quantity(0.018015, "kg/mol") * self.get_total_moles_solute() / self.solvent_mass)
1751
+
1752
+ raise ValueError("Invalid scale argument. Pass 'molal', 'molar', or 'rational'.")
1753
+
1754
+ def get_activity(
1755
+ self,
1756
+ solute: str,
1757
+ scale: Literal["molal", "molar", "rational"] = "molal",
1758
+ ) -> Quantity:
1759
+ """
1760
+ Return the thermodynamic activity of the solute in solution on the chosen concentration scale.
1761
+
1762
+ Args:
1763
+ solute:
1764
+ String representing the name of the solute of interest
1765
+ scale:
1766
+ The concentration scale for the returned activity.
1767
+ Valid options are "molal", "molar", and "rational" (i.e., mole fraction).
1768
+ By default, the molal scale activity is returned.
1769
+ verbose:
1770
+ If True, pyEQL will print a message indicating the parent salt
1771
+ that is being used for activity calculations. This option is
1772
+ useful when modeling multicomponent solutions. False by default.
1773
+
1774
+ Returns:
1775
+ The thermodynamic activity of the solute in question (dimensionless Quantity)
1776
+
1777
+ Notes:
1778
+ The thermodynamic activity depends on the concentration scale used [rs]_ .
1779
+ By default, the ionic strength, activity coefficients, and activities are all
1780
+ calculated based on the molal (mol/kg) concentration scale.
1781
+
1782
+ References:
1783
+ .. [rs] Robinson, R. A.; Stokes, R. H. Electrolyte Solutions: Second Revised
1784
+ Edition; Butterworths: London, 1968, p.32.
1785
+
1786
+ See Also:
1787
+ :attr:`ionic_strength`
1788
+ :py:meth:`get_activity_coefficient`
1789
+ :py:meth:`get_salt`
1790
+
1791
+ """
1792
+ # switch to the water activity function if the species is H2O
1793
+ if solute in ["H2O(aq)", "water", "H2O", "HOH"]:
1794
+ activity = self.get_water_activity()
1795
+ else:
1796
+ # determine the concentration units to use based on the desired scale
1797
+ if scale == "molal":
1798
+ units = "mol/kg"
1799
+ elif scale == "molar":
1800
+ units = "mol/L"
1801
+ elif scale == "rational":
1802
+ units = "fraction"
1803
+ else:
1804
+ raise ValueError("Invalid scale argument. Pass 'molal', 'molar', or 'rational'.")
1805
+
1806
+ activity = (self.get_activity_coefficient(solute, scale=scale) * self.get_amount(solute, units)).magnitude
1807
+ self.logger.debug(f"Calculated {scale} scale activity of solute {solute} as {activity}")
1808
+
1809
+ return ureg.Quantity(activity, "dimensionless")
1810
+
1811
+ # TODO - engine method
1812
+ def get_osmotic_coefficient(self, scale: Literal["molal", "molar", "rational"] = "molal") -> Quantity:
1813
+ """
1814
+ Return the osmotic coefficient of an aqueous solution.
1815
+
1816
+ The method used depends on the Solution object's equation of state engine.
1817
+
1818
+ """
1819
+ molal_phi = self.engine.get_osmotic_coefficient(self)
1820
+
1821
+ if scale == "molal":
1822
+ return molal_phi
1823
+ if scale == "rational":
1824
+ return (
1825
+ -molal_phi
1826
+ * ureg.Quantity(0.018015, "kg/mol")
1827
+ * self.get_total_moles_solute()
1828
+ / self.solvent_mass
1829
+ / np.log(self.get_amount(self.solvent, "fraction"))
1830
+ )
1831
+ if scale == "fugacity":
1832
+ return np.exp(
1833
+ -molal_phi * ureg.Quantity(0.018015, "kg/mol") * self.get_total_moles_solute() / self.solvent_mass
1834
+ - np.log(self.get_amount(self.solvent, "fraction"))
1835
+ ) * ureg.Quantity(1, "dimensionless")
1836
+
1837
+ raise ValueError("Invalid scale argument. Pass 'molal', 'rational', or 'fugacity'.")
1838
+
1839
+ def get_water_activity(self) -> Quantity:
1840
+ r"""
1841
+ Return the water activity.
1842
+
1843
+ Returns:
1844
+ Quantity:
1845
+ The thermodynamic activity of water in the solution.
1846
+
1847
+ See Also:
1848
+ :attr:`ionic_strength`
1849
+ :py:meth:`get_activity_coefficient`
1850
+ :py:meth:`get_salt`
1851
+
1852
+ Notes:
1853
+ Water activity is related to the osmotic coefficient in a solution containing i solutes by:
1854
+
1855
+ .. math:: \ln a_{w} = - \Phi M_{w} \sum_{i} m_{i}
1856
+
1857
+ Where :math:`M_{w}` is the molar mass of water (0.018015 kg/mol) and :math:`m_{i}` is the molal
1858
+ concentration of each species.
1859
+
1860
+ If appropriate Pitzer model parameters are not available, the
1861
+ water activity is assumed equal to the mole fraction of water.
1862
+
1863
+ References:
1864
+ Blandamer, Mike J., Engberts, Jan B. F. N., Gleeson, Peter T., Reis, Joao Carlos R., 2005. "Activity of
1865
+ water in aqueous systems: A frequently neglected property." *Chemical Society Review* 34, 440-458.
1866
+
1867
+ Examples:
1868
+ >>> s1 = pyEQL.Solution([['Na+','0.3 mol/kg'],['Cl-','0.3 mol/kg']])
1869
+ >>> s1.get_water_activity()
1870
+ <Quantity(0.9900944932888518, 'dimensionless')>
1871
+ """
1872
+ osmotic_coefficient = self.get_osmotic_coefficient()
1873
+
1874
+ if osmotic_coefficient == 1:
1875
+ self.logger.warning("Pitzer parameters not found. Water activity set equal to mole fraction")
1876
+ return self.get_amount("H2O", "fraction")
1877
+
1878
+ concentration_sum = np.sum([mol for item, mol in self.components.items() if item != "H2O(aq)"])
1879
+ concentration_sum /= self.solvent_mass.to("kg").magnitude # converts to mol/kg
1880
+
1881
+ self.logger.debug("Calculated water activity using osmotic coefficient")
1882
+
1883
+ return ureg.Quantity(np.exp(-osmotic_coefficient * 0.018015 * concentration_sum), "dimensionless")
1884
+
1885
+ def get_chemical_potential_energy(self, activity_correction: bool = True) -> Quantity:
1886
+ r"""
1887
+ Return the total chemical potential energy of a solution (not including
1888
+ pressure or electric effects).
1889
+
1890
+ Args:
1891
+ activity_correction : bool, optional
1892
+ If True, activities will be used to calculate the true chemical
1893
+ potential. If False, mole fraction will be used, resulting in
1894
+ a calculation of the ideal chemical potential.
1895
+
1896
+ Returns:
1897
+ Quantity
1898
+ The actual or ideal chemical potential energy of the solution, in Joules.
1899
+
1900
+ Notes:
1901
+ The chemical potential energy (related to the Gibbs mixing energy) is
1902
+ calculated as follows: [koga]_
1903
+
1904
+ .. math:: E = R T \sum_i n_i \ln a_i
1905
+
1906
+ or
1907
+
1908
+ .. math:: E = R T \sum_i n_i \ln x_i
1909
+
1910
+ Where :math:`n` is the number of moles of substance, :math:`T` is the temperature in kelvin,
1911
+ :math:`R` the ideal gas constant, :math:`x` the mole fraction, and :math:`a` the activity of
1912
+ each component.
1913
+
1914
+ Note that dissociated ions must be counted as separate components,
1915
+ so a simple salt dissolved in water is a three component solution (cation,
1916
+ anion, and water).
1917
+
1918
+ References:
1919
+ .. [koga] Koga, Yoshikata, 2007. *Solution Thermodynamics and its Application to Aqueous Solutions:*
1920
+ *A differential approach.* Elsevier, 2007, pp. 23-37.
1921
+ """
1922
+ E = ureg.Quantity(0, "J")
1923
+
1924
+ # loop through all the components and add their potential energy
1925
+ for item in self.components:
1926
+ try:
1927
+ if activity_correction is True:
1928
+ E += (
1929
+ ureg.R
1930
+ * self.temperature.to("K")
1931
+ * self.get_amount(item, "mol")
1932
+ * np.log(self.get_activity(item))
1933
+ )
1934
+ else:
1935
+ E += (
1936
+ ureg.R
1937
+ * self.temperature.to("K")
1938
+ * self.get_amount(item, "mol")
1939
+ * np.log(self.get_amount(item, "fraction"))
1940
+ )
1941
+ # If we have a solute with zero concentration, we will get a ValueError
1942
+ except ValueError:
1943
+ continue
1944
+
1945
+ return E.to("J")
1946
+
1947
+ def _get_property(self, solute: str, name: str) -> Any | None:
1948
+ """Retrieve a thermodynamic property (such as diffusion coefficient)
1949
+ for solute, and adjust it from the reference conditions to the conditions
1950
+ of the solution.
1951
+
1952
+ Args:
1953
+ solute: str
1954
+ String representing the chemical formula of the solute species
1955
+ name: str
1956
+ The name of the property needed, e.g.
1957
+ 'diffusion coefficient'
1958
+
1959
+ Returns:
1960
+ Quantity: The desired parameter or None if not found
1961
+
1962
+ """
1963
+ base_temperature = ureg.Quantity(25, "degC")
1964
+ # base_pressure = ureg.Quantity("1 atm")
1965
+
1966
+ # query the database using the standardized formula
1967
+ rform = standardize_formula(solute)
1968
+ # TODO - there seems to be a bug in mongomock / JSONStore wherein properties does
1969
+ # not properly return dot-notation fields, e.g. size.molar_volume will not be returned.
1970
+ # also $exists:True does not properly return dot notated fields.
1971
+ # for now, just set properties=[] to return everything
1972
+ # data = list(self.database.query({"formula": rform, name: {"$ne": None}}, properties=["formula", name]))
1973
+ data = list(self.database.query({"formula": rform, name: {"$ne": None}}))
1974
+ # formulas should always be unique in the database. len==0 indicates no
1975
+ # data. len>1 indicates duplicate data.
1976
+ if len(data) > 1:
1977
+ self.logger.warning(f"Duplicate database entries for solute {solute} found!")
1978
+ if len(data) == 0:
1979
+ # TODO - add molar volume of water to database?
1980
+ if name == "size.molar_volume" and rform == "H2O(aq)":
1981
+ # calculate the partial molar volume for water since it isn't in the database
1982
+ vol = ureg.Quantity(self.get_property("H2O", "molecular_weight")) / (
1983
+ ureg.Quantity(self.water_substance.rho, "g/L")
1984
+ )
1985
+
1986
+ return vol.to("cm **3 / mol")
1987
+
1988
+ # try to determine basic properties using pymatgen
1989
+ doc = Solute.from_formula(rform).as_dict()
1990
+ data = [doc]
1991
+
1992
+ doc: dict = data[0]
1993
+
1994
+ try:
1995
+ # perform temperature-corrections or other adjustments for certain
1996
+ # parameter types
1997
+ if name == "transport.diffusion_coefficient":
1998
+ data = doc["transport"]["diffusion_coefficient"]
1999
+ if data is not None:
2000
+ return ureg.Quantity(data["value"]).to("m**2/s")
2001
+
2002
+ # just return the base-value molar volume for now; find a way to adjust for concentration later
2003
+ if name == "size.molar_volume":
2004
+ data = doc["size"]["molar_volume"]
2005
+ if data is not None:
2006
+ base_value = ureg.Quantity(doc["size"]["molar_volume"].get("value"))
2007
+ if self.temperature != base_temperature:
2008
+ self.logger.warning(f"Partial molar volume for species {solute} not corrected for temperature")
2009
+ return base_value
2010
+ return data
2011
+
2012
+ if name == "model_parameters.dielectric_zuber":
2013
+ return ureg.Quantity(doc["model_parameters"]["dielectric_zuber"]["value"])
2014
+
2015
+ if name == "model_parameters.activity_pitzer":
2016
+ # return a dict
2017
+ if doc["model_parameters"]["activity_pitzer"].get("Beta0") is not None:
2018
+ return doc["model_parameters"]["activity_pitzer"]
2019
+ return None
2020
+
2021
+ if name == "model_parameters.molar_volume_pitzer":
2022
+ # return a dict
2023
+ if doc["model_parameters"]["molar_volume_pitzer"].get("Beta0") is not None:
2024
+ return doc["model_parameters"]["molar_volume_pitzer"]
2025
+ return None
2026
+
2027
+ if name == "molecular_weight":
2028
+ return ureg.Quantity(doc.get(name))
2029
+
2030
+ if name == "elements":
2031
+ return doc.get(name)
2032
+
2033
+ if name == "oxi_state_guesses":
2034
+ # ensure that all oxi states are returned as floats
2035
+ return {k: float(v) for k, v in doc.get(name).items()}
2036
+
2037
+ # for parameters not named above, just return the base value
2038
+ if name == "pmg_ion" or not isinstance(doc.get(name), dict):
2039
+ # if the queried value is not a dict, it is a root level key and should be returned as is
2040
+ return doc.get(name)
2041
+
2042
+ val = doc[name].get("value")
2043
+ # self.logger.warning("%s has not been corrected for solution conditions" % name)
2044
+ if val is not None:
2045
+ return ureg.Quantity(val)
2046
+
2047
+ except KeyError:
2048
+ self.logger.error(f"Property {name} for solute {solute} not found in database. Returning None.")
2049
+ return None
2050
+
2051
+ if name == "model_parameters.molar_volume_pitzer":
2052
+ # return a dict
2053
+ if doc["model_parameters"]["molar_volume_pitzer"].get("Beta0") is not None:
2054
+ return doc["model_parameters"]["molar_volume_pitzer"]
2055
+ return None
2056
+
2057
+ if name == "molecular_weight":
2058
+ return ureg.Quantity(doc.get(name))
2059
+
2060
+ if name == "oxi_state_guesses":
2061
+ return doc.get(name)
2062
+
2063
+ # for parameters not named above, just return the base value
2064
+ if name == "pmg_ion" or not isinstance(doc.get(name), dict):
2065
+ # if the queried value is not a dict, it is a root level key and should be returned as is
2066
+ return doc.get(name)
2067
+
2068
+ val = doc[name].get("value")
2069
+ # self.logger.warning("%s has not been corrected for solution conditions" % name)
2070
+ if val is not None:
2071
+ return ureg.Quantity(val)
2072
+ return None
2073
+
2074
+ def get_transport_number(self, solute: str) -> Quantity:
2075
+ r"""Calculate the transport number of the solute in the solution.
2076
+
2077
+ Args:
2078
+ solute: Formula of the solute for which the transport number is
2079
+ to be calculated.
2080
+
2081
+ Returns:
2082
+ The transport number of `solute`, as a dimensionless Quantity.
2083
+
2084
+ Notes:
2085
+ Transport number is calculated according to :
2086
+
2087
+ .. math::
2088
+
2089
+ t_i = {D_i z_i^2 C_i \over \sum D_i z_i^2 C_i}
2090
+
2091
+ Where :math:`C_i` is the concentration in mol/L, :math:`D_i` is the diffusion
2092
+ coefficient, and :math:`z_i` is the charge, and the summation extends
2093
+ over all species in the solution.
2094
+
2095
+ Diffusion coefficients :math:`D_i` are adjusted for the effects of temperature
2096
+ and ionic strength using the method implemented in PHREEQC >= 3.4.
2097
+ See `get_diffusion_coefficient for` further details.
2098
+
2099
+
2100
+ References:
2101
+ Geise, G. M.; Cassady, H. J.; Paul, D. R.; Logan, E.; Hickner, M. A. "Specific
2102
+ ion effects on membrane potential and the permselectivity of ion exchange membranes.""
2103
+ *Phys. Chem. Chem. Phys.* 2014, 16, 21673-21681.
2104
+
2105
+ See Also:
2106
+ :py:meth:`get_diffusion_coefficient`
2107
+ :py:meth:`get_molar_conductivity`
2108
+ """
2109
+ solute = standardize_formula(solute)
2110
+ denominator = numerator = 0
2111
+
2112
+ for item, mol in self.components.items():
2113
+ # the molar conductivity of each species is F/RT D * z^2, and the F/RT factor
2114
+ # cancels out
2115
+ # using species amounts in mol is equivalent to using concentrations in mol/L
2116
+ # since there is only one solution volume, and it's much faster.
2117
+ term = self.get_molar_conductivity(item).magnitude * mol
2118
+
2119
+ if item == solute:
2120
+ numerator = term
2121
+
2122
+ denominator += term
2123
+
2124
+ return ureg.Quantity(numerator / denominator, "dimensionless")
2125
+
2126
+ def _get_molar_conductivity(self, solute: str) -> Quantity:
2127
+ r"""
2128
+ Calculate the molar (equivalent) conductivity for a solute.
2129
+
2130
+ Args:
2131
+ solute: String identifying the solute for which the molar conductivity is
2132
+ to be calculated.
2133
+
2134
+ Returns:
2135
+ The molar or equivalent conductivity of the species in the solution.
2136
+ Zero if the solute is not charged.
2137
+
2138
+ Notes:
2139
+ Molar conductivity is calculated from the Nernst-Einstein relation [smed]_
2140
+
2141
+ .. math::
2142
+
2143
+ \lambda_i = \frac{F^2}{RT} D_i z_i^2
2144
+
2145
+ Diffusion coefficients :math:`D_i` are adjusted for the effects of temperature
2146
+ and ionic strength using the method implemented in PHREEQC >= 3.4. See `get_diffusion_coefficient`
2147
+ for further details.
2148
+
2149
+ References:
2150
+ 1. .. [smed] Smedley, Stuart. The Interpretation of Ionic Conductivity in Liquids, pp 1-9. Plenum Press, 1980.
2151
+
2152
+ 2. https://www.hydrochemistry.eu/exmpls/sc.html
2153
+
2154
+ 3. Appelo, C.A.J. Solute transport solved with the Nernst-Planck equation for concrete pores with 'free'
2155
+ water and a double layer. Cement and Concrete Research 101, 2017.
2156
+ https://dx.doi.org/10.1016/j.cemconres.2017.08.030
2157
+
2158
+ 4. CRC Handbook of Chemistry and Physics
2159
+
2160
+ See Also:
2161
+ :py:meth:`get_diffusion_coefficient`
2162
+ """
2163
+ D = self.get_diffusion_coefficient(solute)
2164
+
2165
+ if D != 0:
2166
+ molar_cond = (
2167
+ D * (ureg.e * ureg.N_A) ** 2 * self.get_property(solute, "charge") ** 2 / (ureg.R * self.temperature)
2168
+ )
2169
+ else:
2170
+ molar_cond = ureg.Quantity(0, "mS / cm / (mol/L)")
2171
+
2172
+ self.logger.debug(f"Calculated molar conductivity as {molar_cond} from D = {D!s} at T={self.temperature}")
2173
+
2174
+ return molar_cond.to("mS / cm / (mol/L)")
2175
+
2176
+ def _get_diffusion_coefficient(self, solute: str, activity_correction: bool = True) -> Quantity:
2177
+ r"""
2178
+ Get the **temperature-adjusted** diffusion coefficient of a solute.
2179
+
2180
+ Args:
2181
+ solute: the solute for which to retrieve the diffusion coefficient.
2182
+ activity_correction: If True (default), adjusts the diffusion coefficient for the effects of ionic
2183
+ strength using a model from Ref 2.
2184
+
2185
+ Notes:
2186
+ This method is equivalent to self.get_property(solute, "transport.diffusion_coefficient")
2187
+ ONLY when the Solution temperature is the same as the reference temperature for the diffusion coefficient
2188
+ in the database (usually 25 C).
2189
+
2190
+ Otherwise, the reference D value is adjusted based on the Solution temperature and (optionally),
2191
+ ionic strength. The adjustments are
2192
+
2193
+ .. math::
2194
+
2195
+ D_T = D_{298} \exp(\frac{d}{T} - \frac{d}{298}) \frac{\nu_{298}}{\nu_T}
2196
+
2197
+ .. math::
2198
+
2199
+ D_{\gamma} = D^0 \exp(\frac{-a1 A |z_i| \sqrt{I}}{1+\kappa a})
2200
+
2201
+ .. math::
2202
+
2203
+ \kappa a = B \sqrt{I} \frac{a2}{1+I^{0.75}}
2204
+
2205
+ where a1, a2, and d are parameters from Ref. 2, A and B are the parameters used in the Debye Huckel
2206
+ equation, and I is the ionic strength. If the model parameters for a particular solute are not available,
2207
+ default values of d=0, a1=1.6, and a2=4.73 (as recommended in Ref. 2) are used instead.
2208
+
2209
+ References:
2210
+ 1. https://www.hydrochemistry.eu/exmpls/sc.html
2211
+ 2. Appelo, C.A.J. Solute transport solved with the Nernst-Planck equation for concrete pores with 'free'
2212
+ water and a double layer. Cement and Concrete Research 101, 2017.
2213
+ https://dx.doi.org/10.1016/j.cemconres.2017.08.030
2214
+ 3. CRC Handbook of Chemistry and Physics
2215
+
2216
+ See Also:
2217
+ pyEQL.activity_correction._debye_parameter_B
2218
+ pyEQL.activity_correction._debye_parameter_activity
2219
+
2220
+ """
2221
+ D = self.get_property(solute, "transport.diffusion_coefficient")
2222
+ rform = standardize_formula(solute)
2223
+ if D is None or D.magnitude == 0:
2224
+ self.logger.warning(
2225
+ f"Diffusion coefficient not found for species {rform}. Using default value of "
2226
+ f"{self.default_diffusion_coeff} m**2/s."
2227
+ )
2228
+ D = ureg.Quantity(self.default_diffusion_coeff, "m**2/s")
2229
+
2230
+ # assume reference temperature is 298.15 K (this is the case for all current DB entries)
2231
+ T_ref = 298.15
2232
+ mu_ref = 0.0008900225512925807 # water viscosity from IAPWS97 at 298.15 K
2233
+ T_sol = self.temperature.to("K").magnitude
2234
+ mu = self.water_substance.mu
2235
+
2236
+ # skip temperature correction if within 1 degree
2237
+ if abs(T_sol - T_ref) > 1 or activity_correction is True:
2238
+ # get the a1, a2, and d parameters required by the PHREEQC model
2239
+ try:
2240
+ doc = self.database.query_one({"formula": rform})
2241
+ d = doc["model_parameters"]["diffusion_temp_smolyakov"]["d"]["value"]
2242
+ a1 = doc["model_parameters"]["diffusion_temp_smolyakov"]["a1"]["value"]
2243
+ a2 = doc["model_parameters"]["diffusion_temp_smolyakov"]["a2"]["value"]
2244
+ # values will be a str, e.g. "1 dimensionless"
2245
+ d = float(d.split(" ")[0])
2246
+ a1 = float(a1.split(" ")[0])
2247
+ a2 = float(a2.split(" ")[0])
2248
+ except TypeError:
2249
+ # this means the database doesn't contain a d value.
2250
+ # according to Ref 2, the following are recommended default parameters
2251
+ self.logger.warning(
2252
+ f"Temperature and ionic strength correction parameters for solute {rform} diffusion "
2253
+ "coefficient not in database. Using recommended default values of a1=1.6, a2=4.73, and d=0."
2254
+ )
2255
+ d = 0
2256
+ a1 = 1.6
2257
+ a2 = 4.73
2258
+
2259
+ # use the PHREEQC model from Ref 2 to correct for temperature if more than 1 degree different from T_ref
2260
+ if abs(T_sol - T_ref) > 1:
2261
+ D *= np.exp(d / T_sol - d / T_ref) * mu_ref / mu
2262
+
2263
+ if activity_correction:
2264
+ A = _debye_parameter_activity(str(self.temperature)).to("kg**0.5/mol**0.5").magnitude / 2.303
2265
+ B = _debye_parameter_B(str(self.temperature)).to("1/angstrom * kg**0.5/mol**0.5").magnitude
2266
+ z = self.get_property(solute, "charge")
2267
+ IS = self.ionic_strength.magnitude
2268
+ kappaa = B * IS**0.5 * a2 / (1 + IS**0.75)
2269
+ # correct for ionic strength
2270
+ D *= np.exp(-a1 * A * abs(z) * IS**0.5 / (1 + kappaa))
2271
+ # else:
2272
+ # # per CRC handbook, D increases by 2-3% per degree above 25 C
2273
+ # return D * (1 + 0.025 * (T_sol - T_ref))
2274
+
2275
+ return D
2276
+
2277
+ def _get_mobility(self, solute: str) -> Quantity:
2278
+ r"""
2279
+ Calculate the ionic mobility of the solute.
2280
+
2281
+ Args:
2282
+ solute (str): String identifying the solute for which the mobility is to be calculated.
2283
+
2284
+ Returns:
2285
+ float: The ionic mobility. Zero if the solute is not charged.
2286
+
2287
+ Note:
2288
+ This function uses the Einstein relation to convert a diffusion coefficient into an ionic mobility [smed]_
2289
+
2290
+ .. math::
2291
+
2292
+ \mu_i = {F |z_i| D_i \over RT}
2293
+
2294
+ References:
2295
+ Smedley, Stuart I. The Interpretation of Ionic Conductivity in Liquids. Plenum Press, 1980.
2296
+ """
2297
+ D = self.get_diffusion_coefficient(solute)
2298
+
2299
+ mobility = ureg.N_A * ureg.e * abs(self.get_property(solute, "charge")) * D / (ureg.R * self.temperature)
2300
+
2301
+ self.logger.debug(f"Calculated ionic mobility as {mobility} from D = {D!s} at T={self.temperature}")
2302
+
2303
+ return mobility.to("m**2/V/s")
2304
+
2305
+ def get_lattice_distance(self, solute: str) -> Quantity:
2306
+ r"""
2307
+ Calculate the average distance between molecules.
2308
+
2309
+ Calculate the average distance between molecules of the given solute,
2310
+ assuming that the molecules are uniformly distributed throughout the
2311
+ solution.
2312
+
2313
+ Args:
2314
+ solute : str
2315
+ String representing the name of the solute of interest
2316
+
2317
+ Returns:
2318
+ Quantity: The average distance between solute molecules
2319
+
2320
+ Examples:
2321
+ >>> soln = Solution([['Na+','0.5 mol/kg'],['Cl-','0.5 mol/kg']])
2322
+ >>> soln.get_lattice_distance('Na+')
2323
+ 1.492964.... nanometer
2324
+
2325
+ Notes:
2326
+ The lattice distance is related to the molar concentration as follows:
2327
+
2328
+ .. math:: d = ( C_i N_A ) ^ {-{1 \over 3}}
2329
+
2330
+ """
2331
+ # calculate the volume per particle as the reciprocal of the molar concentration
2332
+ # (times avogadro's number). Take the cube root of the volume to get
2333
+ # the average distance between molecules
2334
+ distance = (self.get_amount(solute, "mol/L") * ureg.N_A) ** (-1 / 3)
2335
+
2336
+ return distance.to("nm")
2337
+
2338
+ def _adjust_charge_balance(self, atol=1e-8) -> None:
2339
+ """Helper method to adjust the charge balance of the Solution."""
2340
+ cb = self.charge_balance
2341
+ if not np.isclose(cb, 0, atol=atol):
2342
+ self.logger.info(f"Solution is not electroneutral (C.B. = {cb} eq/L).")
2343
+ if self.balance_charge is None:
2344
+ # Nothing to do.
2345
+ self.logger.info("balance_charge is None, so no charge balancing will be performed.")
2346
+ return
2347
+
2348
+ self.logger.info(f"Adjusting {self._cb_species} to compensate.")
2349
+
2350
+ if self.balance_charge == "pH":
2351
+ # the charge imbalance associated with the H+ / OH- system can be expressed
2352
+ # as ([H+] - [OH-]) or ([H+] - K_W/[H+]). If we adjust H+, we also have to
2353
+ # adjust OH- to maintain water equilibrium.
2354
+ C_hplus = self.get_amount("H+", "mol/L").magnitude
2355
+ start_imbalance = C_hplus - K_W / C_hplus
2356
+ new_imbalance = start_imbalance - cb
2357
+ # calculate the new concentration of H+ that will balance the charge
2358
+ # solve H^2 - new_imbalance H - K_W = 0, so a=1, b=-new_imbalance, c=-K_W. Note that this is guaranteed to have real roots as
2359
+ # b^2-4ac > 0
2360
+ new_hplus = max(
2361
+ [
2362
+ (new_imbalance + np.sqrt(new_imbalance**2 + 4 * 1 * K_W)) / 2,
2363
+ (new_imbalance - np.sqrt(new_imbalance**2 + 4 * 1 * K_W)) / 2,
2364
+ ]
2365
+ )
2366
+ self.set_amount("H+", f"{new_hplus} mol/L")
2367
+ self.set_amount("OH-", f"{K_W / new_hplus} mol/L")
2368
+ return
2369
+
2370
+ z = self.get_property(self._cb_species, "charge")
2371
+ try:
2372
+ self.add_amount(self._cb_species, f"{-1 * cb / z} mol")
2373
+ return
2374
+ except ValueError:
2375
+ # if the concentration is negative, it must mean there is not enough present.
2376
+ # remove everything that's present and log an error.
2377
+ self.components[self._cb_species] = 0.0
2378
+ self.logger.error(
2379
+ f"There is not enough {self._cb_species} present to balance the charge. Try a different species."
2380
+ )
2381
+ return
2382
+
2383
+ def _update_volume(self):
2384
+ """Recalculate the solution volume based on composition."""
2385
+ self._volume = self._get_solvent_volume() + self._get_solute_volume()
2386
+
2387
+ def _get_solvent_volume(self):
2388
+ """Return the volume of the pure solvent."""
2389
+ # calculate the volume of the pure solvent
2390
+ solvent_vol = self.solvent_mass / ureg.Quantity(self.water_substance.rho, "g/L")
2391
+
2392
+ return solvent_vol.to("L")
2393
+
2394
+ def _get_solute_volume(self):
2395
+ """Return the volume of only the solutes."""
2396
+ return self.engine.get_solute_volume(self)
2397
+
2398
+ def as_dict(self) -> dict:
2399
+ """Convert the Solution into a dict representation that can be serialized to .json or other format."""
2400
+ # clear the volume update flag, if required
2401
+ if self.volume_update_required:
2402
+ self._update_volume()
2403
+ d = super().as_dict()
2404
+ for k, v in d.items():
2405
+ # convert all Quantity to str
2406
+ if isinstance(v, Quantity):
2407
+ d[k] = str(v)
2408
+ # replace solutes with the current composition
2409
+ d["solutes"] = {k: f"{v} mol" for k, v in self.components.items()}
2410
+ # replace the engine with the associated str
2411
+ d["engine"] = self._engine
2412
+ # d["logger"] = self.logger.__dict__
2413
+ return d
2414
+
2415
+ @classmethod
2416
+ def from_dict(cls, d: dict) -> Solution:
2417
+ """Instantiate a Solution from a dictionary generated by as_dict()."""
2418
+ # because of the automatic volume updating that takes place during the __init__ process,
2419
+ # care must be taken here to recover the exact quantities of solute and volume
2420
+ # first we store the volume of the serialized solution
2421
+ orig_volume = ureg.Quantity(d["volume"])
2422
+ # then instantiate a new one
2423
+ decoded = {k: MontyDecoder().process_decoded(v) for k, v in d.items() if not k.startswith("@")}
2424
+ new_sol = cls(**decoded)
2425
+ # now determine how different the new solution volume is from the original
2426
+ scale_factor = (orig_volume / new_sol.volume).magnitude
2427
+ # reset the new solution volume to that of the original. In the process of
2428
+ # doing this, all the solute amounts are scaled by new_sol.volume / volume
2429
+ new_sol.volume = str(orig_volume)
2430
+ # undo the scaling by diving by that scale factor
2431
+ for sol in new_sol.components:
2432
+ new_sol.components[sol] /= scale_factor
2433
+ # ensure that another volume update won't be triggered by these changes
2434
+ # (this line should in principle be unnecessary, but it doesn't hurt anything)
2435
+ new_sol.volume_update_required = False
2436
+ return new_sol
2437
+
2438
+ @classmethod
2439
+ def from_preset(
2440
+ cls, preset: Literal["seawater", "rainwater", "wastewater", "urine", "normal saline", "Ringers lactate"]
2441
+ ) -> Solution:
2442
+ r"""Instantiate a solution from a preset composition.
2443
+
2444
+ Args:
2445
+ preset (str): String representing the desired solution.
2446
+ Valid entries are 'seawater', 'rainwater', 'wastewater',
2447
+ 'urine', 'normal saline' and 'Ringers lactate'.
2448
+
2449
+ Returns:
2450
+ A pyEQL Solution object.
2451
+
2452
+ Raises:
2453
+ FileNotFoundError: If the given preset file doesn't exist on the file system.
2454
+
2455
+ Notes:
2456
+ The following sections explain the different solution options:
2457
+
2458
+ - 'rainwater' - pure water in equilibrium with atmospheric CO2 at pH 6
2459
+ - 'seawater' or 'SW'- Standard Seawater. See Table 4 of the Reference for Composition [mf08]_
2460
+ - 'wastewater' or 'WW' - medium strength domestic wastewater. See Table 3-18 of [me13]_
2461
+ - 'urine' - typical human urine. See Table 3-15 of [me13]_
2462
+ - 'normal saline' or 'NS' - normal saline solution used in medicine [saline]_
2463
+ - 'Ringers lacatate' or 'RL' - Ringer's lactate solution used in medicine [lactate]_
2464
+
2465
+ References:
2466
+ .. [mf08] Millero, Frank J. "The composition of Standard Seawater and the definition of
2467
+ the Reference-Composition Salinity Scale." *Deep-sea Research. Part I* 55(1), 2008, 50-72.
2468
+
2469
+ .. [me13] Metcalf & Eddy, Inc. et al. *Wastewater Engineering: Treatment and Resource Recovery*, 5th Ed.
2470
+ McGraw-Hill, 2013.
2471
+
2472
+ .. [saline] https://en.wikipedia.org/w/index.php?title=Saline_(medicine)&oldid=1298292693
2473
+
2474
+ .. [lactate] https://en.wikipedia.org/wiki/Ringer%27s_lactate_solution
2475
+ """
2476
+ # preset_dir = files("pyEQL") / "presets"
2477
+ # Path to the YAML and JSON files corresponding to the preset
2478
+ yaml_path = files("pyEQL") / "presets" / f"{preset}.yaml"
2479
+ json_path = files("pyEQL") / "presets" / f"{preset}.json"
2480
+
2481
+ # Check if the file exists
2482
+ if yaml_path.exists():
2483
+ preset_path = yaml_path
2484
+ elif json_path.exists():
2485
+ preset_path = json_path
2486
+ else:
2487
+ raise FileNotFoundError(f"Invalid preset! File '{yaml_path}' or '{json_path} not found!")
2488
+
2489
+ # Create and return a Solution object
2490
+ return cls().from_file(preset_path)
2491
+
2492
+ def to_file(self, filename: str | Path) -> None:
2493
+ """Saving to a .yaml or .json file.
2494
+
2495
+ Args:
2496
+ filename (str | Path): The path to the file to save Solution.
2497
+ Valid extensions are .json or .yaml.
2498
+ """
2499
+ str_filename = str(filename)
2500
+ if not ("yaml" in str_filename.lower() or "json" in str_filename.lower()):
2501
+ self.logger.error("Invalid file extension entered - {str_filename}")
2502
+ raise ValueError("File extension must be .json or .yaml")
2503
+ if "yaml" in str_filename.lower():
2504
+ solution_dict = self.as_dict()
2505
+ solution_dict.pop("database")
2506
+ dumpfn(solution_dict, filename)
2507
+ else:
2508
+ dumpfn(self, filename)
2509
+
2510
+ @classmethod
2511
+ def from_file(self, filename: str | Path) -> Solution:
2512
+ """Loading from a .yaml or .json file.
2513
+
2514
+ Args:
2515
+ filename (str | Path): Path to the .json or .yaml file (including extension) to load the Solution from.
2516
+ Valid extensions are .json or .yaml.
2517
+
2518
+ Returns:
2519
+ A pyEQL Solution object.
2520
+
2521
+ Raises:
2522
+ FileNotFoundError: If the given filename doesn't exist on the file system.
2523
+ """
2524
+ if not os.path.exists(filename):
2525
+ raise FileNotFoundError(f"File '{filename}' not found!")
2526
+ str_filename = str(filename)
2527
+ if "yaml" in str_filename.lower():
2528
+ true_keys = [
2529
+ "solutes",
2530
+ "volume",
2531
+ "temperature",
2532
+ "pressure",
2533
+ "pH",
2534
+ "pE",
2535
+ "balance_charge",
2536
+ "solvent",
2537
+ "engine",
2538
+ # "database",
2539
+ ]
2540
+ solution_dict = loadfn(filename)
2541
+ keys_to_delete = [key for key in solution_dict if key not in true_keys]
2542
+ for key in keys_to_delete:
2543
+ solution_dict.pop(key)
2544
+ return Solution(**solution_dict)
2545
+ return loadfn(filename)
2546
+
2547
+ # arithmetic operations
2548
+ def __add__(self, other: Solution) -> Solution:
2549
+ """
2550
+ Solution addition: mix two solutions together.
2551
+
2552
+ Args:
2553
+ other: The Solutions to be mixed with this solution.
2554
+
2555
+ Returns:
2556
+ A Solution object that represents the result of mixing this solution and other.
2557
+
2558
+ Notes:
2559
+ The initial volume of the mixed solution is set as the sum of the volumes of this solution and other.
2560
+ The pressure and temperature are volume-weighted averages. The pH and pE values are currently APPROXIMATE
2561
+ because they are calculated assuming H+ and e- mix conservatively (i.e., the mixing process does not
2562
+ incorporate any equilibration reactions or buffering). Such support is planned in a future release.
2563
+ """
2564
+ # check to see if the two solutions have the same solvent
2565
+ if self.solvent != other.solvent:
2566
+ raise ValueError("Cannot add Solution with different solvents!")
2567
+
2568
+ if self._engine != other._engine:
2569
+ raise ValueError("Cannot add Solution with different engines!")
2570
+
2571
+ if self.database != other.database:
2572
+ raise ValueError("Cannot add Solution with different databases!")
2573
+
2574
+ # set the pressure for the new solution
2575
+ p1 = self.pressure
2576
+ t1 = self.temperature
2577
+ v1 = self.volume
2578
+ p2 = other.pressure
2579
+ t2 = other.temperature
2580
+ v2 = other.volume
2581
+
2582
+ # set the initial volume as the sum of the volumes
2583
+ mix_vol = v1 + v2
2584
+
2585
+ # check to see if the solutions have the same temperature and pressure
2586
+ if p1 != p2:
2587
+ self.logger.info(
2588
+ "Adding two solutions of different pressure. Pressures will be averaged (weighted by volume)"
2589
+ )
2590
+
2591
+ mix_pressure = (p1 * v1 + p2 * v2) / (mix_vol)
2592
+
2593
+ if t1 != t2:
2594
+ self.logger.info(
2595
+ "Adding two solutions of different temperature. Temperatures will be averaged (weighted by volume)"
2596
+ )
2597
+
2598
+ # do all temperature conversions in Kelvin to avoid ambiguity associated with "offset units". See pint docs.
2599
+ mix_temperature = (t1.to("K") * v1 + t2.to("K") * v2) / (mix_vol)
2600
+
2601
+ # retrieve the amount of each component in the parent solution and
2602
+ # store in a list.
2603
+ mix_amounts = FormulaDict({})
2604
+ for sol, amt in [*self.components.items(), *other.components.items()]:
2605
+ mix_amounts[sol] = amt + mix_amounts.get(sol, 0.0)
2606
+
2607
+ # TODO - call equilibrate() here once the method is functional to get new pH and pE, instead of the below
2608
+ warnings.warn(
2609
+ "The pH and pE value of the mixed solution is approximate! More accurate addition (mixing) of"
2610
+ "this property is planned for a future release."
2611
+ )
2612
+ # calculate the new pH and pE (before reactions) by mixing
2613
+ # for pH, we make sure to conserve the mass of H+ and OH-. By not passing
2614
+ # a kwarg for pH (i.e., by using the default value), the H+ concentration
2615
+ # will override and determine the pH value of the mixed solution.
2616
+
2617
+ # pE = -log[e-], so calculate the moles of e- in each solution and mix them
2618
+ mol_e_self = 10 ** (-1 * self.pE) * self.volume.to("L").magnitude
2619
+ mol_e_other = 10 ** (-1 * other.pE) * other.volume.to("L").magnitude
2620
+ mix_pE = -np.log10((mol_e_self + mol_e_other) / mix_vol.to("L").magnitude)
2621
+ solutes = {sol: f"{amount} mol" for sol, amount in mix_amounts.items()}
2622
+
2623
+ # create a new solution
2624
+ return Solution(
2625
+ solutes=solutes,
2626
+ volume=str(mix_vol),
2627
+ pressure=str(mix_pressure),
2628
+ temperature=str(mix_temperature.to("K")),
2629
+ # pH=7, # leave at default value so that H+ concentration determines pH
2630
+ pE=mix_pE,
2631
+ engine=self._engine,
2632
+ solvent=self.solvent,
2633
+ database=self.database,
2634
+ )
2635
+
2636
+ def __sub__(self, other: Solution) -> None:
2637
+ raise NotImplementedError("Subtraction of solutions is not implemented.")
2638
+
2639
+ def __mul__(self, factor: float) -> None:
2640
+ """
2641
+ Solution multiplication: scale all components by a factor. For example, Solution * 2 will double the moles of
2642
+ every component (including solvent). No other properties will change.
2643
+ """
2644
+ self.volume *= factor
2645
+ return self
2646
+
2647
+ def __truediv__(self, factor: float) -> None:
2648
+ """
2649
+ Solution division: scale all components by a factor. For example, Solution / 2 will remove half of the moles
2650
+ of every compoonents (including solvent). No other properties will change.
2651
+ """
2652
+ self.volume /= factor
2653
+ return self
2654
+
2655
+ # informational methods
2656
+
2657
+ def print(
2658
+ self,
2659
+ mode: Literal["all", "ions", "cations", "anions", "neutrals"] = "all",
2660
+ units: Literal["ppm", "mol", "mol/kg", "mol/L", "%", "activity"] = "mol",
2661
+ places=4,
2662
+ ):
2663
+ """
2664
+ Print details about the Solution.
2665
+
2666
+ Args:
2667
+ mode: Whether to list the amounts of all solutes, or only anions, cations, any ion, or any neutral solute.
2668
+ units: The units to list solute amounts in. "activity" will list dimensionless activities instead of
2669
+ concentrations.
2670
+ places: The number of decimal places to round the solute amounts.
2671
+ """
2672
+ print(self)
2673
+ str1 = "Activities" if units == "activity" else "Concentrations"
2674
+ str2 = f" ({units})" if units != "activity" else ""
2675
+ header = f"\nComponent {str1}{str2}:"
2676
+ print(header)
2677
+ print("=" * (len(header) - 1))
2678
+ for i in self.components:
2679
+ if mode != "all":
2680
+ z = self.get_property(i, "charge")
2681
+ if (
2682
+ (z != 0 and mode == "neutrals")
2683
+ or (z >= 0 and mode == "anions")
2684
+ or (z <= 0 and mode == "cations")
2685
+ or (z == 0 and mode == "ions")
2686
+ ):
2687
+ continue
2688
+
2689
+ amt = self.get_activity(i).magnitude if units == "activity" else self.get_amount(i, units).magnitude
2690
+
2691
+ print(f"{i:<12} {amt:0.{places}f}")
2692
+
2693
+ def __str__(self) -> str:
2694
+ # set output of the print() statement for the solution
2695
+ l1 = f"Volume: {self.volume:.3f~}"
2696
+ l2 = f"Temperature: {self.temperature:.3f~}"
2697
+ l3 = f"Pressure: {self.pressure:.3f~}"
2698
+ l4 = f"pH: {self.pH:.1f}"
2699
+ l5 = f"pE: {self.pE:.1f}"
2700
+ l6 = f"Solvent: {self.solvent}"
2701
+ l7 = f"Components: {self.components.keys():}"
2702
+ return f"{l1}\n{l2}\n{l3}\n{l4}\n{l5}\n{l6}\n{l7}"
2703
+
2704
+ """
2705
+ Legacy methods to be deprecated in a future release.
2706
+ """
2707
+
2708
+ @deprecated(message="add_solute() is deprecated. Use add_amount() instead.")
2709
+ def add_solvent(self, formula: str, amount: str): # pragma: no cover
2710
+ """Same as add_solute but omits the need to pass solvent mass to pint."""
2711
+ quantity = ureg.Quantity(amount)
2712
+ mw = self.get_property(formula, "molecular_weight")
2713
+ target_mol = quantity.to("moles", "chem", mw=mw, volume=self.volume, solvent_mass=self.solvent_mass)
2714
+ self.components[formula] = target_mol.to("moles").magnitude