Effective core potentials (ECPs)

vibe-qc ships libecpint 1.0.7 vendored as a runtime dependency. ECP integrals, reduced-Z nuclear-attraction, and valence-electron-count accounting are wired through every molecular SCF driver (RHF, UHF, RKS, UKS). Heavy-element chemistry — including the d-block, the lanthanides, and the actinides — is reachable without constructing the all-electron basis.

The banner lists libecpint as a linked dependency:

linked: libint 2.13.1 · libxc 7.0.0 · spglib 2.7.0 · libecpint 1.0.7

What ships

Six ECP libraries bundled inside libecpint, MIT-licensed (see docs/license.md):

Library

Family

Atomic-number coverage

Citation family

ecp10mdf

Stuttgart-Köln MDF, 10-electron core

post-K (rows 4+)

Andrae, Häußermann, Dolg, Stoll, Preuß, Theor. Chim. Acta 77, 123 (1990) and follow-ups

ecp28mdf

Stuttgart-Köln MDF, 28-electron core

post-Cd

(ditto, per-element refs)

ecp46mdf

Stuttgart-Köln MDF, 46-electron core

post-Hg

(ditto)

ecp60mdf

Stuttgart-Köln MDF, 60-electron core

f-block (post-Yb)

(ditto)

ecp78mdf

Stuttgart-Köln MDF, 78-electron core

actinides

(ditto)

lanl2dz

Hay-Wadt LANL

post-Na (rows 3+)

Hay, Wadt, J. Chem. Phys. 82, 270 + 299 + 284 (1985)

The accompanying valence-only orbital basis sets (e.g. lanl2dz orbital basis, def2-svp for Stuttgart-MDF cores) are available in vibe-qc’s standard basis library.

The two recipes

There are two ways to wire an ECP into an SCF run:

  1. Manual ECPCenter recipe (always-available, all versions). You explicitly construct an ECPCenter per heavy atom and assign the right ECP library name. Verbose but transparent.

  2. vq.auto_ecp_centers(...) helper (basissetdev-conditional; see § Phase-14e auto-helper below). One-liner replacement: parses the bundled .ecp sidecar, picks the right library, and returns (ecp_centers, library_name) ready to drop onto any SCF Options.

If you’re writing tutorials or sample scripts and the auto-helper is available, use it. If you’re writing custom production scripts and want full control over the ECP metadata, the manual recipe is the right surface.

Manual recipe (always available)

import vibeqc as vq

# Pt atom at the origin.
mol = vq.Molecule([vq.Atom(78, [0.0, 0.0, 0.0])])
basis = vq.BasisSet(mol, "lanl2dz")    # valence-only orbital basis

# One ECPCenter per heavy atom.
ec = vq.ECPCenter()
ec.Z = 78                              # atomic number of the centre
ec.xyz = [0.0, 0.0, 0.0]               # bohr (Cartesian)

# Multiplicity lives on the Molecule; rebuild it with the right
# spin state if you're not already starting from one.
mol = vq.Molecule([vq.Atom(78, [0.0, 0.0, 0.0])], multiplicity=3)
basis = vq.BasisSet(mol, "lanl2dz")

# Wire the ECP onto the SCF options.
opts = vq.UHFOptions()
opts.ecp_centers = [ec]
opts.ecp_library = "lanl2dz"           # which ECP XML library

result = vq.run_uhf(mol, basis, opts)
print(result.energy)                   # -118.227 Ha (22 BFs, 125 SCF iters)

The same ecp_centers + ecp_library flags are accepted by run_rhf, run_rks, and run_uks. Multiple heavy atoms → build one ECPCenter per atom and pass them all in:

# Pt-Pt dimer, 10-bohr separation. Singlet (paired) lives on
# the Molecule, not on the SCF options.
mol = vq.Molecule(
    [
        vq.Atom(78, [0.0, 0.0, -5.0]),
        vq.Atom(78, [0.0, 0.0, +5.0]),
    ],
    multiplicity=1,
)
basis = vq.BasisSet(mol, "lanl2dz")

opts = vq.UHFOptions()
opts.ecp_centers = [
    vq.ECPCenter(Z=78, xyz=[0.0, 0.0, -5.0]),
    vq.ECPCenter(Z=78, xyz=[0.0, 0.0, +5.0]),
]
opts.ecp_library = "lanl2dz"

Electron count and charge convention

Molecule.charge is always the physical ionic charge, and Molecule.n_electrons() is always the full physical electron count, including the core electrons the ECP will replace. The SCF subtracts the replaced cores itself: the per-element core sizes come from the ECP library (ECPHcore.total_ncore on the C++ side), and only the remaining valence electrons fill orbitals in the valence-only basis.

Worked example, [ZnH]+ with ecp10mdf:

