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 |
|---|---|---|---|
|
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 |
|
Stuttgart-Köln MDF, 28-electron core |
post-Cd |
(ditto, per-element refs) |
|
Stuttgart-Köln MDF, 46-electron core |
post-Hg |
(ditto) |
|
Stuttgart-Köln MDF, 60-electron core |
f-block (post-Yb) |
(ditto) |
|
Stuttgart-Köln MDF, 78-electron core |
actinides |
(ditto) |
|
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:
Manual
ECPCenterrecipe (always-available, all versions). You explicitly construct anECPCenterper heavy atom and assign the right ECP library name. Verbose but transparent.vq.auto_ecp_centers(...)helper (basissetdev-conditional; see § Phase-14e auto-helper below). One-liner replacement: parses the bundled.ecpsidecar, 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 passescharge = 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 thatn_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/UKSn_alpha/n_betasplit behave as expected.Gradients derive the same valence count. When
GradientOptions.ecp_centers/ecp_librarymirror 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-atomZ_eff = Z - n_corevector 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-svpfor elements ≥ K (Z ≥ 19) where the basis omits the core.You’re using
dhf-{svp,sv(p),tzvp,…}orx2c-*relativistic bases — these are valence-only by design.The basis you picked has an
.ecpsidecar inpython/vibeqc/basis_library/basis/(orthird_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 |
|
|
Z = 37–54 (Rb–Xe) |
28 |
|
|
Z = 55–86 (Cs–Rn) |
46 |
|
|
Z = 57–71 (lanthanides) |
60 |
|
dhf-tzvp / dhf-qzvp lanthanide variants |
Z = 87–88 (Fr/Ra) |
78 |
|
dhf-* actinide variants |
Z = 21–86 (alternative) |
varies |
|
|
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¶
basis_sets.md— orbital basis selection, including which orbital bases are designed to be paired with an ECP.density_fitting.md— DF / RIJCOSX Fock build, fully ECP-aware.docs/license.md— full ECP licensing inventory.