Source code for vibeqc.periodic_rhf_gdf

"""Gamma-only periodic RHF/RKS with native Gaussian density fitting.

This is the first self-hosted GDF SCF driver in vibe-qc's periodic
stack. It uses the native C++ periodic 2c/3c auxiliary-integral kernels
through :func:`vibeqc.aux_basis.build_lpq_native`, then contracts the
resulting ``Lpq`` tensor in Python to build J and K.

Scope
-----

* \u0393-only RHF and closed-shell RKS.
* Native vibe-qc integrals only.
* Multi-k GDF is available via :func:`vibeqc.run_krhf_periodic_gdf`.

Algorithm selection
-------------------

* ``gdf_algorithm='bare'`` (default): direct image-summed 2c/3c ERIs
  with modrho rescaling. Works for 1D/2D/3D molecular-limit cells.
  For 3D cells where the GDF cutoff includes image cells, the driver
  falls back to EWALD_3D J + real-space K.
* ``gdf_algorithm='rsgdf'``: range-separated GDF (Ye & Berkelbach 2021,
  DOI 10.1063/5.0046617). Split 1/r = erfc(\u03c9r)/r + erf(\u03c9r)/r; SR
  in real space, LR in reciprocal space. Works for 1D/2D systems;
  on 3D systems the sparse G-mesh (~4 bohr\u207b\u00b9) cannot resolve
  compact aux primitives, producing \u223c231 mHa over-binding. A
  runtime warning is emitted for 3D RSGDF.

For production periodic SCF on 3D ionic crystals (LiH, MgO, etc.),
prefer :func:`vibeqc.run_krhf_periodic_gdf` with ``use_compcell=True``
(multi-k compcell GDF, validated at \u00b5Ha parity) or
:func:`vibeqc.run_pbc_gdf_rhf` (\u0393-only compcell with AFT correction
on by default).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import List, Optional, Tuple, Union

import numpy as np

from ._vibeqc_core import (
    apply_level_shift,
    BasisSet,
    bloch_sum,
    build_grid,
    build_jk_gamma_molecular_limit,
    build_xc_periodic,
    compute_kinetic_lattice,
    compute_overlap_lattice,
    CoulombMethod,
    direct_lattice_cells,
    Functional,
    GridOptions,
    InitialGuess,
    LatticeMatrixSet,
    LatticeSumOptions,
    level_shift_at_iter,
    LevelShiftDensity,
    nuclear_repulsion_per_cell,
    PeriodicKSOptions,
    PeriodicRHFOptions,
    PeriodicSystem,
    SCFIteration,
)
from .aux_basis import build_lpq_native, default_aux_for, make_aux_basis_set
from .ewald_composed import make_ewald_3d_gamma_j_builder
from .ewald_j import auto_grid
from .guess import initial_density_closed_shell
from .linear_dependence import scf_preflight_overlap_check
from .occupations import (
    aufbau_occupations_per_k as _aufbau_occupations_per_k,
)
from .occupations import (
    fermi_dirac_occupations_per_k as _fermi_dirac_occupations_per_k,
)
from .occupations import (
    hartree_to_kelvin_temperature as _hartree_to_kelvin_temperature,
)
from .options_dump import dump_active_settings
from .periodic_grid import build_periodic_becke_grid
from .periodic_rhf_ewald import (
    _canonical_orthogonalizer,
)
from .periodic_scf_accelerators import (
    DynamicDamping,
    PeriodicSCFAccelerator,
)
from .periodic_screened_exchange import reject_unscreened_range_separated
from .progress import ProgressLogger, resolve_progress
from .symmetry_integrals_reduced import (
    compute_kinetic_lattice_reduced,
    compute_overlap_lattice_reduced,
)

__all__ = [
    "PeriodicRHFGDFResult",
    "run_rhf_periodic_gamma_gdf",
    "run_rks_periodic_gamma_gdf",
]


[docs] @dataclass class PeriodicRHFGDFResult: """Result of :func:`run_rhf_periodic_gamma_gdf`. The ``backend`` field indicates the actual J/K mechanism: - ``"native-gamma-gdf"`` -- true density fitting via Lpq (1D/2D systems; 3D molecular-limit cells whose GDF cutoff includes only the home cell). - ``"ewald-jk-fallback"`` -- Ewald-3D J + real-space K (3D systems with image cells in the GDF cutoff; see :mod:`vibeqc.pbc_gdf` for the compensated-cell GDF path).""" energy: float e_electronic: float e_nuclear: float n_iter: int converged: bool mo_energies: np.ndarray mo_coeffs: np.ndarray density: np.ndarray fock: np.ndarray overlap: np.ndarray scf_trace: List[SCFIteration] = field(default_factory=list) hcore: np.ndarray = field(default_factory=lambda: np.empty((0, 0))) aux_basis_name: str = "" n_aux: int = 0 n_fit: int = 0 linear_dep_threshold: float = 1e-9 gdf_algorithm: str = "bare" e_xc: float = 0.0 e_coulomb: float = 0.0 e_hf_exchange: float = 0.0 functional: str = "" fock_mixing: float = 0.0 level_shift: float = 0.0 level_shift_warmup_cycles: int = 0 smearing_temperature: float = 0.0 fermi_level: float = 0.0 entropy: float = 0.0 free_energy: float = 0.0 occupations: np.ndarray = field(default_factory=lambda: np.empty(0)) backend: str = "native-gamma-gdf"
# --- Ewald-J build parameters (Step 2 of the 2026-05-13 gauge fix) --- # Defaults match `periodic_rks_ewald.py` (`omega = 0.5 / bohr`, FFT # Poisson spacing `0.3 bohr`); the composed Ewald-3D J is w-invariant # up to numerical precision (validated in `validate_ewald_identity`), so # tuning matters only for cost. Exposed here as constants so the choice # is discoverable; can be promoted to function-level kwargs in a follow-up # if user-tuning becomes needed. _J_EWALD_OMEGA: float = 0.0 _J_EWALD_SPACING_BOHR: float = 0.3 def _check_energy_sanity( result: PeriodicRHFGDFResult, system: PeriodicSystem, plog: ProgressLogger, ) -> None: """Post-convergence sanity check: warn if the total energy is physically unreasonable for the given system. The bound is a loose hydrogenic upper limit (Z_sum^2 per cell). Values exceeding this are almost certainly from a gauge mismatch, missing Madelung correction, or divergent lattice sum -- not a physical SCF minimum. This is an informational warning only; the caller decides whether to treat the result as usable.""" z_sum_sq = sum(atom.Z**2 for atom in system.unit_cell) sane_bound = max(10.0 * z_sum_sq, 100.0) if abs(result.energy) > sane_bound: plog.warn( f"Total energy {result.energy:.6f} Ha is outside the " f"physically-reasonable range [{sane_bound:.0f} Ha] for " f"this cell (sum Z^2 = {z_sum_sq:.0f}). This usually " f"indicates a gauge mismatch, a missing Madelung/exxdiv " f"correction, or a divergent lattice sum. The result " f"may be meaningless -- consider a different JK backend " f"(e.g. run_pbc_gdf_rhf for 3D cells)." ) def _gauge_lat_opts_for_v_ne_and_e_nuc( src: LatticeSumOptions, system: PeriodicSystem, ) -> LatticeSumOptions: """Return a clone of ``src`` with the Coulomb gauge forced to Ewald-3D for the V_ne lattice sum and the nuclear-nuclear lattice sum, when the system is fully 3D-periodic. Rationale (gauge-regression fix, 2026-05-13): The native gamma GDF driver's three Coulomb-bearing pieces (V_ne, e_nuc, J / K via Lpq) must share a single gauge for the total energy to be physically meaningful. PySCF (``exxdiv='ewald'``), CRYSTAL14, and vibe-qc's own ``EWALD_3D`` direct path all use the Madelung/Ewald gauge -- that's the convention the now-sealed v0.8.0 STO-3G parity baseline (``examples/regression/crystal_parity/baseline_sto3g/PARITY_TABLE.md``) is calibrated against. The user-supplied ``lat_opts.coulomb_method`` controls **J/K** routing (DIRECT_TRUNCATED vs EWALD_3D vs DF backends), not one-electron / nuclear gauge -- those need to be Ewald-3D unconditionally on 3D-periodic systems. For ``dim < 3`` there is **no gauge to return**, so this fails closed (2026-07-10). Every periodic Coulomb kernel is defined only up to the conditional ``G+q=0`` constant ``c``, and a neutral cell's total energy is physical only when all three Coulomb channels share one ``c``:: E_nn -> +½ c Z² Tr[D·V_ne] -> - c Z N_e ½Tr[D·J] -> +½ c N_e² ------------------------------------------------ sum = ½ c (N_e - Z)² = 0 (neutral cell) This function used to pass ``src`` through unchanged for ``dim < 3``, handing back **bare** lattice sums for ``V_ne``/``E_nn`` while ``J`` came from the ``G+q=0``-dropped neutral cderi. Bare 1-D/2-D Coulomb sums are only conditionally convergent, so their ``c`` grows without bound with the lattice-sum cutoff and the uncancelled ``-½cZ²`` diverges: polyethylene (dim=1, sto-3g, (4,1,1)) ran ``E_total = -1226.78 -> -1368.41 -> -1518.55`` Ha as ``nuclear_cutoff_bohr`` went ``45 -> 90 -> 180``. Matching the two gauges would not have rescued it. For ``dim < 3`` the neutral cderi is not a Coulomb kernel either: its G-mesh pins every non-periodic axis at ``G_perp = 0`` (:func:`~vibeqc.aux_basis.rsgdf_dense_g_mesh`), so ``J`` is a transverse-uniform sheet term ``∝ 1/V`` that vanishes with the vacuum padding. A cutoff-independent total built on it would still be meaningless. The gauge-consistent low-D Hamiltonians live outside this 3-D-torus dispatch: ``dim == 1`` -> the mixed-boundary wire kernel (:func:`~vibeqc.periodic.ccm.lowd_scf.run_ccm_rhf_wire`); ``dim == 2`` -> the four-center route (bare ``1/r`` minimum image places all channels in one gauge) pending the slab partial-FT build. See the retired periodic-SCF gauge-regression forensic note in git history, Sec. "Fix path -- three steps" / Step 1. """ dim = int(system.dim) if dim != 3: raise NotImplementedError( f"_gauge_lat_opts_for_v_ne_and_e_nuc: no consistent Coulomb gauge for a " f"dim = {dim} cell. The bare V_ne/E_nn lattice sums are conditionally " "convergent (the total diverges with nuclear_cutoff_bohr) and the neutral " "cderi they would pair with collapses to a transverse-uniform sheet term. " "Use run_ccm_rhf_wire for dim == 1; dim == 2 needs the slab mixed-boundary " "kernel (docs/aiccm2026dev_a_lowd_greens.md section 9) and is not " "implemented -- the four-center route is unaffected and remains available." ) dst = LatticeSumOptions() for attr in dir(src): if attr.startswith("_"): continue try: value = getattr(src, attr) except Exception: continue if callable(value): continue try: setattr(dst, attr, value) except Exception: pass dst.coulomb_method = CoulombMethod.EWALD_3D return dst def _lat_opts_with_nuclear_cutoff( src: LatticeSumOptions, nuclear_cutoff_bohr: float, ) -> LatticeSumOptions: """Return a clone of ``src`` with ``nuclear_cutoff_bohr`` overridden. Used by the Lpq-J/K (non-Ewald) path to clamp the V_ne / e_nuc nuclear lattice sum to the GDF integral cutoff, so all three Coulomb-bearing pieces (V_ne, e_nuc, J/K) sum over a single cell set (audit F3, 2026-05-31). See the call site for rationale. """ dst = LatticeSumOptions() for attr in dir(src): if attr.startswith("_"): continue try: value = getattr(src, attr) except Exception: continue if callable(value): continue try: setattr(dst, attr, value) except Exception: pass dst.nuclear_cutoff_bohr = float(nuclear_cutoff_bohr) return dst def _build_j_from_lpq(Lpq: np.ndarray, D: np.ndarray) -> np.ndarray: """Closed-shell Coulomb matrix from a fitted periodic ERI factor.""" rho = np.einsum("Lij,ij->L", Lpq, D, optimize=True) J = np.einsum("L,Lij->ij", rho, Lpq, optimize=True) return 0.5 * (J + J.T) def _build_k_from_lpq(Lpq: np.ndarray, D: np.ndarray) -> np.ndarray: """Closed-shell exchange matrix from a fitted periodic ERI factor. ``D`` is the total RHF density, including the factor of two for doubly occupied orbitals. The RHF Fock then uses ``J - 0.5 K``. """ K = np.einsum("Lmk,kl,Lnl->mn", Lpq, D, Lpq, optimize=True) return 0.5 * (K + K.T) def _density_set_gamma( template: LatticeMatrixSet, D: np.ndarray, ) -> LatticeMatrixSet: """Return a Γ-only density set with ``D`` in the home-cell block. ``build_xc_periodic`` treats a home-block-only set as a *molecular-limit* density (``ρ = χ_0 D χ_0``, no image-cell AO products -- see ``density_has_cross_cell_blocks`` in ``cpp/src/periodic_xc.cpp``). That pairs with the **molecular** Becke grid (``use_periodic_becke=False``, the v0.8.x convention); on the default periodic-Becke grid use :func:`_density_set_torus_gamma` instead -- the molecular-limit density on the one-cell periodic grid drops every image contribution to ρ(r) and mis-evaluates E_xc/V_xc by ~0.3 Ha on tight ionic cells (the 2026-07-09 KRKS finding, handovers/HANDOVER_AICCM_DIRECT_TORUS.md §4). """ if D.shape[0] != template.nbf: raise ValueError( f"_density_set_gamma: D has nbf={D.shape[0]} but template " f"has nbf={template.nbf}" ) zero = np.zeros_like(D) for i in range(len(template)): template.set_block(i, D if i == 0 else zero) return template def _density_set_torus_gamma( template: LatticeMatrixSet, D: np.ndarray, ) -> LatticeMatrixSet: """Return the Γ-torus density set: ``D`` in **every** lattice block. The inverse Bloch fold of a single Γ point is cell-independent -- ``D(R) = D_Γ`` for every direct-lattice cell ``R`` -- so the physical periodic density is the cross-cell sum ``ρ(r) = Σ_{a,s} χ_a(r) D χ_s(r) = |Σ_R χ(r-R)|-contracted``, which is what ``build_xc_periodic``'s cross-cell mode evaluates when the ``g != 0`` blocks are populated. This is the same convention the multi-k drivers feed via ``real_space_density_from_per_k_density`` (their ``N_k = 1`` limit is exactly this set) and the convention the external references (PySCF KRKS) use at Γ. """ if D.shape[0] != template.nbf: raise ValueError( f"_density_set_torus_gamma: D has nbf={D.shape[0]} but template " f"has nbf={template.nbf}" ) for i in range(len(template)): template.set_block(i, D) return template def _resolve_fock_mixing(options, override: Optional[float]) -> float: """Return previous-Fock weight for CRYSTAL-style FMIXING.""" value = override if value is None: value = getattr(options, "fock_mixing", None) if value is None: percent = getattr(options, "fmixing_percent", None) if percent is not None: value = float(percent) / 100.0 if value is None: value = 0.0 value = float(value) if not (0.0 <= value < 1.0): raise ValueError( f"run_rhf_periodic_gamma_gdf: fock_mixing must be in [0, 1); got {value}" ) return value def _resolve_level_shift_warmup_cycles( options, *, level_shift: float, max_iter: int, override: Optional[int] = None, ) -> int: """Return the number of shifted startup cycles. ``None`` or ``-1`` means "auto": if a nonzero level shift is active, use up to five shifted startup cycles while leaving at least one final unshifted tail cycle. ``0`` keeps the historic persistent level-shift behaviour. Positive integers request an explicit warm-up length and are capped to preserve the unshifted tail cycle. """ max_iter = int(max_iter) if abs(float(level_shift)) == 0.0 or max_iter <= 1: return 0 raw = override if raw is None: raw = getattr(options, "level_shift_warmup_cycles", None) if raw is None: raw = -1 raw = int(raw) if raw < -1: raise ValueError( "run_rhf_periodic_gamma_gdf: level_shift_warmup_cycles must " f"be >= 0 or -1 for auto; got {raw}" ) if raw < 0: raw = 5 if raw == 0: return 0 return min(raw, max_iter - 1) def _density_from_orbitals_and_occupations( C: np.ndarray, occupations: np.ndarray, ) -> np.ndarray: """One-particle density for closed-shell occupations in ``[0, 2]``.""" D = (C * np.asarray(occupations, dtype=float)[None, :]) @ C.T return 0.5 * (D + D.T)
[docs] def run_rhf_periodic_gamma_gdf( system: PeriodicSystem, basis: BasisSet, options: Optional[Union[PeriodicRHFOptions, PeriodicKSOptions]] = None, *, functional: Optional[str] = None, aux_basis: Optional[str] = None, aux_drop_eta: float = 0.0, linear_dep_threshold: float = 1e-7, gdf_linear_dep_threshold: float = 1e-9, apply_modrho: bool = True, gdf_algorithm: str = "bare", rsgdf_omega: float = 0.4, fock_mixing: Optional[float] = None, level_shift_warmup_cycles: Optional[int] = None, symmetry_stabilize: bool = False, symmetry_reduce_fock: bool = False, progress: Union[bool, ProgressLogger, None] = None, verbose: Optional[int] = None, ) -> PeriodicRHFGDFResult: """Run Γ-only closed-shell periodic RHF/RKS via native GDF. Parameters mirror the older target API, but the implementation is now entirely vibe-qc owned. If ``functional`` is provided, the driver adds native libxc ``V_xc`` on the periodic/molecular Becke grid and uses the functional's exact-exchange fraction for hybrids. """ opts = options if options is not None else PeriodicRHFOptions() lat_opts: LatticeSumOptions = opts.lattice_opts plog = resolve_progress(progress, verbose=verbose) func_name = functional or str(getattr(opts, "functional", "") or "") is_ks = bool(func_name) func = Functional(func_name, 1) if is_ks else None # This legacy Γ fallback builds the molecular-limit K full-range # only; screened hybrids must not silently run as full-range twins. reject_unscreened_range_separated( func, where="run_rhf_periodic_gamma_gdf" ) alpha = float(func.hf_exchange_fraction) if func is not None else 1.0 fock_mixing_value = _resolve_fock_mixing(opts, fock_mixing) level_shift = float(getattr(opts, "level_shift", 0.0)) max_iter = int(opts.max_iter) warmup_cycles = _resolve_level_shift_warmup_cycles( opts, level_shift=level_shift, max_iter=max_iter, override=level_shift_warmup_cycles, ) # Explicit per-iteration schedule (unified with the molecular # drivers). Empty ⇒ the warm-up step function below; non-empty ⇒ # resolved per iteration by the shared C++ helper. _ls_schedule = list(getattr(opts, "level_shift_schedule", None) or []) smearing_T = float(getattr(opts, "smearing_temperature", 0.0)) if smearing_T < 0.0: raise ValueError( "run_rhf_periodic_gamma_gdf: smearing_temperature must be >= 0" ) n_elec = system.n_electrons() if n_elec % 2 != 0: raise ValueError( "run_rhf_periodic_gamma_gdf: closed-shell RHF/RKS requires even " f"electron count; got {n_elec}" ) if system.multiplicity != 1: raise ValueError( "run_rhf_periodic_gamma_gdf: closed-shell RHF/RKS requires " f"multiplicity=1; got {system.multiplicity}" ) n_occ = n_elec // 2 aux_name = aux_basis or default_aux_for(basis.name) label = f"RKS {func_name}" if is_ks else "RHF" plog.info( f"{label} Gamma native GDF / " f"aux={aux_name}, algorithm={gdf_algorithm}, " f"cutoff={lat_opts.cutoff_bohr:.2f} bohr" ) plog.info(f"basis: {basis.name} ({basis.nbasis} BFs / {basis.nshells} shells)") dim = int(system.dim) active_lengths = [ float(np.linalg.norm(np.asarray(system.lattice, dtype=float)[:, i])) for i in range(dim) ] plog.info( "periodicity: " f"dim={dim}D, active lengths=" + ", ".join(f"{x:.3f}" for x in active_lengths) + " bohr" ) n_int_cells = len(direct_lattice_cells(system, lat_opts.cutoff_bohr)) n_nuc_cells = len(direct_lattice_cells(system, lat_opts.nuclear_cutoff_bohr)) plog.info( "lattice cells: " f"one-electron/GDF cutoff -> {n_int_cells}, " f"nuclear cutoff -> {n_nuc_cells}" ) dump_active_settings( plog, [ ("PeriodicKSOptions" if is_ks else "PeriodicRHFOptions", opts), ("LatticeSumOptions", lat_opts), ( "GDF kwargs", { "functional": func_name or None, "hf_exchange_fraction": alpha, "fock_mixing": fock_mixing_value, "fmixing_percent": 100.0 * fock_mixing_value, "level_shift": level_shift, "level_shift_warmup_cycles": warmup_cycles, "smearing_temperature": smearing_T, "aux_basis": aux_name, "aux_drop_eta": float(aux_drop_eta), "linear_dep_threshold": float(linear_dep_threshold), "gdf_linear_dep_threshold": float(gdf_linear_dep_threshold), "apply_modrho": bool(apply_modrho), "gdf_algorithm": gdf_algorithm, "rsgdf_omega": float(rsgdf_omega), }, ), ], ) # ---- Functional + grid -------------------------------------------- grid = None _set_xc_density = _density_set_gamma if is_ks: grid_options = getattr(opts, "grid", None) if grid_options is None: grid_options = GridOptions() if bool(getattr(opts, "use_periodic_becke", False)): grid = build_periodic_becke_grid( system, grid_options=grid_options, image_radius_bohr=float(getattr(opts, "becke_image_radius_bohr", 0.0)), ) # Periodic-Becke grid (one-cell partition of the crystal) # pairs with the *periodic* Γ-torus density (D in every # lattice block -> build_xc_periodic's cross-cell mode). # Feeding the home-cell-only set here evaluates the # molecular density on the periodic grid: exact in a vacuum # box, ~0.3 Ha wrong on tight ionic cells (the 2026-07-09 # KRKS finding, HANDOVER_AICCM_DIRECT_TORUS.md §4). _set_xc_density = _density_set_torus_gamma else: # Molecular Becke grid (v0.8.x reproduction path) pairs with # the molecular-limit home-cell-only density. grid = build_grid(system.unit_cell_molecule(), grid_options) molecular_limit_gdf = int(system.dim) == 3 and n_int_cells == 1 use_ewald_jk = int(system.dim) == 3 and not molecular_limit_gdf # ---- One-electron integrals at Γ ---------------------------------- # # Force V_ne (and e_nuc below) through the Ewald-3D gauge regardless # of the user-supplied coulomb_method. See # ``_gauge_lat_opts_for_v_ne_and_e_nuc`` for rationale + the # 2026-05-13 gauge-regression handover doc. ``coulomb_method`` on # the user-facing ``lat_opts`` still controls J/K routing. if use_ewald_jk: gauge_lat_opts = _gauge_lat_opts_for_v_ne_and_e_nuc(lat_opts, system) if gauge_lat_opts is not lat_opts: plog.info( "V_ne / e_nuc gauge: Ewald-3D " "(forced for 3D-periodic systems regardless of " f"lat_opts.coulomb_method={lat_opts.coulomb_method!r})" ) else: # Lpq J/K path: 3D molecular-limit cell (GDF cutoff contains the # home cell only) or any dim<3 vacuum-padded wire/slab. J and K # come from the native Lpq tensor, which is summed over the GDF # integral cutoff `cutoff_bohr`. V_ne and e_nuc MUST sum nuclei # over the SAME cell set, or the long-range electron-nuclear # attraction is summed to image nuclei (out to the default # nuclear_cutoff_bohr=25) with no compensating electron-electron # repulsion (Lpq sees only cutoff_bohr=15) -- the neutral cell's # cancellation breaks and the energy over-binds with no warning # (CLAUDE.md Sec.7; audit F3 2026-05-31). H2 in an 18-bohr box came # back -1.783 Ha at the default cutoffs vs the isolated-H2 # -1.117 Ha once the nuclear cutoff is clamped to cutoff_bohr. if lat_opts.nuclear_cutoff_bohr > lat_opts.cutoff_bohr: gauge_lat_opts = _lat_opts_with_nuclear_cutoff( lat_opts, lat_opts.cutoff_bohr ) plog.info( "V_ne / e_nuc nuclear cutoff clamped to the GDF integral " f"cutoff {lat_opts.cutoff_bohr:.2f} bohr (was " f"{lat_opts.nuclear_cutoff_bohr:.2f} bohr) to match the " "Lpq J/K cell set and preserve neutral-cell cancellation" ) else: gauge_lat_opts = lat_opts with plog.stage( "integrals_lattice", detail=f"S/T/V at cutoff {lat_opts.cutoff_bohr:.2f} bohr", ): _use_sym = system.symmetry is not None and system.symmetry.operations if _use_sym: ops = system.symmetry.operations plog.info( f"S/T integrals: symmetry-reduced path " f"(SG {system.symmetry.international_symbol}, " f"{system.symmetry.order} ops)" ) _, S_blocks = compute_overlap_lattice_reduced( basis, system, lat_opts, ops, ) S_lat = compute_overlap_lattice(basis, system, lat_opts) for i in range(len(S_lat)): S_lat.set_block(i, S_blocks[i]) _, T_blocks = compute_kinetic_lattice_reduced( basis, system, lat_opts, ops, ) T_lat = compute_kinetic_lattice(basis, system, lat_opts) for i in range(len(T_lat)): T_lat.set_block(i, T_blocks[i]) else: S_lat = compute_overlap_lattice(basis, system, lat_opts) T_lat = compute_kinetic_lattice(basis, system, lat_opts) from .periodic_v_ne import compute_nuclear_lattice_dispatch V_lat = compute_nuclear_lattice_dispatch(basis, system, gauge_lat_opts) k_gamma = np.zeros(3) S = np.real(bloch_sum(S_lat, k_gamma)) T = np.real(bloch_sum(T_lat, k_gamma)) V = np.real(bloch_sum(V_lat, k_gamma)) Hcore = 0.5 * ((T + V) + (T + V).T) S = 0.5 * (S + S.T) scf_preflight_overlap_check(S, plog=plog, label="S(Γ)", basis=basis) X, n_kept = _canonical_orthogonalizer(S, linear_dep_threshold) if n_occ > n_kept: raise RuntimeError( "run_rhf_periodic_gamma_gdf: canonical orthogonalisation " f"dropped too many directions (n_occ={n_occ}, n_kept={n_kept})" ) use_davidson = getattr(opts, "use_davidson", False) dav_opts = getattr(opts, "davidson", None) dav_dim = getattr(opts, "davidson_min_dim", 100) use_dav = use_davidson and S.shape[0] >= dav_dim if use_dav and dav_opts is None: from vibeqc._vibeqc_core import DavidsonOptions dav_opts = DavidsonOptions() # ---- Native GDF factor -------------------------------------------- mol = system.unit_cell_molecule() with plog.stage("aux_basis", detail=aux_name): aux = make_aux_basis_set(mol, aux_name=aux_name, drop_eta=float(aux_drop_eta)) plog.info(f"aux basis: {aux_name} ({aux.nbasis} BFs / {aux.nshells} shells)") with plog.stage( "native_lpq", detail=( f"2c/3c lattice ERIs, aux={aux.nbasis}, " f"fit_thr={gdf_linear_dep_threshold:.1e}" ), ): if gdf_algorithm == "rsgdf" and int(system.dim) == 3: plog.warn( "RSGDF on 3D systems is an experimental research feature " "(~230 mHa error on H2/12-bohr; architectural limitation -- " "the sparse G-mesh sized by the LR damping kernel cannot " "resolve compact aux primitives). Prefer " "gdf_algorithm='bare' (1D/2D) or the compcell path via " "run_pbc_gdf_rhf (3D). See aux_basis.py RSGDF LR status " "note for details." ) Lpq = build_lpq_native( system, basis, aux, lat_opts=lat_opts, linear_dep_thr=float(gdf_linear_dep_threshold), apply_modrho=bool(apply_modrho), algorithm=gdf_algorithm, rsgdf_omega=float(rsgdf_omega), ) plog.info( f"Lpq: {Lpq.shape[0]} fit vectors, " f"shape=({Lpq.shape[0]}, {Lpq.shape[1]}, {Lpq.shape[2]})" ) e_nuc = nuclear_repulsion_per_cell(system, gauge_lat_opts) # --- Steps 2 + 3 of the 2026-05-13 gauge fix: Ewald-3D J + K ------- # # Step 2: for dim == 3 the Hartree J is built via the composed # Ewald-3D builder (short-range erfc/r + long-range FFT-Poisson of # erf/r), bringing J into the same Madelung/Ewald gauge as V_ne / # e_nuc (Step 1) and CRYSTAL14 / PySCF `exxdiv='ewald'`. # # Step 3 (revised): the K build *also* moves off the native modrho- # Lpq factor for dim == 3. The 2026-05-14 forensic K-vs-K comparison # on LiH/STO-3G showed `_build_k_from_lpq` gives ||K|| ~5x too large # on tight ionic cells -- `build_lpq_native`'s native Lpq tensor is # wrong for tight cells where AO images overlap. So for v0.8.0 K is # built via the same full-range real-space path the working EWALD_3D # driver uses (`build_jk_gamma_molecular_limit` at omega=0). # # The 2026-05-14 handover originally framed this molecular-limit # kernel as a builder bug ("misses g_l != g_s contributions"); the # 2026-05-15 cutoff-convergence sweep (retired forensic note in git # history) showed the # alternative "homogeneous-D triple-cell-sum" build diverges with # lattice cutoff and the molecular-limit kernel is in fact the # correct Γ-only periodic Fock build (the inverse-Bloch convention # P(g) = P_Γ . d_{g,0}). The -0.9 Ha gap to CRYSTAL14 SHRINK 8 8 # on LiH primitive is BZ-sampling residual, not a builder bug -- # multi-k sampling is the fix for tight ionic crystals. # # Consequence: in v0.8.0 this "GDF" driver does NOT use density # fitting for J or K on tight dim == 3 cells -- it is Ewald-J + # real-space-K, identical in operation to # `run_rhf_periodic_gamma_ewald3d`. In the molecular-limit regime # where the GDF cutoff contains only the home cell, the native Lpq # tensor remains the intended backend and exercises arbitrary 3D # lattice metrics without the tight-cell image-overlap pathology. # # Pre-compute the FFT grid once; it depends only on lattice geometry. j_ewald_grid_shape: Optional[Tuple[int, int, int]] = None # Iteration-invariant analytic-FT Hartree-J builder for the Ewald-JK # fallback path; built once below (None on the Lpq path). j_ewald_builder = None if use_ewald_jk: lat_arr = np.asarray(system.lattice, dtype=float) j_ewald_grid_shape = tuple( int(x) for x in auto_grid(lat_arr, _J_EWALD_SPACING_BOHR) ) # Resolve the Ewald split w once (fixed geometry -> fixed w). The # analytic-FT J is w-invariant, so this matters only for the # diagnostic grid backend; resolving it here keeps that path # byte-identical to the former per-call resolution. _j_ewald_omega = _J_EWALD_OMEGA if _j_ewald_omega <= 0.0: from .bipole_ext_el_pole import crystal_default_ewald_alpha _V_cell = float(abs(np.linalg.det(np.asarray(system.lattice, dtype=float)))) _j_ewald_omega = crystal_default_ewald_alpha(_V_cell) # Build the density-INDEPENDENT analytic-FT machinery (the Bloch # AO-pair FT pair_ft, the dense G-mesh, and the 4pi/G^2 kernel -- an # EwaldJFTGammaCache) ONCE per SCF via make_ewald_3d_gamma_j_builder, # then reuse it every iteration; the closure redoes only the O(n^2.n_G) # density contraction. Previously _build_j called build_j_ewald_3d # each iteration, which rebuilt this cache every cycle (E2, # docs/pbc_audit_2026-06.md -- the GDF Lpq cache is the model this # mirrors). Bit-identical to the per-iteration build_j_ewald_3d call # (gated by test_ewald_j_cache.test_gamma_builder_matches_build_j_ewald_3d); # the diagnostic VIBEQC_J_EWALD3D_BACKEND=grid path stays uncached. j_ewald_builder = make_ewald_3d_gamma_j_builder( basis, system, omega=_j_ewald_omega, lattice_opts=lat_opts, grid_shape=j_ewald_grid_shape, origin=None, spacing_bohr=_J_EWALD_SPACING_BOHR, ) plog.info( "J gauge: Ewald-3D " f"(omega={'auto' if _J_EWALD_OMEGA <= 0.0 else f'{_J_EWALD_OMEGA:.3f}'} / bohr, " f"FFT grid {j_ewald_grid_shape[0]}x" f"{j_ewald_grid_shape[1]}x{j_ewald_grid_shape[2]})" ) plog.info( "K gauge: full-range real-space " "(molecular-limit kernel = correct Γ-only build; " "tight-ionic accuracy needs multi-k)" ) elif molecular_limit_gdf: plog.info( "J/K backend: native Gamma GDF " "(3D molecular-limit cell; GDF cutoff contains home cell only)" ) def _build_j(D_in: np.ndarray) -> np.ndarray: """Coulomb J for the current density. For dim==3 use the Ewald-3D composed builder (gauge-correct), reusing the iteration-invariant analytic-FT machinery cached once in ``j_ewald_builder`` above; fall back to the Lpq-based J for dim<3 (vacuum-padded; bare gauge is fine).""" if use_ewald_jk: J_out = j_ewald_builder(D_in) else: J_out = _build_j_from_lpq(Lpq, D_in) return 0.5 * (J_out + J_out.T) def _build_k(D_in: np.ndarray) -> np.ndarray: """Exchange K for the current density. For dim==3 use the full-range real-space molecular-limit builder (the correct Γ-only build per the 2026-05-15 cutoff-convergence finding); fall back to the Lpq-based K for dim<3 (vacuum-padded -- the native Lpq tensor is fine when AO images don't overlap).""" if use_ewald_jk: jk = build_jk_gamma_molecular_limit( basis, system, lat_opts, D_in, 0.0, ) K_out = np.asarray(jk.K) else: K_out = _build_k_from_lpq(Lpq, D_in) return 0.5 * (K_out + K_out.T) def diagonalise(F: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: Fp = X.T @ F @ X Fp = 0.5 * (Fp + Fp.T) if use_dav and dav_opts is not None: from vibeqc._vibeqc_core import davidson_solve if dav_opts.n_eig == 0: dav_opts.n_eig = Fp.shape[0] if dav_opts.guess_vectors is not None: pass # already set from previous iteration dres = davidson_solve(Fp, dav_opts) if not dres.converged: raise RuntimeError( f"Davidson did not converge after {dres.n_iter} iters" ) eps, Cp = dres.eigenvalues, dres.eigenvectors dav_opts.guess_vectors = Cp else: eps, Cp = np.linalg.eigh(Fp) return X @ Cp, eps def occupations_from_eps( eps: np.ndarray, ) -> Tuple[np.ndarray, float, float]: if smearing_T <= 0.0: occ = _aufbau_occupations_per_k([eps], n_occ)[0] return np.asarray(occ, dtype=float), 0.0, 0.0 occ_list, mu, entropy = _fermi_dirac_occupations_per_k( [eps], [1.0], float(n_elec), smearing_T, ) return np.asarray(occ_list[0], dtype=float), float(mu), float(entropy) guess = getattr(opts, "initial_guess", InitialGuess.HCORE) D_engine = initial_density_closed_shell( mol, basis, n_occ, guess, is_periodic=True, periodic_system=system, lattice_opts=lat_opts, # READ restart (Γ-only): prior g=0 cell density (pre-resolved from # read_from, or read + projected from read_path). Ignored unless READ. read_density=getattr(opts, "read_density", None), read_path=getattr(opts, "read_path", ""), ) if D_engine is not None: plog.info(f"initial guess: {guess.name} (density via GuessEngine)") D = D_engine C0, eps0 = diagonalise(Hcore) else: plog.info(f"initial guess: {guess.name} (Hcore-diagonalise)") C0, eps0 = diagonalise(Hcore) occ0, _, _ = occupations_from_eps(eps0) D = _density_from_orbitals_and_occupations(C0, occ0) D_prev = D.copy() occ, fermi_level, entropy = occupations_from_eps(eps0) if smearing_T > 0.0: plog.info( "smearing: Fermi-Dirac kBT = " f"{smearing_T:.6g} Ha " f"({_hartree_to_kelvin_temperature(smearing_T):.1f} K)" ) damping = float(opts.damping) if not (0.0 <= damping < 1.0): raise ValueError( f"run_rhf_periodic_gamma_gdf: damping must be in [0, 1); got {damping}" ) if fock_mixing_value != 0.0: plog.info( "fock mixing: CRYSTAL FMIXING " f"{100.0 * fock_mixing_value:.1f}% " "(previous Fock/KS matrix weight)" ) damper: Optional[DynamicDamping] = None if bool(getattr(opts, "dynamic_damping", False)): damper = DynamicDamping( initial_alpha=damping, alpha_min=float(getattr(opts, "dynamic_damping_min", 0.0)), alpha_max=float(getattr(opts, "dynamic_damping_max", 0.95)), ) use_diis = bool(opts.use_diis) diis_start_iter = int(opts.diis_start_iter) accel: Optional[PeriodicSCFAccelerator] = ( PeriodicSCFAccelerator(opts) if use_diis else None ) if level_shift != 0.0: if warmup_cycles > 0: cycle_word = "cycle" if warmup_cycles == 1 else "cycles" plog.info( "level-shift warm-up: " f"{warmup_cycles} {cycle_word} at {level_shift:.3f} Ha; " "then restart unshifted" ) else: plog.info( f"level shift: {level_shift:.3f} Ha applied at each diagonalization" ) D_set = compute_overlap_lattice(basis, system, lat_opts) if is_ks else None plog.banner(f"SCF ({label} Gamma, native GDF)") plog.info(" iter energy (Ha) dE ||[F,DS]|| DIIS") scf_trace: List[SCFIteration] = [] result = PeriodicRHFGDFResult( energy=0.0, e_electronic=0.0, e_nuclear=float(e_nuc), e_xc=0.0, e_coulomb=0.0, e_hf_exchange=0.0, n_iter=0, converged=False, mo_energies=np.empty(0), mo_coeffs=np.empty((0, 0)), density=D.copy(), fock=np.empty((0, 0)), overlap=S, scf_trace=scf_trace, hcore=Hcore, aux_basis_name=aux_name, n_aux=int(aux.nbasis), n_fit=int(Lpq.shape[0]), linear_dep_threshold=float(gdf_linear_dep_threshold), gdf_algorithm=gdf_algorithm, functional=func_name, fock_mixing=fock_mixing_value, level_shift=level_shift, level_shift_warmup_cycles=warmup_cycles, smearing_temperature=smearing_T, fermi_level=float(fermi_level), entropy=float(entropy), free_energy=0.0, occupations=np.asarray(occ, dtype=float), backend=("ewald-jk-fallback" if use_ewald_jk else "native-gamma-gdf"), ) E_prev = 0.0 C_final = C0 eps_final = eps0 F_prev_mixed: Optional[np.ndarray] = None P_cache: list = [] if symmetry_stabilize and system.symmetry is not None: from .symmetry_integrals import symmorphic_operations as _so from .symmetry_scf import build_ao_permutation_cache as _bpc ops = _so(system.symmetry.operations) if ops: P_cache = _bpc(system, basis, ops) plog.info(f"symmetry_stabilize: {len(P_cache)} symmorphic ops") for iter_idx in range(1, max_iter + 1): if damper is not None: damping = damper.alpha if warmup_cycles > 0 and iter_idx == warmup_cycles + 1: if accel is not None: accel = PeriodicSCFAccelerator(opts) F_prev_mixed = None plog.info("restart: unshifted Fock with fresh DIIS history") if _ls_schedule: active_level_shift = level_shift_at_iter( level_shift, warmup_cycles, _ls_schedule, max_iter, iter_idx ) else: active_level_shift = ( level_shift if ( level_shift != 0.0 and (warmup_cycles == 0 or iter_idx <= warmup_cycles) ) else 0.0 ) diis_active = use_diis and iter_idx >= diis_start_iter D_used = ( D if (iter_idx == 1 or damping == 0.0 or diis_active) else damping * D_prev + (1.0 - damping) * D ) if symmetry_reduce_fock and system.symmetry is not None: from .symmetry_fock_reduced import compute_jk_gamma_reduced as _jk_red J, K = _jk_red( basis, system, lat_opts_2e, D_used, system.symmetry.operations, ) else: J = _build_j(D_used) K = _build_k(D_used) if alpha != 0.0 else None if symmetry_stabilize and P_cache: from .symmetry_scf import symmetrize_matrix as _sym J = _sym(J, P_cache) if K is not None: K = _sym(K, P_cache) F_HF_part = J - 0.5 * alpha * K if K is not None else J E_xc = 0.0 V_xc = 0.0 if is_ks: _set_xc_density(D_set, D_used) xc_contrib = build_xc_periodic( basis, system, grid, func, D_set, lat_opts, ) V_xc = np.real(bloch_sum(xc_contrib.V_xc, k_gamma)) V_xc = 0.5 * (V_xc + V_xc.T) E_xc = float(xc_contrib.e_xc) F = Hcore + F_HF_part + V_xc F = 0.5 * (F + F.T) E_core = float(np.einsum("ij,ij->", D_used, Hcore)) E_hf_trace = 0.5 * float(np.einsum("ij,ij->", D_used, F_HF_part)) E_elec = E_xc + E_core + E_hf_trace E_total = E_elec + float(e_nuc) E_coulomb = 0.5 * float(np.einsum("ij,ij->", D_used, J)) E_hf_K = ( -0.25 * alpha * float(np.einsum("ij,ij->", D_used, K)) if K is not None else 0.0 ) free_energy = E_total - smearing_T * entropy FDS = F @ D_used @ S grad = FDS - FDS.T grad_norm = float(np.linalg.norm(grad)) dE = free_energy - E_prev converged = ( iter_idx > 1 and (warmup_cycles == 0 or iter_idx > warmup_cycles) and abs(dE) < float(opts.conv_tol_energy) and grad_norm < float(opts.conv_tol_grad) ) scf_trace.append( SCFIteration( iter=iter_idx, energy=float(free_energy), delta_e=float(dE if iter_idx > 1 else 0.0), grad_norm=float(grad_norm), diis_subspace=(accel.subspace_size if accel is not None else 0), ) ) plog.iteration( iter_idx, energy=float(free_energy), dE=float(dE if iter_idx > 1 else 0.0), grad=float(grad_norm), diis=(accel.subspace_size if accel is not None else 0), ) plog.energy_decomposition( iter_idx, E_kin=float(np.einsum("ij,ij->", D_used, T)), E_ne=float(np.einsum("ij,ij->", D_used, V)), E_J=E_coulomb, E_xc=E_xc if is_ks else None, E_K=E_hf_K, E_nuc=float(e_nuc), ) if accel is not None: F_ex = accel.extrapolate_rhf( F, error=grad, density=D_used, energy=E_total, mo_coeffs=C_final, mo_energies=eps_final, n_occ=n_occ, ) if diis_active: F = F_ex if fock_mixing_value != 0.0: if F_prev_mixed is not None: F_mixed = ( 1.0 - fock_mixing_value ) * F + fock_mixing_value * F_prev_mixed F = 0.5 * (F_mixed + F_mixed.T) F_prev_mixed = F.copy() # Shared Saunders-Hillier operator: the weight on S·D·S is fixed by # the density convention, not by taste. ``D_used`` is the closed-shell # total density (occupations in {0, 2}), hence TOTAL. Returns F # untouched when the shift is 0. # Guarded: skip the pybind conversion of S and D entirely on an # unshifted cycle, which is the default and the common case. F_diag = ( F if active_level_shift == 0.0 else apply_level_shift( F, S, D_used, active_level_shift, LevelShiftDensity.TOTAL) ) C_new, eps_new = diagonalise(F_diag) occ, fermi_level, entropy = occupations_from_eps(eps_new) D_prev = D_used D = _density_from_orbitals_and_occupations(C_new, occ) C_final = C_new eps_final = eps_new if damper is not None: damper.update(free_energy) E_prev = free_energy result.energy = E_total result.e_electronic = E_elec result.e_xc = E_xc result.e_coulomb = E_coulomb result.e_hf_exchange = E_hf_K result.n_iter = iter_idx result.mo_energies = eps_new result.mo_coeffs = C_new result.density = D_used result.fock = F result.fermi_level = float(fermi_level) result.entropy = float(entropy) result.free_energy = float(free_energy) result.occupations = np.asarray(occ, dtype=float) if converged: J_f = _build_j(D) K_f = _build_k(D) if alpha != 0.0 else None F_HF_f = J_f - 0.5 * alpha * K_f if K_f is not None else J_f E_xc_f = 0.0 V_xc_f = 0.0 if is_ks: _set_xc_density(D_set, D) xc_f = build_xc_periodic( basis, system, grid, func, D_set, lat_opts, ) V_xc_f = np.real(bloch_sum(xc_f.V_xc, k_gamma)) V_xc_f = 0.5 * (V_xc_f + V_xc_f.T) E_xc_f = float(xc_f.e_xc) F_f = 0.5 * ((Hcore + F_HF_f + V_xc_f) + (Hcore + F_HF_f + V_xc_f).T) C_f, eps_f = diagonalise(F_f) occ_f, fermi_level_f, entropy_f = occupations_from_eps(eps_f) E_core_f = float(np.einsum("ij,ij->", D, Hcore)) E_hf_trace_f = 0.5 * float(np.einsum("ij,ij->", D, F_HF_f)) E_elec_f = E_xc_f + E_core_f + E_hf_trace_f result.energy = E_elec_f + float(e_nuc) result.e_electronic = E_elec_f result.e_xc = E_xc_f result.e_coulomb = 0.5 * float(np.einsum("ij,ij->", D, J_f)) result.e_hf_exchange = ( -0.25 * alpha * float(np.einsum("ij,ij->", D, K_f)) if K_f is not None else 0.0 ) result.mo_energies = eps_f result.mo_coeffs = C_f result.density = D result.fock = F_f result.fermi_level = float(fermi_level_f) result.entropy = float(entropy_f) result.free_energy = float(result.energy - smearing_T * entropy_f) result.occupations = np.asarray(occ_f, dtype=float) result.converged = True # Diagnostic: HOMO-LUMO gap check at convergence. Mirrors # CRYSTAL14's `POSSIBLY CONDUCTING STATE - EFERMI(AU)` # warning that fires when the gap between the highest # occupied and lowest unoccupied MO is below threshold. # For a closed-shell Γ-only calc on a true insulator the # gap should be tens of mHa or larger; below ~1 mHa the # system is effectively metallic at this k-point and # SCF convergence may be fragile / sensitive to start # guess. This is purely informational -- we don't fail # the SCF. The 1e-3 Ha (~27 meV) threshold matches # CRYSTAL14's default reporting cutoff. _CONDUCTING_GAP_THRESHOLD_HA = 1e-3 if n_occ > 0 and n_occ < eps_f.shape[0]: homo = float(eps_f[n_occ - 1]) lumo = float(eps_f[n_occ]) gap = lumo - homo if gap < _CONDUCTING_GAP_THRESHOLD_HA: plog.info( "POSSIBLY CONDUCTING STATE at convergence: " f"HOMO-LUMO gap = {gap:.6f} Ha " f"({gap * 27.2114:.3f} eV) " f"below the {_CONDUCTING_GAP_THRESHOLD_HA:.0e} Ha threshold " f"(HOMO={homo:.6f}, LUMO={lumo:.6f}). " "For metals / small-gap systems consider Fermi smearing " "(opts.smearing_temperature > 0) or denser k-mesh; " "Γ-only RHF on a true metal can be ill-defined." ) plog.converged( n_iter=result.n_iter, energy=result.energy, converged=True, ) _check_energy_sanity(result, system, plog) return result result.mo_coeffs = C_final result.mo_energies = eps_final result.converged = False plog.converged( n_iter=result.n_iter, energy=result.energy, converged=False, ) return result
# Closed-shell RKS via GDF -- the RHF/GDF driver already dispatches to # the KS branch when ``options`` is :class:`PeriodicKSOptions` (or # ``functional=`` is set); this alias gives the call site the right # name for what it does. run_rks_periodic_gamma_gdf = run_rhf_periodic_gamma_gdf