mol = vq.Molecule(
    [vq.Atom(30, [0.0, 0.0, 0.0]), vq.Atom(1, [0.0, 0.0, 3.0])],
    charge=1,          # the physical ionic charge, nothing more
    multiplicity=1,
)
# Z_total = 31, charge +1  ->  n_electrons() = 30 (physical)
# ecp10mdf replaces 10 core electrons on Zn (3s 3p 3d 4s stay
# in valence)                ->  20 valence electrons
# closed shell              ->  10 doubly occupied orbitals

Things that follow from the convention:

  • Never fold the core count into charge. An input that passes charge = n_core + ionic_charge (a convention some codes and some pre-v0.12 vibe-qc scripts used) now describes a different, over-stripped ion: the SCF would remove the core a second time. The drivers raise if the cores outnumber the electrons, with a reminder that n_electrons() must be the full physical count.

  • Parity and multiplicity arithmetic work on either count. Every shipped core (ecpNNmdf, lanl2dz) replaces an even number of electrons, so the physical and valence counts have the same parity; run_rhf’s even-electron check and the UHF/UKS n_alpha/n_beta split behave as expected.

  • Gradients derive the same valence count. When GradientOptions.ecp_centers / ecp_library mirror the SCF options (see § Gradients below), the gradient drivers rebuild the energy-weighted density from exactly the orbitals the SCF occupied. Leaving them unset on an ECP run is the bare-Z bug the 2026-05-18 audit flagged.

  • vq.ecp_effective_charges(mol, ecp_centers, library_name) returns the per-atom Z_eff = Z - n_core vector if you need the accounting explicitly (atoms without an ECP keep bare Z).

Phase-14e auto-helper (vq.auto_ecp_centers)

The Phase 14e helper short-circuits the boilerplate above. Given a molecule and a basis name, it parses the bundled .ecp sidecar, picks the matching libecpint XML library, and returns ready-to-drop (ecp_centers, library_name):

import vibeqc as vq

mol = vq.Molecule(
    [
        vq.Atom(78, [0.0, 0.0, -5.0]),
        vq.Atom(78, [0.0, 0.0, +5.0]),
    ],
    multiplicity=1,                    # singlet (paired)
)
basis = vq.BasisSet(mol, "lanl2dz")

opts = vq.UHFOptions()
opts.ecp_centers, opts.ecp_library = vq.auto_ecp_centers(mol, "lanl2dz")

result = vq.run_uhf(mol, basis, opts)
# Same -118.227 Ha as the manual recipe, but no per-atom ECPCenter
# wiring needed. Tutorial / sample-script-friendly.

The helper is the recommended path for tutorials and example scripts. Live numerics match the manual recipe: Pt/lanl2dz UHF = −118.227 Ha (singlet) at 22 BFs in 125 iters; Si/lanl2dz RHF = −3.475 Ha.

Important

Status (shipped on main). vq.auto_ecp_centers is exported from vibeqc.__init__ and works out of the box on a pip install of vibe-qc. The basissetdev paper-writing branch still tracks basis-set-side work (the BSE-fetched basis sets, ongoing re-optimisations) but the ECP auto-helper itself has landed in main since v0.8.0. The manual recipe below remains useful when you want to override the Stuttgart-MDF default mapping per element row.

When you need it

ECPs are mandatory when:

  • You’re using a valence-only basis like lanl2dz, lanl2tz, def2-svp for elements ≥ K (Z ≥ 19) where the basis omits the core.

  • You’re using dhf-{svp,sv(p),tzvp,…} or x2c-* relativistic bases — these are valence-only by design.

  • The basis you picked has an .ecp sidecar in python/vibeqc/basis_library/basis/ (or third_party/libint/install/share/libint/2.13.1/basis/).

ECPs are not needed when:

  • You’re using an all-electron basis (6-31g*, def2-tzvp, cc-pvtz, pob-tzvp). The basis carries every core and valence electron explicitly.

  • All your atoms are H–Mg (Z ≤ 12) and your basis covers them all-electron. (Most commonly: any first/second-period organic chemistry.)

If you forget the ECP wiring on a basis that needs one, vibe-qc fails loudly at SCF with "canonical orth dropped too many basis directions" — the symptom of trying to fit valence orbitals to nuclei that still expect core electrons. The fix is to add the ecp_centers + ecp_library flags.

Choosing the right ECP library

For each heavy atom, the right ECP library is determined by the size of the core electron count the orbital basis implies:

Atomic number

Core size

Library

Orbital basis examples

Z = 19–36 (K–Kr)

10

ecp10mdf

def2-svp, def2-tzvp, def2-qzvp for K–Kr

Z = 37–54 (Rb–Xe)

28

ecp28mdf

def2-svp, def2-tzvp, def2-qzvp for Rb–Xe

Z = 55–86 (Cs–Rn)

46

ecp46mdf

def2-svp, def2-tzvp, def2-qzvp for Cs–Rn

