Skip to main content

sci_form/scf/
uhf.rs

1//! Unrestricted Hartree-Fock (UHF) and Restricted Open-shell HF (ROHF).
2//!
3//! UHF uses separate alpha and beta Fock matrices and density matrices,
4//! allowing different spatial orbitals for different spins.
5//!
6//! **UHF equations:**
7//!   F^α = H⁰ + Σ_{λσ} [P^T_{λσ}(μν|λσ) - P^α_{λσ}(μλ|νσ)]
8//!   F^β = H⁰ + Σ_{λσ} [P^T_{λσ}(μν|λσ) - P^β_{λσ}(μλ|νσ)]
9//!
10//! where P^T = P^α + P^β is the total density.
11
12use nalgebra::DMatrix;
13
14use super::basis::BasisSet;
15use super::constants::{
16    DIIS_SUBSPACE_SIZE, HARTREE_TO_EV, SCF_DENSITY_THRESHOLD, SCF_ENERGY_THRESHOLD, SCF_MAX_ITER,
17};
18use super::core_matrices::{nuclear_repulsion_energy, CoreMatrices};
19use super::diis::DiisAccelerator;
20use super::orthogonalization::{back_transform, lowdin_orthogonalization, transform_to_orthogonal};
21use super::two_electron::TwoElectronIntegrals;
22use super::types::MolecularSystem;
23
24/// Result of an Unrestricted Hartree-Fock (UHF) calculation.
25#[derive(Debug, Clone)]
26pub struct UhfResult {
27    /// Alpha orbital energies (Hartree, ascending).
28    pub alpha_orbital_energies: Vec<f64>,
29    /// Beta orbital energies (Hartree, ascending).
30    pub beta_orbital_energies: Vec<f64>,
31    /// Alpha MO coefficients (n_basis × n_basis).
32    pub alpha_coefficients: DMatrix<f64>,
33    /// Beta MO coefficients (n_basis × n_basis).
34    pub beta_coefficients: DMatrix<f64>,
35    /// Alpha density matrix.
36    pub alpha_density: DMatrix<f64>,
37    /// Beta density matrix.
38    pub beta_density: DMatrix<f64>,
39    /// Total density matrix (P^α + P^β).
40    pub total_density: DMatrix<f64>,
41    /// Electronic energy (Hartree).
42    pub electronic_energy: f64,
43    /// Nuclear repulsion energy (Hartree).
44    pub nuclear_repulsion: f64,
45    /// Total energy (Hartree).
46    pub total_energy: f64,
47    /// HOMO energy (Hartree) — highest among alpha/beta.
48    pub homo_energy: f64,
49    /// LUMO energy (Hartree) — lowest among alpha/beta.
50    pub lumo_energy: Option<f64>,
51    /// HOMO-LUMO gap (eV).
52    pub gap_ev: f64,
53    /// Mulliken charges per atom.
54    pub mulliken_charges: Vec<f64>,
55    /// Number of SCF iterations.
56    pub scf_iterations: usize,
57    /// Whether SCF converged.
58    pub converged: bool,
59    /// Number of basis functions.
60    pub n_basis: usize,
61    /// Number of alpha electrons.
62    pub n_alpha: usize,
63    /// Number of beta electrons.
64    pub n_beta: usize,
65    /// Expectation value <S²> (ideally S(S+1) for clean spin state).
66    pub s2_expectation: f64,
67    /// Spin contamination: <S²> - S(S+1).
68    pub spin_contamination: f64,
69    /// Overlap matrix.
70    pub overlap_matrix: DMatrix<f64>,
71    /// Alpha Fock matrix at convergence.
72    pub alpha_fock: DMatrix<f64>,
73    /// Beta Fock matrix at convergence.
74    pub beta_fock: DMatrix<f64>,
75}
76
77/// Configuration for UHF/ROHF calculations.
78#[derive(Debug, Clone)]
79pub struct UhfConfig {
80    pub max_iterations: usize,
81    pub energy_threshold: f64,
82    pub density_threshold: f64,
83    pub diis_size: usize,
84    pub level_shift: f64,
85    pub damping: f64,
86    pub use_parallel_eri: bool,
87    /// If true, apply ROHF constraint after UHF convergence.
88    pub rohf: bool,
89}
90
91impl Default for UhfConfig {
92    fn default() -> Self {
93        Self {
94            max_iterations: SCF_MAX_ITER,
95            energy_threshold: SCF_ENERGY_THRESHOLD,
96            density_threshold: SCF_DENSITY_THRESHOLD,
97            diis_size: DIIS_SUBSPACE_SIZE,
98            level_shift: 0.0,
99            damping: 0.0,
100            use_parallel_eri: false,
101            rohf: false,
102        }
103    }
104}
105
106/// Build single-spin density matrix: P^σ_μν = Σ_{k ∈ occ_σ} C^σ_μk · C^σ_νk
107fn build_spin_density(coefficients: &DMatrix<f64>, n_occ: usize) -> DMatrix<f64> {
108    let n = coefficients.nrows();
109    let mut p = DMatrix::zeros(n, n);
110    for i in 0..n {
111        for j in 0..=i {
112            let mut val = 0.0;
113            for k in 0..n_occ {
114                val += coefficients[(i, k)] * coefficients[(j, k)];
115            }
116            p[(i, j)] = val;
117            p[(j, i)] = val;
118        }
119    }
120    p
121}
122
123/// Build UHF Fock matrix for one spin channel.
124///
125/// F^σ_μν = H⁰_μν + Σ_{λσ} [P^T_{λσ}(μν|λσ) − P^σ_{λσ}(μλ|νσ)]
126fn build_uhf_fock(
127    h_core: &DMatrix<f64>,
128    p_total: &DMatrix<f64>,
129    p_spin: &DMatrix<f64>,
130    eris: &TwoElectronIntegrals,
131) -> DMatrix<f64> {
132    let n = h_core.nrows();
133    let mut fock = h_core.clone();
134
135    for mu in 0..n {
136        for nu in 0..=mu {
137            let mut g = 0.0;
138            for lam in 0..n {
139                for sig in 0..n {
140                    // Coulomb from total density
141                    let j = p_total[(lam, sig)] * eris.get(mu, nu, lam, sig);
142                    // Exchange from same-spin density only
143                    let k = p_spin[(lam, sig)] * eris.get(mu, lam, nu, sig);
144                    g += j - k;
145                }
146            }
147            fock[(mu, nu)] += g;
148            if mu != nu {
149                fock[(nu, mu)] += g;
150            }
151        }
152    }
153    fock
154}
155
156/// Compute UHF electronic energy.
157///
158/// E_elec = 0.5 · Tr[P^T · H⁰ + P^α · F^α + P^β · F^β]
159fn uhf_electronic_energy(
160    p_alpha: &DMatrix<f64>,
161    p_beta: &DMatrix<f64>,
162    h_core: &DMatrix<f64>,
163    f_alpha: &DMatrix<f64>,
164    f_beta: &DMatrix<f64>,
165) -> f64 {
166    let n = h_core.nrows();
167    let mut e = 0.0;
168    for i in 0..n {
169        for j in 0..n {
170            let p_t = p_alpha[(i, j)] + p_beta[(i, j)];
171            e += p_t * h_core[(i, j)]
172                + p_alpha[(i, j)] * f_alpha[(i, j)]
173                + p_beta[(i, j)] * f_beta[(i, j)];
174        }
175    }
176    0.5 * e
177}
178
179/// Compute <S²> expectation value for a UHF wavefunction.
180///
181/// <S²> = S_z(S_z+1) + n_β − Σ_{i∈α,j∈β} |⟨ψ^α_i|ψ^β_j⟩|²
182fn compute_s2(
183    c_alpha: &DMatrix<f64>,
184    c_beta: &DMatrix<f64>,
185    overlap: &DMatrix<f64>,
186    n_alpha: usize,
187    n_beta: usize,
188) -> f64 {
189    let sz = 0.5 * (n_alpha as f64 - n_beta as f64);
190    let mut overlap_sum = 0.0;
191
192    // Compute S^α_β = C^α^T · S · C^β for occupied orbitals
193    for i in 0..n_alpha {
194        for j in 0..n_beta {
195            let mut s_ij = 0.0;
196            let n = overlap.nrows();
197            for mu in 0..n {
198                for nu in 0..n {
199                    s_ij += c_alpha[(mu, i)] * overlap[(mu, nu)] * c_beta[(nu, j)];
200                }
201            }
202            overlap_sum += s_ij * s_ij;
203        }
204    }
205
206    sz * (sz + 1.0) + n_beta as f64 - overlap_sum
207}
208
209/// RMS change between two density matrices.
210fn density_rms(a: &DMatrix<f64>, b: &DMatrix<f64>) -> f64 {
211    let n = a.nrows();
212    let mut sum = 0.0;
213    for i in 0..n {
214        for j in 0..n {
215            let d = a[(i, j)] - b[(i, j)];
216            sum += d * d;
217        }
218    }
219    (sum / (n * n) as f64).sqrt()
220}
221
222/// Diagonalize a Fock matrix in orthogonal basis, return sorted
223/// (coefficients_AO, orbital_energies).
224fn diag_fock(fock: &DMatrix<f64>, x: &DMatrix<f64>, n_basis: usize) -> (DMatrix<f64>, Vec<f64>) {
225    let f_ortho = transform_to_orthogonal(fock, x);
226    let eigen = f_ortho.symmetric_eigen();
227
228    let mut indices: Vec<usize> = (0..n_basis).collect();
229    indices.sort_by(|&a, &b| {
230        eigen.eigenvalues[a]
231            .partial_cmp(&eigen.eigenvalues[b])
232            .unwrap()
233    });
234
235    let mut c_ortho = DMatrix::zeros(n_basis, n_basis);
236    let mut energies = vec![0.0; n_basis];
237    for (new_idx, &old_idx) in indices.iter().enumerate() {
238        energies[new_idx] = eigen.eigenvalues[old_idx];
239        for i in 0..n_basis {
240            c_ortho[(i, new_idx)] = eigen.eigenvectors[(i, old_idx)];
241        }
242    }
243
244    let c = back_transform(&c_ortho, x);
245    (c, energies)
246}
247
248/// Run an Unrestricted Hartree-Fock (UHF) calculation.
249///
250/// Supports open-shell systems with arbitrary spin multiplicity.
251/// Set `multiplicity = 1` for closed-shell (equivalent to RHF),
252/// `multiplicity = 2` for doublet (radical), `multiplicity = 3` for
253/// triplet, etc.
254pub fn run_uhf(system: &MolecularSystem, config: &UhfConfig) -> UhfResult {
255    let n_electrons = system.n_electrons();
256    let n_unpaired = (system.multiplicity as usize).saturating_sub(1);
257    let n_alpha = (n_electrons + n_unpaired) / 2;
258    let n_beta = n_electrons - n_alpha;
259
260    // Build basis
261    let basis = BasisSet::sto3g(&system.atomic_numbers, &system.positions_bohr);
262    let n_basis = basis.n_basis;
263
264    // One-electron matrices
265    let core = CoreMatrices::build(&basis, &system.atomic_numbers, &system.positions_bohr);
266    let s = &core.overlap;
267    let h_core = &core.core_hamiltonian;
268    let e_nuc = nuclear_repulsion_energy(&system.atomic_numbers, &system.positions_bohr);
269
270    // Two-electron integrals
271    let eris = {
272        #[cfg(feature = "parallel")]
273        {
274            if config.use_parallel_eri {
275                TwoElectronIntegrals::compute_parallel(&basis)
276            } else {
277                TwoElectronIntegrals::compute(&basis)
278            }
279        }
280        #[cfg(not(feature = "parallel"))]
281        {
282            TwoElectronIntegrals::compute(&basis)
283        }
284    };
285
286    // Löwdin orthogonalization
287    let (x, _) = lowdin_orthogonalization(s, 1e-8);
288
289    // Initial guess: diagonalize H_core for both spins
290    let (mut c_alpha, mut e_alpha) = diag_fock(h_core, &x, n_basis);
291    let (mut c_beta, mut e_beta) = diag_fock(h_core, &x, n_basis);
292
293    let mut p_alpha = build_spin_density(&c_alpha, n_alpha);
294    let mut p_beta = build_spin_density(&c_beta, n_beta);
295    let mut p_total = &p_alpha + &p_beta;
296
297    let mut p_alpha_old = p_alpha.clone();
298    let mut p_beta_old = p_beta.clone();
299
300    // SCF loop
301    let mut diis_alpha = DiisAccelerator::new(config.diis_size);
302    let mut diis_beta = DiisAccelerator::new(config.diis_size);
303    let mut e_total = 0.0;
304    let mut converged = false;
305    let mut scf_iter = 0;
306    let mut f_alpha = h_core.clone();
307    let mut f_beta = h_core.clone();
308
309    for iteration in 0..config.max_iterations {
310        scf_iter = iteration + 1;
311
312        // Build Fock matrices
313        f_alpha = build_uhf_fock(h_core, &p_total, &p_alpha, &eris);
314        f_beta = build_uhf_fock(h_core, &p_total, &p_beta, &eris);
315
316        // Level shift
317        if config.level_shift > 0.0 {
318            for i in 0..n_basis {
319                f_alpha[(i, i)] += config.level_shift;
320                f_beta[(i, i)] += config.level_shift;
321            }
322        }
323
324        // DIIS
325        if config.diis_size > 0 {
326            diis_alpha.add_iteration(&f_alpha, &p_alpha, s);
327            diis_beta.add_iteration(&f_beta, &p_beta, s);
328            if let Some(fa) = diis_alpha.extrapolate() {
329                f_alpha = fa;
330            }
331            if let Some(fb) = diis_beta.extrapolate() {
332                f_beta = fb;
333            }
334        }
335
336        // Diagonalize
337        let (ca_new, ea_new) = diag_fock(&f_alpha, &x, n_basis);
338        let (cb_new, eb_new) = diag_fock(&f_beta, &x, n_basis);
339        c_alpha = ca_new;
340        c_beta = cb_new;
341        e_alpha = ea_new;
342        e_beta = eb_new;
343
344        let pa_new = build_spin_density(&c_alpha, n_alpha);
345        let pb_new = build_spin_density(&c_beta, n_beta);
346
347        if config.damping > 0.0 {
348            p_alpha = &pa_new * (1.0 - config.damping) + &p_alpha_old * config.damping;
349            p_beta = &pb_new * (1.0 - config.damping) + &p_beta_old * config.damping;
350        } else {
351            p_alpha = pa_new;
352            p_beta = pb_new;
353        }
354        p_total = &p_alpha + &p_beta;
355
356        let e_elec = uhf_electronic_energy(&p_alpha, &p_beta, h_core, &f_alpha, &f_beta);
357        let e_new = e_elec + e_nuc;
358
359        let delta_e = (e_new - e_total).abs();
360        let delta_pa = density_rms(&p_alpha, &p_alpha_old);
361        let delta_pb = density_rms(&p_beta, &p_beta_old);
362        let delta_p = delta_pa.max(delta_pb);
363
364        if delta_e < config.energy_threshold && delta_p < config.density_threshold && iteration > 0
365        {
366            converged = true;
367            e_total = e_new;
368            break;
369        }
370
371        e_total = e_new;
372        p_alpha_old = p_alpha.clone();
373        p_beta_old = p_beta.clone();
374    }
375
376    // ROHF: apply canonical ROHF projection if requested
377    if config.rohf {
378        // Effective Fock = (F^α + F^β)/2 with coupling operator
379        let f_eff = (&f_alpha + &f_beta) * 0.5;
380        let (c_rohf, e_rohf) = diag_fock(&f_eff, &x, n_basis);
381        // Use same spatial orbitals for both spins
382        p_alpha = build_spin_density(&c_rohf, n_alpha);
383        p_beta = build_spin_density(&c_rohf, n_beta);
384        p_total = &p_alpha + &p_beta;
385        c_alpha = c_rohf.clone();
386        c_beta = c_rohf;
387        e_alpha = e_rohf.clone();
388        e_beta = e_rohf;
389    }
390
391    let e_elec = uhf_electronic_energy(&p_alpha, &p_beta, h_core, &f_alpha, &f_beta);
392
393    // HOMO/LUMO
394    let homo_a = if n_alpha > 0 {
395        e_alpha[n_alpha - 1]
396    } else {
397        f64::NEG_INFINITY
398    };
399    let homo_b = if n_beta > 0 {
400        e_beta[n_beta - 1]
401    } else {
402        f64::NEG_INFINITY
403    };
404    let homo = homo_a.max(homo_b);
405
406    let lumo_a = if n_alpha < n_basis {
407        Some(e_alpha[n_alpha])
408    } else {
409        None
410    };
411    let lumo_b = if n_beta < n_basis {
412        Some(e_beta[n_beta])
413    } else {
414        None
415    };
416    let lumo = match (lumo_a, lumo_b) {
417        (Some(a), Some(b)) => Some(a.min(b)),
418        (Some(a), None) => Some(a),
419        (None, Some(b)) => Some(b),
420        _ => None,
421    };
422    let gap = lumo.map(|l| (l - homo) * HARTREE_TO_EV).unwrap_or(0.0);
423
424    // <S²>
425    let s2 = compute_s2(&c_alpha, &c_beta, s, n_alpha, n_beta);
426    let s_exact = 0.5 * n_unpaired as f64;
427    let s2_exact = s_exact * (s_exact + 1.0);
428    let contamination = s2 - s2_exact;
429
430    // Mulliken from total density
431    let mulliken = super::mulliken::mulliken_analysis(
432        &p_total,
433        s,
434        &basis.function_to_atom,
435        &system.atomic_numbers,
436    );
437
438    UhfResult {
439        alpha_orbital_energies: e_alpha,
440        beta_orbital_energies: e_beta,
441        alpha_coefficients: c_alpha,
442        beta_coefficients: c_beta,
443        alpha_density: p_alpha,
444        beta_density: p_beta,
445        total_density: p_total,
446        electronic_energy: e_elec,
447        nuclear_repulsion: e_nuc,
448        total_energy: e_total,
449        homo_energy: homo,
450        lumo_energy: lumo,
451        gap_ev: gap,
452        mulliken_charges: mulliken.charges,
453        scf_iterations: scf_iter,
454        converged,
455        n_basis,
456        n_alpha,
457        n_beta,
458        s2_expectation: s2,
459        spin_contamination: contamination,
460        overlap_matrix: s.clone(),
461        alpha_fock: f_alpha,
462        beta_fock: f_beta,
463    }
464}
465
466/// Convenience: run UHF with parallel ERI.
467pub fn run_uhf_parallel(system: &MolecularSystem) -> UhfResult {
468    run_uhf(
469        system,
470        &UhfConfig {
471            use_parallel_eri: true,
472            ..UhfConfig::default()
473        },
474    )
475}
476
477/// Convenience: run ROHF (restricted open-shell).
478pub fn run_rohf(system: &MolecularSystem) -> UhfResult {
479    run_uhf(
480        system,
481        &UhfConfig {
482            rohf: true,
483            use_parallel_eri: true,
484            ..UhfConfig::default()
485        },
486    )
487}