├── src └── pHcalc │ ├── __init__.py │ └── pHcalc.py ├── _static ├── dist_diagram.png └── titration_crv.png ├── conda └── meta.yaml ├── pyproject.toml ├── .gitignore └── README.rst /src/pHcalc/__init__.py: -------------------------------------------------------------------------------- 1 | from .pHcalc import Acid, Inert, System 2 | -------------------------------------------------------------------------------- /_static/dist_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rnelsonchem/pHcalc/HEAD/_static/dist_diagram.png -------------------------------------------------------------------------------- /_static/titration_crv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rnelsonchem/pHcalc/HEAD/_static/titration_crv.png -------------------------------------------------------------------------------- /conda/meta.yaml: -------------------------------------------------------------------------------- 1 | {% set name = "pHcalc" %} 2 | {% set version = "0.2.0" %} 3 | 4 | package: 5 | name: {{ name|lower }} 6 | version: {{ version }} 7 | 8 | source: 9 | url: https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/pHcalc-{{ version }}.tar.gz 10 | sha256: 692aa59d92b5a3c2ce90f947be3166fd8964adb9246bd81d67b1c347f5713317 11 | 12 | build: 13 | noarch: python 14 | script: {{ PYTHON }} -m pip install . -vv 15 | number: 0 16 | 17 | requirements: 18 | host: 19 | - python >=3.5 20 | - setuptools 21 | - pip 22 | run: 23 | - python >=3.5 24 | - numpy >=1.10.0 25 | - scipy >=0.17.0 26 | 27 | test: 28 | imports: 29 | - pHcalc 30 | commands: 31 | - pip check 32 | requires: 33 | - pip 34 | 35 | about: 36 | summary: Systematic pH calculation package for Python 37 | license: BSD-3-Clause 38 | 39 | extra: 40 | recipe-maintainers: 41 | - rnelsonchem 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pHcalc" 7 | version = "0.2.0" 8 | 9 | description = "Systematic pH calculation package for Python" 10 | keywords = [ "pH", "systematic", "distribution", "titration", "acid", "base" ] 11 | readme = "README.rst" 12 | license = {text = "BSD-3-Clause"} 13 | 14 | authors = [ 15 | { name="Ryan Nelson", email="rnelsonchem@gmail.com" }, 16 | ] 17 | 18 | classifiers = [ 19 | 'Development Status :: 4 - Beta', 20 | 'Intended Audience :: Science/Research', 21 | 'License :: OSI Approved :: BSD License', 22 | 'Programming Language :: Python :: 3', 23 | ] 24 | 25 | requires-python = ">=3.5" 26 | dependencies = [ 27 | 'numpy>=1.10.0', 28 | 'scipy>=0.17.0', 29 | ] 30 | 31 | [project.urls] 32 | "Homepage" = "https://github.com/rnelsonchem/pHcalc" 33 | 34 | [tool.setuptools.packages.find] 35 | where = ["src"] 36 | 37 | [project.optional-dependencies] 38 | test = [ 39 | "pytest", 40 | ] 41 | 42 | dev = [ 43 | "pytest", 44 | "build", 45 | "ipython", 46 | "twine", 47 | ] 48 | 49 | [tool.pytest.ini_options] 50 | addopts = [ 51 | "--import-mode=prepend", 52 | ] 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | 125 | ### RCN stuff ### 126 | *.swp 127 | # For conda builds 128 | conda-bld/ 129 | 130 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pHcalc 2 | ###### 3 | 4 | *pHcalc* is a Python library for systematically calculating solution pH, 5 | distribution diagrams, and titration curves. 6 | 7 | This package is Python3 compatible with dependencies_ only on Numpy and 8 | Scipy. If you will be plotting the data, then there is an `optional 9 | dependency`_ on Matplotlib as well. 10 | 11 | .. _dependencies: 12 | 13 | Dependencies 14 | ------------ 15 | 16 | * Numpy >= 1.10 17 | 18 | * Scipy >= 0.17 19 | 20 | .. _optional dependency: 21 | 22 | Optional Packages 23 | ----------------- 24 | 25 | * Matplotlib >= 1.5 26 | 27 | Installation 28 | ------------ 29 | 30 | *pHcalc* is available via ``pip``, ``conda``, or the `GitHub repo`_ (most 31 | recent). 32 | 33 | From PyPI:: 34 | 35 | $ pip install pHcalc 36 | 37 | Via Conda:: 38 | 39 | $ conda install -c rnelsonchem phcalc 40 | 41 | If you have ``git`` installed on your system, then you can get the most 42 | recent, unrelased version from the `GitHub repo`_:: 43 | 44 | $ pip install git+https://github.com/rnelsonchem/pHcalc.git 45 | 46 | 47 | 48 | Background 49 | ########## 50 | 51 | *pHcalc* calculates the pH of a complex system of potentially strong and weak 52 | acids and bases using a systematic equilibrium solution method. This method is 53 | described in detail in `the Journal of Chemical Education`_ and in this 54 | `ChemWiki article`_, for example. (There is also another, older Pascal program 55 | called PHCALC_, which uses matrix algebra to accomplish the same task. To the 56 | best of my knowledge, the source code for this program is no longer 57 | available.) 58 | 59 | Basically, this method finds the optimum pH for the mixture by systematically 60 | adjusting the pH until a charge balance is achieved, i.e. the concentrations 61 | of positively charged ions equals the charge for the negatively charged ions. 62 | For (polyprotic) weak acids, the fractional distribution of the species 63 | at a given pH value is determined. Multiplying this by the concentration of 64 | acid in solution provides the concentration of each acidic species in the 65 | system, and these concentrations are used to balance the charge. 66 | 67 | Using this methodology bases and strong acids can be described using inert, 68 | charged species. These are ions that do not react with water, such as |Na+| 69 | and |Cl-|. In this context, any |Cl-| in solution must be charged balanced 70 | with an appropriate amount of |H3O|, which would define HCl in solution. 71 | |Na+| must be offset by an equivalent amount of |OH-|, which defines a 72 | solution of NaOH. A 1:1 combination of |Na+| and |H2CO3| would describe a 73 | solution of |NaHCO3|, the additional equivalent of |OH-| is implied by the 74 | charge imbalance. 75 | 76 | Example Usage 77 | ############# 78 | 79 | *pHcalc* defines three classes - Acid, Inert, and System - which are used in 80 | calculating the pH of the system. |H3O| and |OH-| are never explicitly 81 | defined. The |H3O| concentration is adjusted internally, and |OH-| is 82 | calculated using K\ :sub:`W`\ . 83 | 84 | .. code:: python 85 | 86 | >>> from pHcalc import Acid, Inert, System 87 | >>> import numpy as np 88 | >>> import matplotlib.pyplot as plt # Optional for plotting below 89 | 90 | pH of 0.01 M HCl 91 | ---------------- 92 | 93 | First of all, HCl completely dissociates in water to give equal amounts of 94 | |H3O| and |Cl-|. Because |H3O| is adjusted internally, all you need to define 95 | is |Cl-|. This implies a single equivalent of |H3O| in order to balance the 96 | charge of the system. 97 | 98 | .. code:: python 99 | 100 | >>> cl = Inert(charge=-1, conc=0.01) 101 | >>> system = System(cl) 102 | >>> system.pHsolve() 103 | >>> print(system.pH) # Should print 1.9999 104 | 105 | pH of 1e-8 M HCl 106 | ---------------- 107 | 108 | This is a notoriously tricky example for introductory chemistry students; 109 | however, *pHcalc* handles it nicely. 110 | 111 | .. code:: python 112 | 113 | >>> cl = Inert(charge=-1, conc=1e-8) 114 | >>> system = System(cl) 115 | >>> system.pHsolve() 116 | >>> print(system.pH) # Should print 6.978295898 (NOT 8!) 117 | 118 | pH of 0.01 M NaOH 119 | ----------------- 120 | 121 | This example is very similar to our HCl example, except that our Inert 122 | species must have a positive charge. The charge balance is achieved internally 123 | by the System using an equivalent amount of |OH-|. 124 | 125 | .. code:: python 126 | 127 | >>> na = Inert(charge=1, conc=0.01) 128 | >>> system = System(na) 129 | >>> system.pHsolve() 130 | >>> print(system.pH) # Should print 12.00000 131 | 132 | pH of 0.01 M HF 133 | --------------- 134 | 135 | Here we will use an Acid object instance to define the weak acid HF, which has 136 | a |Ka| of 6.76e-4 and a |pKa| of 3.17. You can use either value when you 137 | create the Acid instance. When defining an Acid species, you must always 138 | define a ``charge`` keyword argument, which is the charge of the *fully 139 | protonated species*. 140 | 141 | .. code:: python 142 | 143 | >>> hf = Acid(Ka=6.76e-4, charge=0, conc=0.01) 144 | >>> # hf = Acid(pKa=3.17, charge=0, conc=0.01) will also work 145 | >>> system = System(hf) 146 | >>> system.pHsolve() 147 | >>> print(system.pH) # Should print 2.6413261 148 | 149 | pH of 0.01 M NaF 150 | ---------------- 151 | 152 | This system consist of a 1:1 mixture of an HF Acid instance and a |Na+| 153 | Inert instance. The System object can be instantiated with an arbitrary 154 | number of Acids and Inert objects. Again, there is an implied equivalent of 155 | |OH-| necessary to balance the charge of the system. 156 | 157 | .. code:: python 158 | 159 | >>> hf = Acid(Ka=6.76e-4, charge=0, conc=0.01) 160 | >>> na = Inert(charge=1, conc=0.01) 161 | >>> system = System(hf, na) 162 | >>> system.pHsolve() 163 | >>> print(system.pH) # Should print 7.5992233 164 | 165 | 166 | pH of 0.01 M |H2CO3| 167 | -------------------- 168 | 169 | The |Ka| and |pKa| attributes can also accept lists of values for polyprotic 170 | species. 171 | 172 | .. code:: python 173 | 174 | >>> carbonic = Acid(pKa=[6.35, 10.33], charge=0, conc=0.01) 175 | >>> system = System(carbonic) 176 | >>> system.pHsolve() 177 | >>> print(system.pH) # Should print 4.176448 178 | 179 | pH of 0.01 M Alanine Zwitterion Form 180 | ------------------------------------ 181 | 182 | Alanine has two pKa values, 2.35 and 9.69, but the fully protonated form is 183 | positively charged. In order to define the neutral zwitterion, only the 184 | positively charged Acid object needs to be defined. The charge balance in this 185 | case implies a single equivalent of |OH-|. 186 | 187 | .. code:: python 188 | 189 | >>> ala = Acid(pKa=[2.35, 9.69], charge=1, conc=0.01) 190 | >>> system = System(ala) 191 | >>> system.pHsolve() 192 | >>> print(system.pH) # Should print 6.0991569 193 | 194 | pH of 0.01 M |NH4PO4| 195 | --------------------- 196 | 197 | This is equivalent to a 1:3 mixture of |H3PO4| and |NH4|, both of which are 198 | defined by Acid objects. Three equivalents of |OH-| are implied to balance the 199 | charge of the system. 200 | 201 | .. code:: python 202 | 203 | >>> phos = Acid(pKa=[2.148, 7.198, 12.319], charge=0, conc=0.01) 204 | >>> nh4 = Acid(pKa=9.25, charge=1, conc=0.01*3) 205 | >>> system = System(phos, nh4) 206 | >>> system.pHsolve() 207 | >>> print(system.pH) # Should print 8.95915298 208 | 209 | Distribution Diagrams 210 | --------------------- 211 | 212 | Acid objects also define a function called ``alpha``, which calculates the 213 | fractional distribution of species at a given pH. This function can be used to 214 | create distribution diagrams for weak acid species. ``alpha`` takes a single 215 | argument, which is a single pH value or a Numpy array of values. For a single 216 | pH value, the function returns a Numpy array of fractional distributions 217 | ordered from most acid to least acidic species. 218 | 219 | .. code:: python 220 | 221 | >>> phos = Acid(pKa=[2.148, 7.198, 12.319], charge=0, conc=0.01) 222 | >>> phos.alpha(7.0) 223 | array([ 8.6055e-06, 6.1204e-01, 3.8795e-01, 1.8611e-06]) 224 | >>> # This is H3PO4, H2PO4-, HPO4_2-, and HPO4_3- 225 | 226 | For a Numpy array og pH values, a 2D array of fractional distribution values 227 | is returned, where each row is a series of distributions for each given pH. 228 | The 2D returned array can be used to plot a distribution diagram. 229 | 230 | .. code:: python 231 | 232 | >>> phos = Acid(pKa=[2.148, 7.198, 12.319], charge=0, conc=0.01) 233 | >>> phs = np.linspace(0, 14, 1000) 234 | >>> fracs = phos.alpha(phs) 235 | >>> plt.plot(phs, fracs) 236 | >>> plt.legend(['H3PO4', 'H2PO4^1-', 'HPO4^2-', 'PO4^3-']) 237 | >>> plt.show() 238 | 239 | .. image:: ./_static/dist_diagram.png 240 | 241 | Titration Curves 242 | ---------------- 243 | 244 | Using a simple loop, we can also construct arbitrary titration curves as well. 245 | In this example, we will titrate |H3PO4| with NaOH. The ``guess_est`` keyword 246 | argument for the ``System.pHsolve`` method forces the calculation of a best 247 | guess for starting the pH optimization algorithm. This may speed up the 248 | evaluation of the pH and can also be used if the minimizer throws an error 249 | during the pH calculation. 250 | 251 | .. code:: python 252 | 253 | >>> na_moles = np.linspace(1e-8, 5.e-3, 500) 254 | >>> sol_volume = 1. # Liter 255 | >>> phos = Acid(pKa=[2.148, 7.198, 12.375], charge=0, conc=1.e-3) 256 | >>> phs = [] 257 | >>> for mol in na_moles: 258 | >>> na = Inert(charge=1, conc=mol/sol_volume) 259 | >>> system = System(phos, na) 260 | >>> system.pHsolve(guess_est=True) 261 | >>> phs.append(system.pH) 262 | >>> plt.plot(na_moles, phs) 263 | >>> plt.show() 264 | 265 | .. image:: ./_static/titration_crv.png 266 | 267 | 268 | .. Substitutions 269 | 270 | 271 | .. |Na+| replace:: Na\ :sup:`+` 272 | .. |Cl-| replace:: Cl\ :sup:`-` 273 | .. |H3O| replace:: H\ :sub:`3`\ O\ :sup:`+` 274 | .. |OH-| replace:: OH\ :sup:`-` 275 | .. |H2CO3| replace:: H\ :sub:`2`\ CO\ :sub:`3` 276 | .. |NaHCO3| replace:: NaHCO\ :sub:`3` 277 | .. |Ka| replace:: K\ :sub:`a` 278 | .. |pKa| replace:: pK\ :sub:`a` 279 | .. |NH4PO4| replace:: (NH\ :sub:`4`\ )\ :sub:`3`\ PO\ :sub:`4` 280 | .. |H3PO4| replace:: H\ :sub:`3`\ PO\ :sub:`4` 281 | .. |NH4| replace:: NH\ :sub:`4`\ :sup:`+` 282 | 283 | .. External Hyperlinks 284 | 285 | .. _GitHub repo: https://github.com/rnelsonchem/pHcalc 286 | .. _PyPI: https://pypi.python.org/pypi/pHcalc 287 | .. _the Journal of Chemical Education: 288 | http://pubs.acs.org/doi/abs/10.1021/ed100784v 289 | .. _ChemWiki article: 290 | http://chemwiki.ucdavis.edu/Core/Analytical_Chemistry/Analytical_Chemistry_2.0/06_Equilibrium_Chemistry/6G%3A_Solving_Equilibrium_Problems#6G.3_A_Systematic_Approach_to_Solving_Equilibrium_Problems 291 | .. _PHCALC: http://pubs.acs.org/doi/pdf/10.1021/ed071p119 292 | -------------------------------------------------------------------------------- /src/pHcalc/pHcalc.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import scipy.optimize as spo 3 | 4 | class Inert: 5 | """A nonreactive ion class. 6 | 7 | This object defines things like K+ and Cl-, which contribute to the 8 | overall charge balance, but do not have any inherent reactivity with 9 | water. 10 | 11 | Parameters 12 | ---------- 13 | charge : int 14 | The formal charge of the ion. 15 | 16 | conc : float 17 | The concentration of this species in solution. 18 | 19 | Attributes 20 | ---------- 21 | charge : int 22 | The formal charge of the ion. 23 | 24 | conc : float 25 | The concentration of this species in solution. 26 | 27 | """ 28 | def __init__(self, charge=None, conc=None): 29 | if charge == None: 30 | raise ValueError( 31 | "The charge for this ion must be defined.") 32 | 33 | self.charge = charge 34 | self.conc = conc 35 | 36 | def alpha(self, pH): 37 | '''Return the fraction of each species at a given pH. 38 | 39 | Parameters 40 | ---------- 41 | pH : int, float, or Numpy Array 42 | These are the pH value(s) over which the fraction should be 43 | returned. 44 | 45 | Returns 46 | ------- 47 | Numpy NDArray 48 | Because this is a non-reactive ion class, this function will 49 | always return a Numpy array containing just 1.0's for all pH 50 | values. 51 | 52 | ''' 53 | if isinstance(pH, (int, float)): 54 | length = 1 55 | else: 56 | length = len(pH) 57 | ones = np.ones(length).reshape(-1,1) 58 | return ones 59 | 60 | 61 | 62 | class Acid: 63 | '''An acidic species class. 64 | 65 | This object is used to calculate a number of parameters related to a weak 66 | acid in an aqueous solution. 67 | 68 | Parameters 69 | ---------- 70 | Ka : None (default), float, list, Numpy Array 71 | This defines the Ka values for all acidic protons in this species. It 72 | can be a single Ka value (float), a list of floats, or a Numpy array 73 | of floats. Either this value or pKa needs to be defined. The other 74 | will then be calculated from the given values. 75 | 76 | pKa : None (default), float, list, Numpy Array 77 | The pKa value(s) for all the acidic protons in this species. This 78 | follows the same rules as Ka (See Ka description for more details), 79 | and either this value or Ka must be defined. 80 | 81 | charge : None (default), int 82 | This is the charge of the fully protonated form of this acid. This 83 | must be defined. 84 | 85 | conc : None (default), float 86 | The formal concentration of this acid in solution. This value must be 87 | defined. 88 | 89 | Note 90 | ---- 91 | There is no corresponding Base object. To define a base, you must use a 92 | combination of an Acid and Inert object. See the documentation for 93 | examples. 94 | 95 | ''' 96 | def __init__(self, Ka=None, pKa=None, charge=None, conc=None): 97 | # Do a couple quick checks to make sure that everything has been 98 | # defined. 99 | if Ka == None and pKa == None: 100 | raise ValueError( 101 | "You must define either Ka or pKa values.") 102 | elif charge == None: 103 | raise ValueError( 104 | "The maximum charge for this acid must be defined.") 105 | 106 | # Make sure both Ka and pKa are calculated. For lists of values, be 107 | # sure to sort them to ensure that the most acidic species is defined 108 | # first. 109 | elif Ka == None: 110 | if isinstance(pKa, (int, float)): 111 | self.pKa = np.array( [pKa,], dtype=float) 112 | else: 113 | self.pKa = np.array(pKa, dtype=float) 114 | self.pKa.sort() 115 | self.Ka = 10**(-self.pKa) 116 | elif pKa == None: 117 | if isinstance(Ka, (int, float)): 118 | self.Ka = np.array( [Ka,], dtype=float) 119 | else: 120 | self.Ka = np.array(Ka, dtype=float) 121 | # Ka values must be in reverse sort order 122 | self.Ka.sort() 123 | self.Ka = self.Ka[::-1] 124 | self.pKa = -np.log10(self.Ka) 125 | # This temporary Ka array will be used to calculate alpha values. It 126 | # starts with an underscore so that it won't be confusing for others. 127 | self._Ka_temp = np.append(1., self.Ka) 128 | 129 | # Make a list of charges for each species defined by the Ka values. 130 | self.charge = np.arange(charge, charge - len(self.Ka) - 1, -1) 131 | # Make sure the concentrations are accessible to the object instance. 132 | self.conc = conc 133 | 134 | def alpha(self, pH): 135 | '''Return the fraction of each species at a given pH. 136 | 137 | Parameters 138 | ---------- 139 | pH : int, float, or Numpy Array 140 | These are the pH value(s) over which the fraction should be 141 | returned. 142 | 143 | Returns 144 | ------- 145 | Numpy NDArray 146 | These are the fractional concentrations at any given pH. They are 147 | sorted from most acidic species to least acidic species. If a 148 | NDArray of pH values is provided, then a 2D array will be 149 | returned. In this case, each row represents the speciation for 150 | each given pH. 151 | ''' 152 | # If the given pH is not a list/array, be sure to convert it to one 153 | # for future calcs. 154 | if isinstance(pH, (int, float)): 155 | pH = [pH,] 156 | pH = np.array(pH, dtype=float) 157 | 158 | # Calculate the concentration of H3O+. If multiple pH values are 159 | # given, then it is best to construct a two dimensional array of 160 | # concentrations. 161 | h3o = 10.**(-pH) 162 | if len(h3o) > 1: 163 | h3o = np.repeat( h3o.reshape(-1, 1), len(self._Ka_temp), axis=1) 164 | 165 | # These are the powers that the H3O+ concentrations will be raised. 166 | power = np.arange(len(self._Ka_temp)) 167 | # Calculate the H3O+ concentrations raised to the powers calculated 168 | # above (in reverse order). 169 | h3o_pow = h3o**( power[::-1] ) 170 | # Calculate a cumulative product of the Ka values. The first value 171 | # must be 1.0, which is why _Ka_temp is used instead of Ka. 172 | Ka_prod = np.cumproduct(self._Ka_temp) 173 | # Multiply the H3O**power values times the cumulative Ka product. 174 | h3o_Ka = h3o_pow*Ka_prod 175 | 176 | # Return the alpha values. The return signature will differ is the 177 | # shape of the H3O array was 2-dimensional. 178 | if len(h3o.shape) > 1: 179 | den = h3o_Ka.sum(axis=1) 180 | return h3o_Ka/den.reshape(-1,1) 181 | else: 182 | den = h3o_Ka.sum() 183 | return h3o_Ka/den 184 | 185 | 186 | class System: 187 | '''An object used to define an a system of acid and neutral species. 188 | 189 | This object accepts an arbitrary number of acid and neutral species 190 | objects and uses these to calculate the pH of the system. Be sure to 191 | include all of the species that completely define the contents of a 192 | particular solution. 193 | 194 | Parameters 195 | ---------- 196 | *species 197 | These are any number of Acid and Inert objects that you'd like to 198 | use to define your system. 199 | 200 | Kw : float (default 1.01e-14) 201 | The autoionization constant for water. This can vary based on 202 | temperature, for example. The default value is for water at 203 | 298 K and 1 atm. 204 | 205 | Attibutes 206 | --------- 207 | species : list 208 | This is a list containing all of the species that you input. 209 | 210 | Kw : float 211 | The autoionization of water set using the Kw keyword argument. 212 | 213 | pHsolution 214 | This is the full minimization output, which is defined by the function 215 | scipy.optimize.minimize. This is only available after running the 216 | pHsolve method. 217 | 218 | pH : float 219 | The pH of this particular system. This is only calculated after 220 | running the pHsolve method. 221 | ''' 222 | def __init__(self, *species, Kw=1.01e-14): 223 | self.species = species 224 | self.Kw = Kw 225 | 226 | 227 | def _diff_pos_neg(self, pH): 228 | '''Calculate the charge balance difference. 229 | 230 | Parameters 231 | ---------- 232 | pH : int, float, or Numpy Array 233 | The pH value(s) used to calculate the different distributions of 234 | positive and negative species. 235 | 236 | Returns 237 | ------- 238 | float or Numpy Array 239 | The absolute value of the difference in concentration between the 240 | positive and negatively charged species in the system. A float is 241 | returned if an int or float is input as the pH: a Numpy array is 242 | returned if an array of pH values is used as the input. 243 | ''' 244 | twoD = True 245 | if isinstance(pH, (int, float)) or pH.shape[0] == 1: 246 | twoD = False 247 | else: 248 | pH = np.array(pH, dtype=float) 249 | # Calculate the h3o and oh concentrations and sum them up. 250 | h3o = 10.**(-pH) 251 | oh = (self.Kw)/h3o 252 | x = (h3o - oh) 253 | 254 | # Go through all the species that were given, and sum up their 255 | # charge*concentration values into our total sum. 256 | for s in self.species: 257 | if twoD == False: 258 | x += (s.conc*s.charge*s.alpha(pH)).sum() 259 | else: 260 | x += (s.conc*s.charge*s.alpha(pH)).sum(axis=1) 261 | 262 | # Return the absolute value so it never goes below zero. 263 | return np.abs(x) 264 | 265 | 266 | def pHsolve(self, guess=7.0, guess_est=False, est_num=1500, 267 | method='Nelder-Mead', tol=1e-5): 268 | '''Solve the pH of the system. 269 | 270 | The pH solving is done using a simple minimization algorithm which 271 | minimizes the difference in the total positive and negative ion 272 | concentrations in the system. The minimization algorithm can be 273 | adjusted using the `method` keyword argument. The available methods 274 | can be found in the documentation for the scipy.optimize.minimize 275 | function. 276 | 277 | A good initial guess may help the minimization. It can be set manually 278 | using the `guess` keyword, which defaults to 7.0. There is an 279 | automated method that can be run as well if you set the `guess_est` 280 | argument. This will override whatever you pass is for `guess`. The 281 | `est_num` keyword sets the number of data points that you'd like to 282 | use for finding the guess estimate. Too few points might start you 283 | pretty far from the actual minimum; too many points is probably 284 | overkill and won't help much. This may or may not speed things up. 285 | 286 | Parameters 287 | ---------- 288 | 289 | guess : float (default 7.0) 290 | This is used as the initial guess of the pH for the system. 291 | 292 | guess_est : bool (default False) 293 | Run a simple algorithm to determine a best guess for the initial 294 | pH of the solution. This may or may not slow down the calculation 295 | of the pH. 296 | 297 | est_num : int (default 1500) 298 | The number of data points to use in the pH guess estimation. 299 | Ignored unless `guess_est=True`. 300 | 301 | method : str (default 'Nelder-Mead') 302 | The minimization method used to find the pH. The possible values 303 | for this variable are defined in the documentation for the 304 | scipy.optimize.minimize function. 305 | 306 | tol : float (default 1e-5) 307 | The tolerance used to determine convergence of the minimization 308 | function. 309 | ''' 310 | if guess_est == True: 311 | phs = np.linspace(0, 14, est_num) 312 | guesses = self._diff_pos_neg(phs) 313 | guess_idx = guesses.argmin() 314 | guess = phs[guess_idx] 315 | 316 | self.pHsolution = spo.minimize(self._diff_pos_neg, guess, 317 | method=method, tol=tol) 318 | 319 | if self.pHsolution.success == False: 320 | print('Warning: Unsuccessful pH optimization!') 321 | print(self.pHsolution.message) 322 | 323 | if len(self.pHsolution.x) == 1: 324 | self.pH = self.pHsolution.x[0] 325 | 326 | 327 | 328 | if __name__ == '__main__': 329 | # KOH, just need to define the amount of K+, solver takes care of the 330 | # rest. 331 | a = Inert(charge=+1, conc=0.1) 332 | s = System(a) 333 | s.pHsolve() 334 | print('NaOH 0.1 M pH = ', s.pH) 335 | print() 336 | 337 | # [HCl] = 1.0 x 10**-8 aka the undergrad nightmare 338 | # You just need to define the amount of Cl-. The solver will find the 339 | # correct H3O+ concentration 340 | b = Inert(charge=-1, conc=1e-8) 341 | s = System(b) 342 | s.pHsolve() 343 | print('HCl 1e-8 M pH = ', s.pH) 344 | print() 345 | 346 | # (NH4)3PO4 347 | # H3PO4 input as the fully acidic species 348 | a = Acid(pKa=[2.148, 7.198, 12.375], charge=0, conc=1.e-3) 349 | # NH4+ again input as fully acidic species 350 | # The concentration is 3x greater than the phosphoric acid 351 | b = Acid(pKa=9.498, charge=1, conc=3.e-3) 352 | #k = neutral(charge=1, conc=1.e-4) 353 | #k = neutral(charge=-1, conc=3.e-3) 354 | s = System(a, b) 355 | s.pHsolve() 356 | print('(NH4)3PO4 1e-3 M pH = ', s.pH) 357 | print() 358 | 359 | try: 360 | import matplotlib.pyplot as plt 361 | except: 362 | print('Matplotlib not installed. Some examples not run.') 363 | else: 364 | # Distribution diagram H3PO4 365 | a = Acid(pKa=[2.148, 7.198, 12.375], charge=0, conc=1.e-3) 366 | pH = np.linspace(0, 14, 1000) 367 | plt.plot(pH, a.alpha(pH)) 368 | plt.show() 369 | 370 | # Estimate Best pH 371 | # This is done internallly by the pHsolve function if you use the 372 | # guess_est=True flag 373 | # This is just a graphical method for visualizing the difference in 374 | # total positive and negative species in the system 375 | s = System(a) 376 | diffs = s._diff_pos_neg(pH) 377 | plt.plot(pH, diffs) 378 | plt.show() 379 | 380 | # Phosphoric Acid Titration Curve 381 | # First create a list of sodium hydroxide concentrations (titrant) 382 | Na_concs = np.linspace(1.e-8, 5.e-3, 500) 383 | # Here's our Acid 384 | H3PO4 = Acid(pKa=[2.148, 7.198, 12.375], charge=0, conc=1.e-3) 385 | phs = [] 386 | for conc in Na_concs: 387 | # Create a neutral Na+ with the concentration of the sodium 388 | # hydroxide titrant added 389 | Na = Inert(charge=1, conc=conc) 390 | # Define the system and solve for the pH 391 | s = System(H3PO4, Na) 392 | s.pHsolve(guess_est=True) 393 | phs.append(s.pH) 394 | plt.plot(Na_concs, phs) 395 | plt.show() 396 | --------------------------------------------------------------------------------