Handover to the documentation chat — libecpint / ECP support

Source chat: basissetdev. Branch: basissetdev @ commit add8163. Date: 2026-05-09. Status: Phase 14a-d shipped end-to-end. Phase 14e/f deferred and captured below as known gaps.

This document tells the documentation chat what to write up for end-users of vibe-qc’s effective-core-potential (ECP) support. The underlying machinery is now functional; what’s missing is exposure in the user-facing docs (tutorials, API reference, CHANGELOG entry).


Headline for the documentation chat

vibe-qc 0.7.x ships with libecpint 1.0.7 vendored. ECP integrals, reduced-Z nuclear-attraction, and valence-electron-count accounting are wired through every molecular SCF driver (RHF, UHF, RKS, UKS). The Karlsruhe def2-ECP family, Stuttgart-Köln MDF (ecp10mdf, ecp28mdf, ecp46mdf, ecp60mdf, ecp78mdf), and Hay-Wadt LANL2DZ ECPs are bundled and ready to use. The user supplies an explicit ecp_centers list per heavy atom plus the ecp_library name; auto-population from inline-ECP basis files is on the v0.8 roadmap.

The banner now lists libecpint:

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

Public API surface (already exported)

import vibeqc as vq

# Version + library 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                           # atomic number of the centre
ec.xyz = [0.0, 0.0, 0.0]            # Cartesian position (bohr)

# 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)

# All four SCF entry points accept ecp_centers + ecp_library:
opts = vq.UHFOptions()
opts.ecp_centers = [ec]
opts.ecp_library = "lanl2dz"
result = vq.run_uhf(mol, basis, opts)

Worked example (drop into a tutorial)

Pt atom UHF with LANL2DZ — Phase-14e auto-population, the recommended recipe:

import vibeqc as vq

# Pt 5d⁹6s¹ ³D ground state, multiplicity = 3
mol = vq.Molecule([vq.Atom(78, [0.0, 0.0, 0.0])], multiplicity=3)
basis = vq.BasisSet(mol, "lanl2dz")    # 22 basis functions (valence only)

opts = vq.UHFOptions()
opts.ecp_centers, opts.ecp_library = vq.auto_ecp_centers(mol, "lanl2dz")
opts.max_iter = 200                    # heavy-atom UHF needs ~125 iters

result = vq.run_uhf(mol, basis, opts)
print(f"Pt UHF/LANL2DZ: {result.energy:.4f} Ha "
      f"({result.n_iter} iters, converged={result.converged})")
# → Pt UHF/LANL2DZ: -118.2268 Ha (125 iters, converged=True)

The pre-Phase-14e manual recipe (kept for reference / advanced users):

opts.ecp_centers = [vq.ECPCenter()]
opts.ecp_centers[0].Z = 78
opts.ecp_centers[0].xyz = [0.0, 0.0, 0.0]
opts.ecp_library = "lanl2dz"

Si atom UHF with LANL2DZ (smaller, faster):

mol = vq.Molecule([vq.Atom(14, [0, 0, 0])], multiplicity=3)
basis = vq.BasisSet(mol, "lanl2dz")    # 8 basis functions
opts = vq.UHFOptions()
opts.ecp_centers = [vq.ECPCenter()]
opts.ecp_centers[0].Z = 14
opts.ecp_centers[0].xyz = [0, 0, 0]
opts.ecp_library = "lanl2dz"
result = vq.run_uhf(mol, basis, opts)
# → Si UHF/LANL2DZ: -3.4748 Ha, 67 iters

What changed under the hood (for the changelog entry)

Phase 14a-c (already shipped before this work):

  1. libecpint vendored under third_party/libecpint/install/ with pugixml + libcerf C++ deps also vendored. CMake links ECPINT::ecpint.

  2. compute_ecp_matrix C++ + Python API for V_ECP integrals.

  3. compute_ecp_one_electron returns ECPHcore { V (with Z_eff nuclear attraction), V_ecp, E_nuc, total_ncore }. All four SCF drivers consume this.

