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.

Automatic attachment and explicit recipes

For molecular run_job calls and direct molecular SCF wrappers, a named basis with a bundled .ecp sidecar automatically attaches its matching ECP centres and libecpint library whenever the caller has not already supplied ECP options. Thus run_job(mol, basis="lanl2dz", method="rhf") runs the valence-electron ECP Hamiltonian, not an all-electron calculation with a valence-only orbital basis.

The two explicit recipes remain useful when you want to inspect or override that automatic selection:

  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 (shipped on main; 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.

For tutorials and sample scripts, use the auto-helper. For custom production scripts that need 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.)

The molecular mean-field drivers automatically wire a bundled ECP sidecar. Use explicit ecp_centers + ecp_library fields when you need to override that selection, or the inline primitive fields for a custom-core ECP.

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 (shipped on main; 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 with ECPs (v0.14+)

Periodic SCF + ECP is supported for the pob-TZVP-REV2 basis family (and any other CRYSTAL-format basis with inline ECP blocks) via run_periodic_job. The ECP data is auto-attached – no manual ECPCenter wiring needed:

import vibeqc as vq
from vibeqc.periodic_runner import run_periodic_job

# Ag fcc, PBE/pob-TZVP-REV2 -- ECP applied automatically.
system = ...  # vq.PeriodicSystem
basis = vq.BasisSet(system.unit_cell_molecule(), "pob-tzvp-rev2")

run_periodic_job(
    system, basis,
    method="RKS", functional="pbe",
    kpoints=[4, 4, 4],
    smearing_temperature=0.005,
    output="ag_pbe",
)

The auto-attach path detects ECP-bearing pob bases by their Z+200 CRYSTAL convention, converts the inline Gaussian expansions to libecpint primitives via set_ecp_basis(…) (Phase 14g), and sets them on the C++ periodic SCF options. H_core uses Z_eff nuclear attraction + V_ECP, n_electrons is reduced to valence-only, and nuclear repulsion uses Z_eff.

For manual control, the PeriodicKSOptions and PeriodicRHFOptions structs carry ecp_primitive_blocks, ecp_home_centers, ecp_effective_charges, and ecp_total_ncore fields – set them directly to bypass the auto-attach heuristic.

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/g: CRYSTAL INPUT ECP-block parser + inline application

Phase 14f shipped on main a parser for CRYSTAL INPUT-format ECP blocks so basis sets distributed in CRYSTAL’s ECP format (the 5th-period pob bases: Rb-I, Cs-Po) parse without raising:

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

Phase 14g wires those parsed inline ECP terms through libecpint’s set_ecp_basis(…) inline-primitive API (no XML library needed):

from vibeqc.basis_crystal import (
    build_periodic_ecp_data,
    crystal_ecp_to_libecpint_arrays,
)

# Convert CRYSTAL ECP → libecpint flat arrays.
arrays = crystal_ecp_to_libecpint_arrays(parsed.ecp)
# arrays.exponents, .coefficients, .ams, .ns -- ready for
# vq.compute_ecp_matrix_from_primitives(basis, center_xyz, [block])

# Or build periodic ECP data for run_periodic_job in one call:
ecp_blocks, ecp_centers, eff_z, ncore = build_periodic_ecp_data(
    system, atom_list
)

This is the path run_periodic_job uses internally for pob-TZVP-REV2 heavy elements. Applications that need ECPs from non-XML sources (vDZP custom cores, mixed-row dhf-* archives) can use the same ECPPrimitiveBlock + compute_ecp_matrix_from_primitives building blocks directly.

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