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.
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:
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 (shipped onmain; 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.
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 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.)
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 |
|
|
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 (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¶
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.-
full ECP licensing inventory.