I'm creating a pH equilibrium calculator. Starting with compositions of acids / bases the calculator should come up with the equilibrated concentrations of each component, based on physical constants. I formulated this as a set of equations which need to be solved simultaneously. One key constraint is H_plus*OH_minus must be 10^-14. I included this in the equations.
Fsolve. This works but is sensitive to initial conditions and I can't put constraints (I need concentrations to be >=0):
import numpy as np
from scipy.optimize import fsolve
def equations(variables, Ka, Kw, initial_concentrations):
HA, A_minus, H_plus, OH_minus, ion_Na = variables
HA_init, A_minus_init, H_plus_init, OH_minus_init, ion_Na_init = initial_concentrations
# Define the equations
eq1 = HA + A_minus - HA_init - A_minus_init # Mass balance on acetate
eq2 = H_plus + ion_Na - OH_minus - A_minus # charge balance
eq3 = H_plus * OH_minus - Kw # Kw for water
eq4 = Ka * HA - A_minus * H_plus # HA acid dissociation
eq5 = ion_Na - ion_Na_init # inert ion
return [eq1, eq2, eq3, eq4, eq5]
def equilibrate(initial_concentrations, initial_guess):
# User-supplied equilibrium constant (Ka) and initial concentration of acetic acid (HA_initial)
Ka = 1.8e-5 # mol/L, acetic acid dissociation constant
Kw = 1e-14 # mol/L, ionic product of water
# Use fsolve from SciPy to find the equilibrium concentrations
solution = fsolve(equations, initial_guess, args=(Ka, Kw, initial_concentrations), xtol=1e-18)
return solution
initial_concentrations = [0.0, 0.1, 1e-7, 1e-7, 0.1]
initial_guess = initial_concentrations
# Extract the concentrations
HA, A_minus, H_plus, OH_minus, ion_Na = equilibrate(initial_concentrations, initial_guess)
# Calculate pH
pH = -np.log10(H_plus)
pKw = -np.log10(H_plus) -np.log10(OH_minus)
# Display the results
print(f"HA: {HA:.8f}")
print(f"A-: {A_minus:.8f}")
print(f"H+: {H_plus:.8f}")
print(f"OH-: {OH_minus:.8f}")
print(f"pH: {pH:.2f}")
print(f"pKw: {pKw:.2f}") # THIS IS THE CONDIRMATORY CHECK - Should be 14.
Nelder-Mead. This converges despite the tight tolerances I set, but gives meaningless values (pKw):
import numpy as np
from scipy.optimize import minimize
def equations(variables, Ka, Kw, initial_concentrations):
HA, A_minus, H_plus, OH_minus, ion_Na = variables
HA_init, A_minus_init, H_plus_init, OH_minus_init, ion_Na_init = initial_concentrations
# Define the equations
eq1 = HA + A_minus - HA_init - A_minus_init # Mass balance on acetate
eq2 = H_plus + ion_Na - OH_minus - A_minus # Charge balance
eq3 = H_plus * OH_minus - Kw # Kw for water
eq4 = Ka * HA - A_minus * H_plus # HA acid dissociation
eq5 = ion_Na - ion_Na_init # Inert ion
return eq1**2 + eq2**2 + eq3**2 + eq4**2 + eq5**2 # Use squared error for the minimization
def equilibrate(initial_concentrations, initial_guess, Ka, Kw):
# Define bounds for variables (concentrations should be >= 0)
bounds = [[0, 1], [0, 1], [0, 1], [0, 1], [0, 1]]
# Use minimize from SciPy with BFGS method to find the equilibrium concentrations
result = minimize(equations, initial_guess, args=(Ka, Kw, initial_concentrations),
bounds=bounds,method='Nelder-Mead', options={'disp': True, 'xatol':1E-40, 'fatol':1E-40, 'maxfev':1000000})
return result.x
# User-supplied equilibrium constant (Ka) and initial concentration of acetic acid (HA_initial)
Ka = 1.8e-5 # mol/L, acetic acid dissociation constant
Kw = 1e-14 # mol/L, ionic product of water
initial_concentrations = [0.00, 0.1, 1e-7, 1e-7, 0.1]
initial_guess = initial_concentrations
# Extract the concentrations
HA, A_minus, H_plus, OH_minus, ion_Na = equilibrate(initial_concentrations, initial_guess, Ka, Kw)
# Calculate pH
pH = -np.log10(H_plus)
pKw = -np.log10(H_plus) -np.log10(OH_minus)
# Display the results
print(f"HA: {HA:.8f}")
print(f"A-: {A_minus:.8f}")
print(f"H+: {H_plus:.8f}")
print(f"OH-: {OH_minus:.8f}")
print(f"pH: {pH:.2f}")
print(f"pKw: {pKw:.2f}") # THIS IS THE CONDIRMATORY CHECK - Should be 14.
The confirmatory check value is all over the place instead of being 14.
- Why does the Nelder-Mead algorithm do so poorly? Is it because the absolute values are close to the error? I thought I could resolve this by tightening tolerances.
- How to impose positive constraints in fsolve?
- Is there a better way to do this?
Don't force Nelder-Mead unless you have a very good reason, and I don't see one here.
ion_Nais not actually a degree of freedom, so either pin it to its initial value via bounds, or don't include it in the variables at all.Include (and test) the Jacobian of the cost function.
Minimize, explicit variables
Minimize, implicit variables
There is a different approach where you set the concentration variables implicitly based on your constraints for pKw and ion_Na; it is much faster but probably incorrect:
The fundamental problem is that your system operates at wildly different orders of magnitude. Normalization scaling, and/or calculations in the logarithmic domain, will probably make this better-behaved.
Minimize, constraints only
This approach is certainly the most accurate, but somewhat slower, and also - instead of giving you an approximate solution if the inputs are malformed and would produce negative concentrations - will simply reject the problem as infeasible.