"""Phase 15c: Γ-point periodic RKS SCF driver using the composed
Ewald-3D Coulomb dispatch.
DFT counterpart of :func:`run_rhf_periodic_gamma_ewald3d`. Replaces
the HF exchange ``-K(D)/2`` with the libxc XC potential
``V_xc[ρ(D)]`` from :func:`build_xc_periodic`, while keeping the
ω-invariant Hartree J on the FFT-Poisson Ewald solver. For hybrid
functionals, a fraction ``α = func.hf_exchange_fraction()`` of HF
exchange is retained.
What this driver does
---------------------
At every SCF iteration:
F = H_core + J_ewald(ω, D) + V_xc[ρ(D)] − (α/2) K(D)
with H_core = T + V at Γ (Bloch-summed lattice integrals), S the
Γ-point overlap, and canonical-orthogonalisation as in the RHF
driver.
Density flow for V_xc
---------------------
``build_xc_periodic`` consumes a :class:`LatticeMatrixSet` density
that, at every grid point r, evaluates
``ρ(r) = Σ_g Σ_{μν} D(g)_{μν} χ_μ(r) χ_ν(r-g)``. For a Γ-only
molecular-limit cell, the physical density lives in the home cell
only (``D(g≠0) ≈ 0``), so we wrap the Γ-folded D in a degenerate
:class:`LatticeMatrixSet` with ``block[0] = D`` and zeros elsewhere.
This is consistent with the Γ-only Ewald assumption already enforced
by :func:`build_j_ewald_3d` (which expects molecular-limit cells —
see the docstring of :func:`run_rhf_periodic_gamma_ewald3d`).
Energy formula
--------------
Following the C++ RKS DIRECT_TRUNCATED driver (cpp/src/periodic_scf.cpp):
E_elec = E_xc + tr(D · H_core) + ½ tr(D · F_HF_part)
where ``F_HF_part = J − (α/2) K``. For pure DFT (``α = 0``) the K
trace is skipped entirely (and the K build is skipped too). For
α = 1, this reduces exactly to the RHF formula.
Validation targets
------------------
* **ω-invariance**: total SCF energy stable across ω ∈ [0.3, 2.0]
to ~µHa, exactly as the RHF Ewald driver.
* **Cross-check against DIRECT_TRUNCATED at molecular limit**: for
a closed-shell molecule in a ~30 bohr vacuum box, the EWALD_3D
and DIRECT_TRUNCATED RKS drivers must agree to within Makov–Payne
finite-box corrections (~O(1/L³), sub-mHa at this box size).
Scope (this commit)
-------------------
* **Γ-only** at a single k-point (0, 0, 0). Multi-k RKS Ewald is a
follow-up, parallel to the RHF / UHF multi-k extensions.
* **Closed-shell** RKS (``multiplicity = 1``, even ``n_electrons``).
The UKS counterpart lands separately.
* **DIIS** supported (default on); plain damping when ``use_diis =
False``. Saunders-Hillier level shift through ``opts.level_shift``.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import List, Optional, Sequence, Tuple, Union
import numpy as np
from ._vibeqc_core import (
BasisSet,
CoulombMethod,
Functional,
GridOptions,
InitialGuess,
LatticeMatrixSet,
LatticeSumOptions,
PeriodicKSOptions,
PeriodicSystem,
SCFIteration,
XCKind,
bloch_sum,
build_grid,
build_jk_gamma_molecular_limit,
build_xc_periodic,
compute_kinetic_lattice,
compute_nuclear_lattice,
compute_overlap_lattice,
nuclear_repulsion_per_cell,
)
from .ewald_composed import build_j_ewald_3d
from .ewald_j import auto_grid
from .guess import initial_density_closed_shell
from .madelung import (
madelung_energy_correction as _madelung_energy_correction,
)
from .periodic_grid import build_periodic_becke_grid
from .periodic_rhf_ewald import _canonical_orthogonalizer
from .periodic_scf_accelerators import DynamicDamping, PeriodicSCFAccelerator
from .progress import ProgressLogger, resolve_progress
from .scf_divergence import check_scf_divergence
__all__ = [
"PeriodicRKSEwaldResult",
"run_rks_periodic_gamma_ewald3d",
]
[docs]
@dataclass
class PeriodicRKSEwaldResult:
"""Result of :func:`run_rks_periodic_gamma_ewald3d`.
Structurally parallel to :class:`PeriodicKSResult` (the C++
DIRECT_TRUNCATED RKS result) but carries Ewald-specific
bookkeeping (``omega``, ``grid_shape``) and a separate XC-energy
field. All matrices are at Γ (no Bloch sums).
"""
energy: float
e_electronic: float
e_nuclear: float
e_xc: float
e_coulomb: float
e_hf_exchange: float
n_iter: int
converged: bool
mo_energies: np.ndarray
mo_coeffs: np.ndarray
density: np.ndarray
fock: np.ndarray
overlap: np.ndarray
functional: str = ""
scf_trace: List[SCFIteration] = field(default_factory=list)
# Ewald-specific:
omega: float = 0.0
grid_shape: Tuple[int, int, int] = (0, 0, 0)
def _density_set_gamma(template: LatticeMatrixSet, D: np.ndarray) -> LatticeMatrixSet:
"""Return a degenerate :class:`LatticeMatrixSet` with ``D`` in
the home-cell block and zeros elsewhere, copying the
cells / nbf layout of ``template`` (typically the overlap
lattice set).
For Γ-only molecular-limit cells, the physical density lives in
the home cell, so this wrapper is exact for the Ewald-Γ regime.
Tight cells where image-cell density blocks are non-negligible
need the multi-k driver.
Mutates the template's ``blocks`` in place via
:meth:`LatticeMatrixSet.set_block` (the only Python-visible way
to mutate the C++ vector — assigning ``.blocks[i]`` writes to a
transient list and silently no-ops at the C++ level).
"""
n_bf = D.shape[0]
if n_bf != template.nbf:
raise ValueError(
f"_density_set_gamma: D has nbf={n_bf} but template 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 _empty_lattice_set_like(
basis: BasisSet,
system: PeriodicSystem,
lat_opts,
) -> LatticeMatrixSet:
"""Build a fresh LatticeMatrixSet with the canonical (cells, nbf)
layout — by computing a throwaway overlap lattice and re-using
its structure. Cheap relative to the SCF itself, and robust
against future cell-list changes."""
return compute_overlap_lattice(basis, system, lat_opts)
[docs]
def run_rks_periodic_gamma_ewald3d(
system: PeriodicSystem,
basis: BasisSet,
options: Optional[PeriodicKSOptions] = None,
*,
omega: float = 0.0,
grid_shape: Optional[Union[Tuple[int, int, int], int]] = None,
origin: Optional[Sequence[float]] = None,
spacing_bohr: float = 0.3,
linear_dep_threshold: float = 1e-7,
canonical_orth_normalize_diag_first: bool = True,
auto_optimize_truncation: bool = True,
progress: Union[bool, ProgressLogger, None] = None,
verbose: Optional[int] = None,
) -> PeriodicRKSEwaldResult:
"""Γ-point closed-shell periodic Kohn-Sham SCF with Ewald-3D
Coulomb.
Hartree ``J`` uses the ω-invariant composed split from
:func:`build_j_ewald_3d`. ``V_xc`` is built on the periodic
Becke grid via :func:`build_xc_periodic`. For hybrid
functionals (``α = func.hf_exchange_fraction() > 0``) a
fraction of HF exchange is added via
:func:`build_jk_gamma_molecular_limit` at ω = 0 — same K-build
convention as the RHF Ewald driver.
Parameters
----------
system
:class:`PeriodicSystem` with a full-rank 3D lattice matrix.
basis
AO basis for the unit cell.
options
Optional :class:`PeriodicKSOptions`. Defaults: PBE
functional, default DIIS, no level shift. The
``use_periodic_becke`` flag selects between the periodic
Becke partition (default since v0.9.x; required for tight
cells, reduces to the molecular partition in the
molecular-limit regime) and the legacy molecular partition
(silently wrong on tight cells; set False only to reproduce
v0.8.x numerics).
omega
Ewald splitting parameter; result is ω-independent at
convergence to ~µHa across ω ∈ [0.3, 2.0].
grid_shape, origin, spacing_bohr
FFT-Poisson grid controls forwarded to :func:`build_j_ewald_3d`.
linear_dep_threshold
Overlap-eigenvalue cutoff for canonical orthogonalisation.
Returns
-------
:class:`PeriodicRKSEwaldResult`.
"""
opts = options if options is not None else PeriodicKSOptions()
lat_opts = opts.lattice_opts
# ---- Force EWALD_3D gauge ----
if lat_opts.coulomb_method != CoulombMethod.EWALD_3D:
lat_opts.coulomb_method = CoulombMethod.EWALD_3D
if lat_opts.cutoff_bohr < 18.0 and system.dim == 3:
V_cell = float(abs(np.linalg.det(np.asarray(system.lattice))))
if V_cell < 1000.0:
lat_opts.cutoff_bohr = max(lat_opts.cutoff_bohr, 18.0)
lat_opts.nuclear_cutoff_bohr = max(lat_opts.nuclear_cutoff_bohr, 25.0)
plog = resolve_progress(progress, verbose=verbose)
# ---- Ewald splitting parameter ω — must match nuclear α ----
# When not specified explicitly, use CRYSTAL's default α = 2.8/V^{1/3}
# (matching the BIPOLE driver and CRYSTAL's MADEL2).
_ewald_tol = getattr(opts, "ewald_tolerance", 1e-12)
_cutoff = getattr(opts, "ewald_cutoff_bohr", lat_opts.nuclear_cutoff_bohr)
if omega <= 0.0:
_user_omega = getattr(opts, "ewald_omega", None)
if _user_omega is not None and float(_user_omega) > 0.0:
omega = float(_user_omega)
else:
from .bipole_ext_el_pole import crystal_default_ewald_alpha
V_cell = float(abs(np.linalg.det(np.asarray(system.lattice, dtype=float))))
omega = crystal_default_ewald_alpha(V_cell)
lat = np.asarray(system.lattice, dtype=float)
if grid_shape is None:
grid_shape_t = auto_grid(lat, spacing_bohr)
elif isinstance(grid_shape, int):
grid_shape_t = (grid_shape, grid_shape, grid_shape)
else:
grid_shape_t = tuple(int(x) for x in grid_shape)
plog.info(
f"RKS Gamma EWALD_3D / functional={opts.functional!r}, "
f"omega = {float(omega):.3f}, "
f"FFT grid {grid_shape_t[0]}x{grid_shape_t[1]}x{grid_shape_t[2]}"
)
plog.info(f"basis: {basis.name} ({basis.nbasis} BFs / {basis.nshells} shells)")
from .options_dump import dump_active_settings
dump_active_settings(
plog,
[
("PeriodicKSOptions", opts),
("LatticeSumOptions", lat_opts),
(
"Driver kwargs",
{
"omega": float(omega),
"grid_shape": grid_shape_t,
"origin": origin,
"spacing_bohr": float(spacing_bohr),
"linear_dep_threshold": float(linear_dep_threshold),
"canonical_orth_normalize_diag_first": canonical_orth_normalize_diag_first,
"auto_optimize_truncation": auto_optimize_truncation,
},
),
],
)
if plog.level >= 5:
from .scf_log import format_basis_summary
plog.write_raw(format_basis_summary(basis))
# Closed-shell check.
n_elec = system.n_electrons()
if n_elec % 2 != 0:
raise ValueError(
"run_rks_periodic_gamma_ewald3d: closed-shell RKS "
f"requires even electron count; got {n_elec}"
)
if system.multiplicity != 1:
raise ValueError(
"run_rks_periodic_gamma_ewald3d: closed-shell RKS requires "
f"multiplicity=1; got {system.multiplicity}"
)
n_occ = n_elec // 2
# ---- Functional + DFT grid -------------------------------------------
func = Functional(opts.functional, 1) # spin-unpolarised RKS
# hf_exchange_fraction is a read-only @property (no parens).
alpha = float(func.hf_exchange_fraction)
if opts.use_periodic_becke:
grid = build_periodic_becke_grid(
system,
grid_options=opts.grid,
image_radius_bohr=float(opts.becke_image_radius_bohr),
)
else:
grid = build_grid(system.unit_cell_molecule(), opts.grid)
# ---- Auto-optimise lattice truncation (default ON) -------------------
# Find the loosest lattice / Schwarz settings that keep S(Γ) PSD,
# so the user doesn't have to hand-tune ``cutoff_bohr`` /
# ``schwarz_threshold`` to make the SCF converge on tight crystals.
# See vibeqc.eigs_preflight.optimize_truncation. Short-circuits
# at 1 evaluation when starting settings already pass the
# preflight; non-short-circuit overhead is typically a handful of
# extra S(Γ) builds, << SCF cost.
if auto_optimize_truncation and lat_opts.coulomb_method == CoulombMethod.EWALD_3D:
from .eigs_preflight import (
format_truncation_optimization_report,
optimize_truncation,
)
opt_rep = optimize_truncation(system, basis, lattice_opts=lat_opts)
if (
opt_rep.n_evaluations > 1
or opt_rep.optimized_lattice_opts.cutoff_bohr != lat_opts.cutoff_bohr
):
plog.write_raw(format_truncation_optimization_report(opt_rep))
if not opt_rep.converged:
plog.warning(
"auto_optimize_truncation did not converge; SCF will run "
"with the last evaluated settings. Check the report above "
"and consider vq.disambiguate_critical_overlap (basis vs "
"screening) and / or vq.make_basis(..., exp_to_discard=...)."
)
lat_opts = opt_rep.optimized_lattice_opts
# ---- One-electron integrals at Γ -------------------------------------
with plog.stage(
"integrals_lattice", detail=f"S/T/V at cutoff {lat_opts.cutoff_bohr:.2f} bohr"
):
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, 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 = T + V
S = 0.5 * (S + S.T)
Hcore = 0.5 * (Hcore + Hcore.T)
# ---- Linear-dependence preflight on S(Γ) -----------------------------
# See periodic_rhf_ewald.py for the rationale.
from .linear_dependence import scf_preflight_overlap_check
scf_preflight_overlap_check(
S,
plog=plog,
label="S(Γ)",
basis=basis,
)
# ---- Canonical orthogonalisation -------------------------------------
X, n_kept = _canonical_orthogonalizer(
S,
linear_dep_threshold,
normalize_diag_first=canonical_orth_normalize_diag_first,
)
if n_occ > n_kept:
raise RuntimeError(
"run_rks_periodic_gamma_ewald3d: canonical orthogonalisation "
f"dropped too many directions (n_occ = {n_occ}, "
f"n_kept = {n_kept}); loosen linear_dep_threshold or pick "
"a less redundant basis."
)
e_nuc = float(nuclear_repulsion_per_cell(system, lat_opts))
def diagonalise(F: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
Fp = X.T @ F @ X
Fp = 0.5 * (Fp + Fp.T)
eps, Cp = np.linalg.eigh(Fp)
return X @ Cp, eps
# ---- Initial guess via the unified engine ---------------------------
guess = getattr(opts, "initial_guess", InitialGuess.HCORE)
D_engine = initial_density_closed_shell(
system.unit_cell_molecule(),
basis,
n_occ,
guess,
is_periodic=True,
)
if D_engine is not None:
plog.info(f"initial guess: {guess.name} (density via GuessEngine)")
D = D_engine
C0, eps0 = diagonalise(Hcore) # MO trace for log symmetry
else:
plog.info(f"initial guess: {guess.name} (Hcore-diagonalise)")
C0, eps0 = diagonalise(Hcore)
D = 2.0 * C0[:, :n_occ] @ C0[:, :n_occ].T
D = 0.5 * (D + D.T)
D_prev = D.copy()
damping = float(opts.damping)
if not (0.0 <= damping < 1.0):
raise ValueError(
f"run_rks_periodic_gamma_ewald3d: damping must be in [0, 1); got {damping}"
)
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
)
level_shift = float(getattr(opts, "level_shift", 0.0))
# Phase C1c — quadratic SCF fallback (see periodic_rhf_ewald
# for the full description).
quadratic_fallback_iter = int(getattr(opts, "quadratic_fallback_iter", 0))
quadratic_fallback_shift = float(getattr(opts, "quadratic_fallback_shift", 0.1))
quadratic_fallback_max_step = float(
getattr(opts, "quadratic_fallback_max_step", 0.1)
)
# Track MO basis between iterations so the C1c Newton step has a
# current MO frame to operate in.
C_prev_mo = C0
eps_prev_mo = eps0
# Pre-allocate a reusable LatticeMatrixSet for the V_xc density
# input. We mutate its blocks each iteration via set_block().
D_set = _empty_lattice_set_like(basis, system, lat_opts)
# Memory cliff guard — build_xc_periodic materialises AO values
# per cell. Fail fast on large systems instead of SIGKILL.
from .periodic_rks_multi_k_ewald import _guard_legacy_periodic_xc_memory
_guard_legacy_periodic_xc_memory(
n_grid=int(np.asarray(grid.points).shape[0]),
nbf=int(basis.nbasis),
n_cells=len(D_set),
is_gga=(func.kind == XCKind.GGA),
functional=str(opts.functional),
)
# ---- SCF loop --------------------------------------------------------
scf_trace: List[SCFIteration] = []
result = PeriodicRKSEwaldResult(
energy=0.0,
e_electronic=0.0,
e_nuclear=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,
functional=str(opts.functional),
scf_trace=scf_trace,
omega=float(omega),
grid_shape=grid_shape_t,
)
E_prev = 0.0
plog.banner(f"SCF (RKS Gamma {opts.functional!r}, EWALD_3D)")
plog.info(" iter energy (Ha) dE ||[F,DS]|| DIIS")
for iter_idx in range(1, int(opts.max_iter) + 1):
if damper is not None:
damping = damper.alpha
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
)
# Hartree J via composed Ewald-3D.
J = build_j_ewald_3d(
basis,
system,
D_used,
omega=float(omega),
lattice_opts=lat_opts,
grid_shape=grid_shape_t,
origin=origin,
spacing_bohr=spacing_bohr,
)
# K via the full-range real-space builder. For pure DFT
# (α = 0) we skip the K build entirely.
K = None
if alpha != 0.0:
jk = build_jk_gamma_molecular_limit(
basis,
system,
lat_opts,
D_used,
0.0,
)
K = np.asarray(jk.K)
# V_xc via libxc on the periodic Becke grid. Wrap D_used in
# a degenerate LatticeMatrixSet (block 0 = D, others zero).
_density_set_gamma(D_set, D_used)
xc_contrib = build_xc_periodic(
basis,
system,
grid,
func,
D_set,
lat_opts,
)
# V_xc lives in the LatticeMatrixSet — Bloch-fold to Γ.
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_HF_part = J − (α/2) K
if K is not None:
F_HF_part = J - 0.5 * alpha * K
else:
F_HF_part = J
F = Hcore + F_HF_part + V_xc
F = 0.5 * (F + F.T)
# Energy: E_elec = E_xc + tr(D·Hcore) + ½ tr(D·F_HF_part)
E_HF_trace = 0.5 * float(np.einsum("ij,ij->", D_used, F_HF_part))
E_core_trace = float(np.einsum("ij,ij->", D_used, Hcore))
E_elec = E_xc + E_core_trace + E_HF_trace
# Madelung-leak correction (v0.6.1) — disabled for EWALD_3D in
# v0.7.0 since V_ne now dispatches to the gauge-aligned Ewald path.
if lat_opts.coulomb_method == CoulombMethod.EWALD_3D:
E_madelung_fix = 0.0
else:
E_madelung_fix = _madelung_energy_correction(
D_used,
S,
system,
nuclear_uses_ewald=False,
)
E_total = E_elec + e_nuc + E_madelung_fix
# Coulomb / HF-exchange decomposition for reporting.
E_coulomb = 0.5 * float(np.einsum("ij,ij->", D_used, J))
E_hf_K = (
-0.5 * alpha * 0.5 * float(np.einsum("ij,ij->", D_used, K))
if K is not None
else 0.0
)
# Orbital-gradient norm.
FDS = F @ D_used @ S
grad = FDS - FDS.T
grad_norm = float(np.linalg.norm(grad))
dE = E_total - E_prev
scf_trace.append(
SCFIteration(
iter=iter_idx,
energy=float(E_total),
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(E_total),
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),
)
# Per-iter energy decomposition (E_kin / E_ne / E_J / E_xc /
# E_K / E_nuc / E_madelung). Always emitted at level 4+ for
# cross-code (PySCF) parity comparison and bug localisation.
E_kin_iter = float(np.einsum("ij,ij->", D_used, T))
E_ne_iter = float(np.einsum("ij,ij->", D_used, V))
plog.energy_decomposition(
iter_idx,
E_kin=E_kin_iter,
E_ne=E_ne_iter,
E_J=E_coulomb,
E_xc=float(E_xc),
E_K=E_hf_K,
E_nuc=float(e_nuc),
E_madelung=float(E_madelung_fix),
)
# Divergence detection (v0.5.6, generalised v0.6.2 to a shared
# helper applied across all 8 periodic SCF entry points).
check_scf_divergence(
"run_rks_periodic_gamma_ewald3d",
iter_idx,
E_total,
grad_norm,
dE,
)
converged = (
iter_idx > 1
and abs(dE) < float(opts.conv_tol_energy)
and grad_norm < float(opts.conv_tol_grad)
)
# Phase C1c gate.
in_quadratic_phase = (
quadratic_fallback_iter > 0 and iter_idx > quadratic_fallback_iter
)
if in_quadratic_phase:
from .quadratic_scf import quadratic_step
C_new, eps_new = quadratic_step(
F,
C_prev_mo,
eps_prev_mo,
n_occ,
shift=quadratic_fallback_shift,
max_step=quadratic_fallback_max_step,
)
else:
# SCF-accelerator extrapolation (DIIS / KDIIS / EDIIS /
# EDIIS_DIIS / ADIIS). The convergence check above uses
# the pre-extrapolation F so dE / grad_norm reflect raw
# SCF progress. ``C_prev_mo`` / ``eps_prev_mo`` from the
# previous diag give KDIIS its canonical-MO basis.
if accel is not None:
F_ex = accel.extrapolate_rhf(
F,
error=grad,
density=D_used,
energy=E_total,
mo_coeffs=C_prev_mo,
mo_energies=eps_prev_mo,
n_occ=n_occ,
)
if diis_active:
F = F_ex
# Saunders-Hillier level shift.
if level_shift != 0.0:
F_diag = F + level_shift * S - (level_shift / 2.0) * (S @ D_used @ S)
F_diag = 0.5 * (F_diag + F_diag.T)
else:
F_diag = F
C_new, eps_new = diagonalise(F_diag)
C_prev_mo = C_new
eps_prev_mo = eps_new
D_prev = D_used
D = 2.0 * C_new[:, :n_occ] @ C_new[:, :n_occ].T
D = 0.5 * (D + D.T)
# Stash latest state.
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
if damper is not None:
damper.update(E_total)
E_prev = E_total
if converged:
# Final consistency pass on the fresh D (matches RHF
# driver convention).
J_f = build_j_ewald_3d(
basis,
system,
D,
omega=float(omega),
lattice_opts=lat_opts,
grid_shape=grid_shape_t,
origin=origin,
spacing_bohr=spacing_bohr,
)
K_f = None
if alpha != 0.0:
jk_f = build_jk_gamma_molecular_limit(
basis,
system,
lat_opts,
D,
0.0,
)
K_f = np.asarray(jk_f.K)
_density_set_gamma(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)
if K_f is not None:
F_HF_f = J_f - 0.5 * alpha * K_f
else:
F_HF_f = J_f
F_f = Hcore + F_HF_f + V_xc_f
F_f = 0.5 * (F_f + F_f.T)
C_f, eps_f = diagonalise(F_f)
E_HF_f = 0.5 * float(np.einsum("ij,ij->", D, F_HF_f))
E_core_f = float(np.einsum("ij,ij->", D, Hcore))
E_elec_f = E_xc_f + E_core_f + E_HF_f
E_coulomb_f = 0.5 * float(np.einsum("ij,ij->", D, J_f))
E_hf_K_f = (
-0.25 * alpha * float(np.einsum("ij,ij->", D, K_f))
if K_f is not None
else 0.0
)
if lat_opts.coulomb_method == CoulombMethod.EWALD_3D:
E_madelung_fix_f = 0.0
else:
E_madelung_fix_f = _madelung_energy_correction(
D,
S,
system,
nuclear_uses_ewald=False,
)
result.energy = E_elec_f + e_nuc + E_madelung_fix_f
result.e_electronic = E_elec_f
result.e_xc = E_xc_f
result.e_coulomb = E_coulomb_f
result.e_hf_exchange = E_hf_K_f
result.mo_energies = eps_f
result.mo_coeffs = C_f
result.density = D
result.fock = F_f
result.converged = True
plog.converged(
n_iter=result.n_iter,
energy=result.energy,
converged=True,
)
return result
result.converged = False
plog.converged(
n_iter=result.n_iter,
energy=result.energy,
converged=False,
)
return result