Phase 14d shipped in commit add8163 (2026-05-09):

  1. library_versions() now lists libecpint (user requirement “list it in our header”). Banner one-liner picks it up.

  2. ECP-aware basis loader. BSE-format .g94 files for vDZP, LANL2DZ, dhf-*, etc. bundle orbital basis blocks AND ECP definition blocks; libint2’s parser chokes on <Sym>-ECP headers. New tooling splits each .g94 at install time:

    • scripts/basisset_dev/split_ecp_g94.py — state-machine splitter with lookahead, handles BSE conventions including all-caps Pople-era element symbols (NA, MG, SI).

    • scripts/setup_basis_library.sh invokes it automatically.

    • 13 ECP-bearing files split: 6 dhf-* + 6 lanl* + vdzp.

    • Result: every BSE .g94 in the bundle now loads cleanly via vq.BasisSet(mol, name).

  3. total_ncore valence-electron accounting. SCF drivers now compute n_elec_eff = mol.n_electrons() ecp_h.total_ncore and use it for nocc / n_α / n_β. Without this, the SCF tried to put the all-electron count into a basis sized for valence only and hit “canonical orthogonalization dropped too many basis directions”. Backward compat preserved when ecp_centers is empty (total_ncore = 0).

Known gaps the documentation chat should mention

Gap 1 — User must supply ecp_centers manually (Phase 14e)

The ECPCenter list is currently a user-supplied per-call argument. The BasisSet knows from the .ecp sidecar which atoms have ECPs and what ncore was assigned, but the SCF entries don’t read that info — so the user has to mirror the basis’s ECP map in opts.ecp_centers. Until Phase 14e lands, document this as the expected workflow. Sample boilerplate:

# Boilerplate that the docs chat could turn into a helper:
def ecp_centers_for(mol, ecp_library_atoms):
    """Build ecp_centers covering every atom in `mol` whose Z is in
    `ecp_library_atoms` (e.g. {78, 79, 53} for an Au-Pt-I cluster)."""
    return [
        _make_center(a.Z, a.xyz)
        for a in mol.atoms()
        if a.Z in ecp_library_atoms
    ]

Gap 2 — Choosing the right ecp_library

The bundled XML libraries cover specific element / core-size pairings:

Library

Replaces

Element range

ecp10mdf

10 e⁻

K – Cu (Stuttgart-Köln 10-core MDF)

ecp28mdf

28 e⁻

Rb – Ag (Stuttgart-Köln 28-core MDF)

ecp46mdf

46 e⁻

Cs – La, lanthanides

ecp60mdf

60 e⁻

Hf – Au

ecp78mdf

78 e⁻

Hg – Rn

lanl2dz

varies

Hay-Wadt LANL2DZ family (per-element)

For def2-TZVP / def2-TZVPP / def2-QZVP (Karlsruhe), use ecp_library matching the element’s row:

  • Rb–Cd → "ecp28mdf"

  • Cs–Hg → "ecp46mdf" (Cs, Ba, La), "ecp60mdf" (Hf–Hg)

  • Tl–Rn → "ecp78mdf"

For LANL2DZ / LANL08 orbital bases, ecp_library="lanl2dz" covers every element the orbital basis covers.

For vDZP (Grimme 2023): the inline ECPs are non-standard per-element core sizes (e.g. B with ncore=3). Until Phase 14e arrives, vDZP cannot be used end-to-end through vq.run_uhf — the orbital basis loads, but no XML library matches the inline definitions. Users should fall back to def2-TZVP+ECP for now.

For dhf- and x2c-**: the basis sets need a relativistic Hamiltonian (X2C / DKH2 / ZORA) to be physically meaningful. The basis files load, but vibe-qc has no relativistic Hamiltonian yet. Document as “files ship, infrastructure pending”.

Gap 3 — CRYSTAL-format ECPs (5th-period pob)

vibeqc.basis_crystal.parse_crystal_atom_basis raises NotImplementedError when a CRYSTAL-format file uses the 200+Z ECP convention (i.e. for Rb–I or Cs–Po pob basis sets). Pure-Python pob fifth-period support requires extending the parser to emit ECPCenter lists alongside the orbital basis.

This is the natural next item for basissetdev to deliver post-handover.

Test artefacts

