Skip to content

Getting Started

Prerequisites

  • Python >= 3.10
  • Rust toolchain (for building from source)
  • maturin (uv tool install maturin)

Installation

equiconc is a Rust library with Python bindings built via maturin. Install from source:

git clone <repo-url>
cd equiconc
python -m venv .venv
source .venv/bin/activate
maturin develop --release

Or with uv:

uv sync
uv run maturin develop --release

Quick start

Simple dimerization

Compute equilibrium concentrations for A + B ⇌ AB:

import equiconc

eq = (
    equiconc.System()
    .monomer("A", 100e-9)       # 100 nM total
    .monomer("B", 100e-9)       # 100 nM total
    .complex("AB", [("A", 1), ("B", 1)], dg_st=-10.0)
    .equilibrium()
)

for name, conc in eq.items():
    print(f"{name}: {conc:.3e} M")

Temperature

The default temperature is 25 °C. Set it explicitly in Celsius or Kelvin:

sys = equiconc.System()                     # 25 C (default)
sys = equiconc.System(temperature_C=37)     # 37 C
sys = equiconc.System(temperature_K=310.15) # 310.15 K = 37 C

Energy specifications

You can specify complex energies in three ways:

Standard free energy (\(\Delta G^\circ\) in kcal/mol):

sys.complex("AB", [("A", 1), ("B", 1)], dg_st=-10.0)

Dimensionless free energy (\(\Delta G / RT\), unitless). When all complexes use this form, temperature is not required:

sys.complex("AB", [("A", 1), ("B", 1)], delta_g_over_rt=-16.2)

Enthalpy and entropy (\(\Delta H\) in kcal/mol, \(\Delta S\) in kcal/(mol·K)). The free energy \(\Delta G = \Delta H - T \Delta S\) is computed at solve time, so changing temperature shifts the equilibrium:

sys.complex("AB", [("A", 1), ("B", 1)], dh_st=-50.0, ds_st=-0.13)

ΔG at one temperature plus ΔS — when you have ΔG° measured at a reference temperature and the entropy of the reaction, pass dg_st as a (value, temperature_C) tuple together with ds_st. Equiconc derives ΔH = ΔG + T·ΔS internally, so the resulting complex behaves like a dh_st/ds_st pair and shifts correctly with temperature_C:

sys.complex(
    "AB", [("A", 1), ("B", 1)],
    dg_st=(-15.0, 37),    # -15 kcal/mol at 37 C
    ds_st=-0.30,          # kcal/(mol K)
)

Builder pattern

The API uses a fluent builder pattern. Chain calls to monomer() and complex(), then call equilibrium() to solve:

sys = equiconc.System(temperature_C=37)
sys = sys.monomer("A", 1e-6)
sys = sys.monomer("B", 1e-6)
sys = sys.complex("AB", [("A", 1), ("B", 1)], dg_st=-10.0)
eq = sys.equilibrium()

Working with results

The Equilibrium object supports dict-like access:

# Bracket access
conc_ab = eq["AB"]

# Membership test
assert "AB" in eq

# Iteration
for name in eq:
    print(name, eq[name])

# Convert to dict
d = eq.to_dict()

# Properties
eq.monomer_names           # ["A", "B"]
eq.complex_names           # ["AB"]
eq.free_monomer_concentrations  # [float, float]
eq.complex_concentrations       # [float]

Multi-complex systems

Define as many complexes as needed. The solver scales with the number of monomer species (not complexes):

eq = (
    equiconc.System()
    .monomer("A", 1e-6)
    .monomer("B", 1e-6)
    .monomer("C", 1e-6)
    .complex("AB", [("A", 1), ("B", 1)], dg_st=-10.0)
    .complex("AC", [("A", 1), ("C", 1)], dg_st=-8.0)
    .complex("ABC", [("A", 1), ("B", 1), ("C", 1)], dg_st=-15.0)
    .equilibrium()
)

Tuning the solver

The default solver settings work for the vast majority of systems. When you need to tune tolerances, trust-region behavior, or numerical clamps, construct a SolverOptions and pass it to System(options=...):

import equiconc

opts = equiconc.SolverOptions(
    gradient_rel_tol=1e-9,    # tighter convergence
    max_iterations=2000,
)

eq = (
    equiconc.System(temperature_C=37, options=opts)
    .monomer("A", 1e-6)
    .monomer("B", 1e-6)
    .complex("AB", [("A", 1), ("B", 1)], dg_st=-12.0)
    .equilibrium()
)

Choosing the objective surface

SolverOptions exposes two trust-region paths via objective=:

  • "linear" (default) — minimizes the convex Dirks dual \(f(\boldsymbol\lambda)\) directly. The Hessian is positive semi-definite, so plain Cholesky always succeeds. Robust on every system the formulation can express.
  • "log" — minimizes \(g(\boldsymbol\lambda) = \ln f(\boldsymbol\lambda)\). Same minimizer, but compresses the exponential dynamic range and can converge in dramatically fewer iterations on stiff systems (very strong binding, asymmetric \(\mathbf c^0\), etc.). The price is that \(g\) is non-convex away from the optimum; equiconc compensates with modified-Cholesky regularization on indefinite Hessians and rejects any step whose model predicts an ascent.
opts = equiconc.SolverOptions(objective="log")

The mass-conservation convergence test is identical for both paths, so results are interchangeable to within tolerance.

See the API Reference for the full list of fields (max iterations, relaxed tolerances, trust-region ρ thresholds and scales, log_c_clamp, log_q_clamp, …).

Conventions

  • Concentrations are in molar (mol/L).
  • Temperature defaults to 25 °C (298.15 K). Specify via temperature_C (Celsius) or temperature_K (Kelvin).
  • Free energies (dg_st) are in kcal/mol with a 1 M standard state (\(u_0 = 1 \text{M}\)).
  • Enthalpy (dh_st) is in kcal/mol; entropy (ds_st) is in kcal/(mol·K).
  • Dimensionless energies (delta_g_over_rt) are unitless \(\Delta G / RT\) values.

Symmetry corrections

Homodimer and higher homo-oligomer symmetry corrections are not applied automatically: equiconc has no way of knowing whether the complex is actually symmetric. If your complex contains identical strands, include the symmetry correction in the \(\Delta G^\circ\) value you provide (e.g., add \(+RT \ln \sigma\) where \(\sigma\) is the symmetry number).

Error handling

Invalid inputs raise ValueError:

import equiconc

# No monomers
try:
    equiconc.System().equilibrium()
except ValueError as e:
    print(e)  # "system has no monomers"

# Negative concentration
try:
    equiconc.System().monomer("A", -1e-9).equilibrium()
except ValueError as e:
    print(e)  # "invalid concentration: -0.000000001 ..."

# Missing energy specification
try:
    equiconc.System().monomer("A", 1e-9).complex("AB", [("A", 1)])
except ValueError as e:
    print(e)  # "must specify energy: dg_st, delta_g_over_rt, ..."