Equilibrium Formulation API — a Python library for solving chemical equilibrium problems
Project description
efta — Equilibrium Formulation API
efta is a Python library for solving chemical equilibrium problems — from simple acid-base dissociation to complex multi-phase solvent extraction systems.
Features
- Flexible reaction input — define reactions as strings (
'Fe[3+] + 3OH[-] = Fe(OH)3(s)'), dicts, or coefficient–name pairs - Multi-reaction systems — solve coupled equilibria simultaneously using four built-in numerical methods
- Speciation and precipitation — handles both aqueous speciation (Ka, Kb, Kf) and solubility products (Ksp)
- Solvent extraction — single-stage and multistage counter-current / cross-current extraction circuits
- Activity coefficients — plug in Davies, Debye-Hückel, or any custom gamma function
- Parameter fitting — fit equilibrium constants to measured concentration data, with Monte Carlo uncertainty analysis
- Plotting — concentration profiles, speciation fraction diagrams, and extraction stage profiles via matplotlib
Installation
pip install efta
Requires Python 3.10+ and NumPy, SciPy, matplotlib (installed automatically).
Quick Start
Acid-Base Equilibrium
from efta import reaction, reactions
# Define individual equilibrium reactions with their equilibrium constants
acetic_acid = reaction('CH3COOH = CH3COO[-] + H[+]', 1.8e-5) # Ka of acetic acid
water_autoion = reaction('H2O = H[+] + OH[-]', 1e-14) # Kw of water
# Combine reactions into a system and solve
sys = reactions(acetic_acid, water_autoion)
# Provide initial concentrations (mol/L) for all species
c_eq = sys.equilibrium({
'CH3COOH': 0.1, # 0.1 M acetic acid
'CH3COO[-]': 0.0,
'H[+]': 1e-7, # neutral pH starting guess
'OH[-]': 1e-7,
'H2O': 1.0,
})
import numpy as np
print(f"pH = {-np.log10(c_eq['H[+]']):.2f}") # → pH ≈ 2.87
Precipitation / Solubility
from efta import reaction, reactions
# ksp=True tells efta this is a solubility product reaction
calcite = reaction('CaCO3(s) = Ca[2+] + CO3[2-]', 3.36e-9, ksp=True)
sys = reactions(calcite)
c_eq = sys.equilibrium({'CaCO3(s)': 1.0, 'Ca[2+]': 0.0, 'CO3[2-]': 0.0})
print(f"[Ca²⁺] = {c_eq['Ca[2+]']:.2e} mol/L")
Concentration Sweeps and Plotting
import numpy as np
# Sweep initial [CH3COOH] from 1e-4 to 1.0 M on a log scale
fig, ax = sys.plot(
{'CH3COOH': [1e-4, 1.0], 'H[+]': 1e-7, 'OH[-]': 1e-7, 'H2O': 1.0},
sweep='CH3COOH',
logx=True,
logy=True,
n_points=60,
)
Species Notation
efta uses a compact notation for chemical species:
| Notation | Meaning | Example |
|---|---|---|
Fe[3+] |
Fe with charge 3+ | ferric iron |
OH[-] |
hydroxide | |
Fe(OH)3(s) |
solid phase | ferric hydroxide precipitate |
H2A2(org) |
organic phase | di-2-ethylhexylphosphoric acid |
e[-] |
electron | for redox reactions |
$(1/3) |
fractional coefficient | $(1/3)Fe[3+] |
Reaction Construction
Four equivalent ways to define the same reaction:
from efta import reaction
# 1. String (most readable)
r = reaction('Fe[3+] + 3OH[-] = Fe(OH)3(s)', 1e3)
# 2. Stoichiometry dict (negative = reactant, positive = product)
r = reaction({'Fe[3+]': -1, 'OH[-]': -3, 'Fe(OH)3(s)': 1}, 1e3)
# 3. Separate reactant and product dicts
r = reaction({'Fe[3+]': 1, 'OH[-]': 3}, {'Fe(OH)3(s)': 1}, 1e3)
# 4. (coefficient, name) pairs — last argument is K
r = reaction((-1, 'Fe[3+]'), (-3, 'OH[-]'), (1, 'Fe(OH)3(s)'), 1e3)
Combining Reactions
Reactions can be added and scaled. K values update automatically:
# Adding two reactions combines their stoichiometries; K values multiply
r_combined = r1 + r2
# Scaling multiplies all coefficients; K is raised to that power
r_half = r1 / 2 # divide all coefficients by 2 → K becomes √K
r_rev = r1 * -1 # reverse the reaction → K becomes 1/K
The reactions System
A reactions object holds multiple coupled reactions and provides the solver interface:
from efta import reaction, reactions
sys = reactions(r1, r2, r3)
# --- Solve for equilibrium concentrations ---
c_eq = sys.equilibrium({'Fe[3+]': 0.01, 'OH[-]': 1e-7, ...})
# --- Inspect which species are in the system ---
print(sys.species) # frozenset of all species names
print(sys.aqueous_species) # aqueous species only
print(sys.organic_species) # organic-phase species only
# --- Plot a concentration sweep ---
fig, ax = sys.plot({'Fe[3+]': [1e-5, 0.1], ...}, sweep='Fe[3+]', logx=True)
# --- Inverse solve: find initial [X] that gives a target equilibrium ---
c_target = sys.find(
unknown='NaOH',
c0={'NaOH': 0.0, 'H[+]': 1e-7, ...},
target={'H[+]': 1e-8}, # target pH 8
)
The solution Class
A solution pairs a concentration dict with a volume, and provides convenient access methods:
from efta import solution
sol = solution({'H[+]': 1e-4, 'OH[-]': 1e-10, 'H2O': 1.0}, volume=0.5)
sol['H[+]'] # 1e-4 — species concentration in mol/L
sol.pH # 4.0 — convenience property
sol.ionic_strength # mol/L
sol.moles('H[+]') # mol = concentration × volume
sol.mass('H2O') # g = moles × molar mass
sol.aqueous # dict of aqueous-phase species only
sol.organic # dict of organic-phase species only
sol.solid # dict of solid-phase species only
sol_2L = sol(2.0) # clone with 2 L volume — moles are preserved
mixed = sol1 + sol2 # mix two solutions (moles add, volumes add)
Creating a solution directly from reactions
sol = sys.solution({'CH3COOH': 0.1, 'H2O': 1.0}, volume=1.0)
# Returns a solution object at equilibrium
Activity Coefficients (Non-Ideal Systems)
Register a gamma function for any species in a reaction. The solver calls it at each iteration and adjusts K accordingly:
import math
# Davies equation activity coefficient (depends on ionic strength I)
def davies(I):
sqI = math.sqrt(I)
return 10 ** (-0.509 * 3**2 * (sqI / (1 + sqI) - 0.3 * I))
# 'I' is a special token — efta computes ionic strength and passes it to davies()
rxn.set_gamma('Fe[3+]', (davies, 'I'))
# Gamma depending on another species' concentration
rxn.set_gamma('Fe[3+]', (lambda c_cl: 1 - 0.1 * c_cl, 'Cl[-]'))
# Constant gamma (no dependencies)
rxn.set_gamma('Fe[3+]', (lambda: 0.5,))
Solvent Extraction
efta includes a full solvent extraction module for modelling liquid–liquid extraction processes.
from efta import reaction, solution
from efta.solventextraction import (
sx, countercurrent, distribution_coef, separation_factor, splitter
)
# Extraction reaction: metal transfers from aqueous to organic phase
rxn = reaction('LaCl[2+] + 3H2A2(org) = LaClA2(HA)4(org) + 2H[+]', 10.6)
feed = solution({'LaCl[2+]': 0.003, 'H[+]': 0.3}, volume=1.0) # aqueous feed
org = solution({'H2A2(org)': 0.25}, volume=1.0) # organic phase
# Single-stage extraction
stage = sx(rxn, feed, org)
stage.run()
extract, raffinate = stage.outlets[0], stage.outlets[1]
D = stage.distribution_coef('La') # D = [La]_org / [La]_aq
beta = stage.separation_factor('La', 'Ce') # β = D(La) / D(Ce)
# 5-stage counter-current extraction circuit
ms = countercurrent(rxn, stages=5, feed=feed, organic=org)
ms.run() # solve all stages at equilibrium
ms.run(efficiency=0.85) # with stage efficiency < 1
raffinate = ms.outlets[5] # aqueous exit after stage 5
extract = ms.outlets[1] # organic exit after stage 1
# Plot concentration profile across stages
ms.plot(['La', 'Ce'], phase='aq')
# Reflux design with a flow splitter
split = splitter(1, 2) # splits flow: 1/3 reflux, 2/3 forward
reflux, forward = split(extract)
Available multistage topologies:
| Function | Description |
|---|---|
countercurrent |
Aqueous feeds stage 1→n, organic feeds stage n→1 |
crosscurrent |
Aqueous feeds stage 1→n, fresh organic at every stage |
strip_countercurrent |
Organic 1→n, aqueous n→1 (stripping mode) |
strip_crosscurrent |
Organic 1→n, fresh aqueous at every stage |
Parameter Fitting
Fit unknown equilibrium constants to experimental data:
from efta import freaction, freactions, model, analyze
# $(x1) is a free parameter — efta will optimise it
r_fit = freaction('Fe[3+] + 3OH[-] = Fe(OH)3(s)', '$(x1)', ksp=True)
# Experimental data: list of {species: measured_concentration} dicts
data = [
{'Fe[3+]': 1e-5, 'OH[-]': 1e-3},
{'Fe[3+]': 2e-5, 'OH[-]': 5e-4},
# ...
]
best_fit = model(r_fit, data)
print(f"Best log K = {best_fit.logK:.2f}")
# Bootstrap uncertainty analysis
result = analyze(r_fit, data, n_bootstrap=200)
print(f"log K = {result.logK_mean:.2f} ± {result.logK_std:.2f}")
Module Reference
| Module | Description |
|---|---|
efta.reaction |
reaction class — single equilibrium reaction |
efta.reactions |
reactions class — coupled system and solver |
efta.species |
Species name parsing: species(), formula(), charge(), components() |
efta.solution |
solution class — composition + volume |
efta.mixture |
mixture class — ordered collection of solutions |
efta.balance |
Cluster detection and conservation-law analysis |
efta.system |
System assembly, activity coefficients, extent↔concentration |
efta.solver |
Numerical solvers (Method L, A, B, DE) |
efta.model |
Parameter fitting, Monte Carlo analysis |
efta.plotting |
Concentration plots, speciation diagrams, style singleton |
efta.styling |
Runtime palette and font-size helpers |
efta.periodic_table |
Atomic masses and element lookup |
efta.solventextraction.sx |
Single-stage extraction |
efta.solventextraction.multistage |
Multistage extraction circuits |
efta.solventextraction.units |
splitter flow-splitter unit |
Error Handling
All efta exceptions inherit from EftaError, so you can catch the entire family with a single clause:
from efta import EftaError, ConvergenceError, ConvergenceWarning
import warnings
# Turn convergence warnings into hard errors (useful during debugging)
warnings.filterwarnings('error', category=ConvergenceWarning)
try:
c_eq = sys.equilibrium(c0)
except ConvergenceError as e:
print(f"Solver failed — best residual: {e.residual:.2e}")
except EftaError as e:
print(f"efta error: {e}")
| Exception | Raised when |
|---|---|
SpeciesError |
Species name cannot be parsed |
ReactionError |
Reaction is malformed (bad K, empty side, …) |
BalanceError |
Automatic balancing fails |
InputError |
Invalid argument passed to a function |
ConcentrationError |
Negative or missing initial concentration |
ConvergenceError |
All solver methods fail to converge |
ConvergenceWarning |
Solver returns a result but residual exceeds tolerance |
License
MIT — see LICENSE for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file efta-1.0.1.tar.gz.
File metadata
- Download URL: efta-1.0.1.tar.gz
- Upload date:
- Size: 147.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
58211f4647fed4071c8847bdcaca40a0f851dddf71a280a0b2408fd8a77cd6e8
|
|
| MD5 |
df90040ee62814fa50e687cecf9d1cc0
|
|
| BLAKE2b-256 |
eb44b964c3b38f0c1101e7fd7215dbb0061867b9581e1dc17e29bb3bcf12da03
|
File details
Details for the file efta-1.0.1-py3-none-any.whl.
File metadata
- Download URL: efta-1.0.1-py3-none-any.whl
- Upload date:
- Size: 158.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
78a416d4ceec649083fbdcb9ec233c6dc2fb328c59aae434c293a68d082418b3
|
|
| MD5 |
66ef35930b040b8447e93956887a6e47
|
|
| BLAKE2b-256 |
e2b0ddc70ee7c6c7a2a47513d0e4e336feadda6bb0a0c1e8429a60d6b59d55ec
|