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 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.