Z = 57–71 (lanthanides)

60

ecp60mdf

dhf-tzvp / dhf-qzvp lanthanide variants

Z = 87–88 (Fr/Ra)

78

ecp78mdf

dhf-* actinide variants

Z = 21–86 (alternative)

varies

lanl2dz

lanl2dz, lanl2tz orbital bases

The Stuttgart-Köln MDF family is the def2-* default. The Hay-Wadt LANL family is the LANL2DZ orbital basis default. Mixing-and-matching across families is possible but should be done deliberately: auto_ecp_centers(mol, basis_name) picks the right library when the helper is available; manually, match library to orbital basis as the table above suggests.

Python API surface

import vibeqc as vq

# Version probe
vq.libecpint_version()              # "libecpint 1.0.7 (vendored, MAX_L=5)"
vq.library_versions()["libecpint"]  # same

# Per-atom ECP descriptor (manual path)
ec = vq.ECPCenter()
ec.Z = 78
ec.xyz = [0.0, 0.0, 0.0]

# AO matrix V_ECP_{μν} = ⟨χ_μ | V_ECP | χ_ν⟩  (spherical basis)
V_ecp = vq.compute_ecp_matrix(
    basis,                          # vibeqc.BasisSet
    ecp_centers=[ec],
    library_name="lanl2dz",
)                                   # → numpy.ndarray (n_bf, n_bf)

# Auto-helper (basissetdev-conditional; see § Phase-14e above)
ecp_centers, library_name = vq.auto_ecp_centers(mol, "lanl2dz")

# Drive any SCF with ECPs:
opts = vq.UHFOptions()
opts.ecp_centers = [ec]
opts.ecp_library = "lanl2dz"
result = vq.run_uhf(mol, basis, opts)

The same ecp_centers + ecp_library API works on every molecular SCF driver: run_rhf, run_uhf, run_rks, run_uks. Periodic SCF + ECP is not yet supported; queued for v0.x.x once the periodic-Γ JKBuilder lands a periodic ECP contribution.

Gradients, forces, geometry optimization

Analytic nuclear gradients are ECP-aware (2026-05-18 audit remediation). The gradient must differentiate the same Hamiltonian the SCF solved, so GradientOptions mirrors the SCF options’ ECP fields:

go = vq.GradientOptions()
go.ecp_centers = opts.ecp_centers   # same list as the SCF call
go.ecp_library = opts.ecp_library
grad = vq.compute_gradient(mol, basis, result, go)

When ecp_centers is set, the nuclear-repulsion and nuclear-attraction derivative pieces switch to the effective charges Z_eff = Z - n_core and a dV_ECP/dR term (libecpint first derivatives) is added; with ecp_centers empty you get the bare-Z all-electron gradient, which is wrong for an ECP SCF at the 0.1 Ha/bohr scale (regression-pinned in tests/test_ecp_gradient.py).

The ASE calculator (vibeqc.ase.VibeQC) and the FD Hessian (compute_hessian_fd) copy the ECP fields off the SCF options automatically, so atoms.get_forces() and optimize=True need no extra wiring. Manual compute_gradient* callers must set the two fields themselves. Diagnostic helpers: vq.ecp_effective_charges(mol, ecp_centers, library_name) and vq.compute_ecp_gradient_contribution(basis, mol, ecp_centers, D).

The analytic Hessian kernels (compute_hessian_*_analytic) do not take ECP options yet; use the FD Hessian for ECP systems.

Phase-14f: CRYSTAL INPUT ECP-block parser

basissetdev also adds a parser for CRYSTAL INPUT-format ECP blocks, so basis sets distributed in CRYSTAL’s ECP format (e.g. 5th-period pob bases) load without raising:

parsed = vq.basis_crystal.parse_crystal_atom_basis(crystal_input_block)
parsed.ecp                          # CrystalECP dataclass
parsed.ecp.terms                    # list of CrystalECPTerm

Same basissetdev caveat as the auto-helper above: if basissetdev doesn’t merge, this parser is also unavailable. Workaround: convert the CRYSTAL .basis file to BSE-format .g94 + .ecp sidecar via the basissetdev fetch + split scripts, then load via the standard BasisSet(mol, "name") path.

Citations

For published work using ECPs:

  • libecpint software: R. A. Shaw, J. G. Hill, J. Chem. Phys. 147, 074108 (2017); R. A. Shaw, J. Chem. Phys. 159, 014103 (2023).

  • Stuttgart-Köln MDF family: Andrae, Häußermann, Dolg, Stoll, Preuß, Theor. Chim. Acta 77, 123 (1990) and per-element follow-ups in the libecpint repository documentation.

  • Hay-Wadt LANL family: Hay, Wadt, J. Chem. Phys. 82, 270 (1985); 299 (1985); 284 (1985).

The libecpint upstream project documents the per-element citations in its source repository.

See also