Two test files in tests/basisset_dev/ cover the work end-to-end:

  • test_basis_library_load.py — parametrised across every .g94 in basis_library/basis/ (366 cases on the current bundle). Two cases per file: BasisSet(mol, name) succeeds, and a single-atom RHF/UHF on the lightest covered element produces a finite negative energy. ECP-bearing files now load cleanly (Phase 14d); the SCF half xfails them with reason “needs Phase 14e”.

  • test_ecp_scf.py — NEW. 4 focused integration cases for the manual-ecp_centers path: Si and Pt UHF on LANL2DZ, plus regression guards for non-ECP backward compat and ncore subtraction propagation.

Final tally on this work: 360 passed, 1 skipped (Phase-14e placeholder), 18 xfailed in 484 s. The 18 xfails partition into:

  • 13 ECP-bearing × test_single_atom_scf (Phase 14e)

  • 3 high-l (cc-pV7Z and friends) — needs libint rebuild with higher LIBINT_MAX_AM

  • 2 SAP atomic-density helpers (not orbital bases by design)

Test execution policy

Run the full parametrised load test on planetx via vq, not on the laptop. The 484-s run with 130+ atomic SCFs — particularly the heavy-atom + LANL2DZ cases — was a contributor to the laptop crash on 2026-05-09 overnight. Standard recipe per reference_vq_queue.md:

# From the laptop, in the basissetdev worktree
JOBID=$(vq submit -d ./tests/basisset_dev -- \
        python -m pytest test_basis_library_load.py --noconftest -q)
# Poll, then
vq fetch "$JOBID" -o ./out

A laptop-tier subset (architecture tests + small-basis SCF only) should be a future deliverable so pytest tests/basisset_dev/ is safe to run locally without crashing the host.

What this handover does NOT cover

  • Tutorial-style worked examples — the docs chat should write these. Suggested topics:

    • “Your first ECP calculation” (Pt or Au atom UHF/LANL2DZ).

    • “Choosing an ECP library for your element” (table from Gap 2).

    • “When to use ECPs vs. all-electron” (heavy-element cost argument; Karlsruhe def2 family is the default workflow).

    • “Why my SCF didn’t converge” troubleshooting (ECP heavy-atom UHF often needs max_iter 200; small-gap systems benefit from level_shift or damping).

  • API-reference renderingcompute_ecp_matrix, compute_ecp_one_electron, ECPCenter, ECPHcore, *Options.ecp_centers, *Options.ecp_library. Ensure Sphinx / mkdocs picks them up. Docstrings exist on the Python side; the C++ headers carry the canonical descriptions.

  • CHANGELOG entry for the next minor release. Suggested bullet:

    libecpint integration completed — basis-set loader handles BSE-format .g94 files with ECP blocks, SCF drivers compute valence-only electron counts, and library_versions() / banner credit libecpint. Karlsruhe def2-ECP, Stuttgart-Köln MDF, and Hay-Wadt LANL2DZ ECP libraries bundled out of the box. Auto-population of ecp_centers from inline-ECP basis files lands in v0.8 (Phase 14e).

Cross-references

  • REQUIREMENTS-PERIODIC.md — the periodic chat needs ECPs for 5th-period pob (R6 / fifth-period blocker).

  • ROADMAP_BASIS_LIBRARY.md — every 📂 (data-only) entry in the dashboard for the LANL family, vDZP, and the dhf-* family is partially unblocked by this work.

  • REVIEW_BASIS_SETS_2026-05-08.md — the literature review that motivated the libecpint priority.

  • Memory files: reference_vq_queue.md (where heavy ECP testing should run), feedback_aux_basis_routing.md (AutoAux ↔ DF chat boundary, separate from libecpint).

Open hand-off question for the documentation chat

The ECPCenter API requires the user to know which ecp_library name maps to which element. Most user-facing chat-codebases encapsulate this in a helper function (ORCA’s keyword: ECP{def2} expands automatically). Vibe-qc’s API is intentionally explicit right now — should the docs chat:

  1. Document the explicit mapping (current state) and let the user opt in to a helper, OR

  2. Wait for Phase 14e and document the auto-population then?

Recommendation from basissetdev: option (1). The explicit mapping is short (the table above), it’s already correct, and it gives users a clear picture of what’s happening under the hood. Phase 14e will be additive.