Skip to main content

qualia_core_db/specialized_libs/chemistry_modeling/
scf.rs

1//! Self-Consistent Field (SCF) Iterative Driver
2//!
3//! Solves the generalized Roothaan-Hall equation (FC = SCE) to find the molecular
4//! ground state electronic energy.
5//!
6//! Implements Direct Inversion in the Iterative Subspace (DIIS) to aggressively accelerate
7//! convergence, strictly bounded within the zero-heap constraints.
8
9use super::super::shared::zero_heap_algebra::ZeroHeapMatrix;
10
11/// Subspace size for DIIS. Maximum historical Fock/Density/Error vectors kept.
12pub const DIIS_SUBSPACE_SIZE: usize = 8;
13pub const SCF_CONVERGENCE_THRESHOLD: f64 = 1e-8;
14pub const MAX_SCF_ITERATIONS: usize = 100;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum ScfFormalism {
18    Restricted,   // RHF (Closed-shell)
19    Unrestricted, // UHF (Open-shell)
20}
21
22#[derive(Debug)]
23pub enum ScfError {
24    ConvergenceFailed,
25    SingularDiisMatrix,
26    InvalidEigenvalueDecomposition,
27}
28
29/// Gaussian elimination solver for small DIIS mixing matrices.
30/// Solves Ax = b where A is an N x N matrix, returning x.
31/// All done strictly on the stack.
32pub fn gaussian_elimination<const N: usize>(
33    mut a: ZeroHeapMatrix<f64, N, N>,
34    mut b: [f64; N],
35) -> Result<[f64; N], ScfError> {
36    // Forward elimination
37    for i in 0..N {
38        // Find pivot
39        let mut max_row = i;
40        let mut max_val = a.get(i, i).abs();
41        for k in (i + 1)..N {
42            let val = a.get(k, i).abs();
43            if val > max_val {
44                max_val = val;
45                max_row = k;
46            }
47        }
48
49        if max_val < 1e-14 {
50            return Err(ScfError::SingularDiisMatrix);
51        }
52
53        // Swap rows
54        if i != max_row {
55            for j in i..N {
56                let temp = a.get(i, j);
57                a.set(i, j, a.get(max_row, j));
58                a.set(max_row, j, temp);
59            }
60            let temp_b = b[i];
61            b[i] = b[max_row];
62            b[max_row] = temp_b;
63        }
64
65        // Eliminate
66        for k in (i + 1)..N {
67            let factor = a.get(k, i) / a.get(i, i);
68            for j in i..N {
69                let new_val = a.get(k, j) - factor * a.get(i, j);
70                a.set(k, j, new_val);
71            }
72            b[k] -= factor * b[i];
73        }
74    }
75
76    // Back substitution
77    let mut x = [0.0; N];
78    for i in (0..N).rev() {
79        let mut sum = 0.0;
80        for j in (i + 1)..N {
81            sum += a.get(i, j) * x[j];
82        }
83        x[i] = (b[i] - sum) / a.get(i, i);
84    }
85
86    Ok(x)
87}
88
89/// Zero-heap Jacobi eigenvalue algorithm for real symmetric matrices.
90/// Returns eigenvalues and eigenvectors.
91pub fn jacobi_diagonalization<const N: usize>(
92    matrix: &ZeroHeapMatrix<f64, N, N>,
93) -> Result<([f64; N], ZeroHeapMatrix<f64, N, N>), ScfError> {
94    let mut a = *matrix;
95    let mut v = ZeroHeapMatrix::<f64, N, N>::zeros();
96    for i in 0..N {
97        v.set(i, i, 1.0);
98    }
99
100    let max_sweeps = 50;
101    let eps = 1e-15;
102
103    for _sweep in 0..max_sweeps {
104        let mut max_off_diag: f64 = 0.0;
105
106        for p in 0..N {
107            for q in (p + 1)..N {
108                max_off_diag = f64::max(max_off_diag, a.get(p, q).abs());
109            }
110        }
111
112        if max_off_diag < eps {
113            let mut eigenvalues = [0.0; N];
114            for i in 0..N {
115                eigenvalues[i] = a.get(i, i);
116            }
117            // Sort eigenvalues and eigenvectors
118            for i in 0..N {
119                for j in (i + 1)..N {
120                    if eigenvalues[i] > eigenvalues[j] {
121                        let temp_val = eigenvalues[i];
122                        eigenvalues[i] = eigenvalues[j];
123                        eigenvalues[j] = temp_val;
124
125                        for k in 0..N {
126                            let temp_v = v.get(k, i);
127                            v.set(k, i, v.get(k, j));
128                            v.set(k, j, temp_v);
129                        }
130                    }
131                }
132            }
133            return Ok((eigenvalues, v));
134        }
135
136        for p in 0..N {
137            for q in (p + 1)..N {
138                let apq = a.get(p, q);
139                if apq.abs() > eps {
140                    let app = a.get(p, p);
141                    let aqq = a.get(q, q);
142                    let theta = 0.5 * (2.0 * apq).atan2(aqq - app);
143                    let c = theta.cos();
144                    let s = theta.sin();
145
146                    for i in 0..N {
147                        if i != p && i != q {
148                            let aip = a.get(i, p);
149                            let aiq = a.get(i, q);
150                            a.set(i, p, c * aip - s * aiq);
151                            a.set(p, i, a.get(i, p));
152
153                            a.set(i, q, s * aip + c * aiq);
154                            a.set(q, i, a.get(i, q));
155                        }
156
157                        let vip = v.get(i, p);
158                        let viq = v.get(i, q);
159                        v.set(i, p, c * vip - s * viq);
160                        v.set(i, q, s * vip + c * viq);
161                    }
162
163                    let a_pp_new = c * c * app - 2.0 * s * c * apq + s * s * aqq;
164                    let a_qq_new = s * s * app + 2.0 * s * c * apq + c * c * aqq;
165
166                    a.set(p, p, a_pp_new);
167                    a.set(q, q, a_qq_new);
168                    a.set(p, q, 0.0);
169                    a.set(q, p, 0.0);
170                }
171            }
172        }
173    }
174
175    Err(ScfError::InvalidEigenvalueDecomposition)
176}
177
178/// Helper function to perform matrix transposition on zero heap.
179pub fn transpose<const N: usize>(m: &ZeroHeapMatrix<f64, N, N>) -> ZeroHeapMatrix<f64, N, N> {
180    let mut out = ZeroHeapMatrix::<f64, N, N>::zeros();
181    for i in 0..N {
182        for j in 0..N {
183            out.set(i, j, m.get(j, i));
184        }
185    }
186    out
187}
188
189/// Calculate symmetric orthogonalization matrix X = S^(-1/2)
190pub fn orthogonalization_matrix<const N: usize>(
191    s: &ZeroHeapMatrix<f64, N, N>,
192) -> Result<ZeroHeapMatrix<f64, N, N>, ScfError> {
193    let (evals, evecs) = jacobi_diagonalization(s)?;
194
195    // Form s^(-1/2)
196    let mut d_inv_sqrt = ZeroHeapMatrix::<f64, N, N>::zeros();
197    for i in 0..N {
198        if evals[i] < 1e-12 {
199            // Drop linearly dependent basis functions or singular values
200            d_inv_sqrt.set(i, i, 0.0);
201        } else {
202            d_inv_sqrt.set(i, i, 1.0 / evals[i].sqrt());
203        }
204    }
205
206    // X = V * D^(-1/2) * V^T
207    let evecs_t = transpose(&evecs);
208    let x = evecs * d_inv_sqrt * evecs_t;
209    Ok(x)
210}
211
212/// Perform a full Restricted Hartree-Fock SCF iteration with DIIS
213pub fn solve_rhf_scf<const N: usize>(
214    h_core: &ZeroHeapMatrix<f64, N, N>, // One-electron Hamiltonian
215    s: &ZeroHeapMatrix<f64, N, N>,      // Overlap Matrix
216    eri: &ZeroHeapMatrix<f64, N, N>, // Two-electron repulsion integrals (mock 2D mapped for test)
217    num_electrons: usize,            // Total electrons
218) -> Result<f64, ScfError> {
219    let x = orthogonalization_matrix(s)?;
220    let x_t = transpose(&x);
221    let mut density = ZeroHeapMatrix::<f64, N, N>::zeros();
222    let mut old_energy = 0.0;
223
224    // DIIS History arrays (Zero heap constraint)
225    let mut error_vectors = [ZeroHeapMatrix::<f64, N, N>::zeros(); DIIS_SUBSPACE_SIZE];
226    let mut fock_history = [ZeroHeapMatrix::<f64, N, N>::zeros(); DIIS_SUBSPACE_SIZE];
227    let mut diis_count = 0;
228    let mut diis_index = 0;
229
230    for iter in 0..MAX_SCF_ITERATIONS {
231        // 1. Build Fock Matrix (F = H + G(P))
232        let mut fock = *h_core;
233        for mu in 0..N {
234            for nu in 0..N {
235                let mut g = 0.0;
236                for lam in 0..N {
237                    for sig in 0..N {
238                        // Normally this would index a 4D ERI tensor (mu, nu | lam, sig).
239                        // In this mock, we map it to 2D for demonstration by collapsing indices.
240                        let eri_val = eri.get((mu + lam) % N, (nu + sig) % N);
241                        // J - 0.5 * K (Coulomb - Exchange)
242                        g += density.get(lam, sig)
243                            * (eri_val - 0.5 * eri.get((mu + sig) % N, (nu + lam) % N));
244                    }
245                }
246                fock.set(mu, nu, fock.get(mu, nu) + g);
247            }
248        }
249
250        // 2. Compute DIIS error vector: e = FDS - SDF
251        let fds = fock * density * (*s);
252        let sdf = (*s) * density * fock;
253        let mut err_vec = ZeroHeapMatrix::<f64, N, N>::zeros();
254        for i in 0..N {
255            for j in 0..N {
256                err_vec.set(i, j, fds.get(i, j) - sdf.get(i, j));
257            }
258        }
259
260        // Transform error vector to orthogonal basis: e' = X^T * e * X
261        let err_ortho = x_t * err_vec * x;
262        let mut max_err: f64 = 0.0;
263        for i in 0..N {
264            for j in 0..N {
265                max_err = max_err.max(err_ortho.get(i, j).abs());
266            }
267        }
268
269        // 3. DIIS Extrapolation
270        fock_history[diis_index] = fock;
271        error_vectors[diis_index] = err_ortho;
272        if diis_count < DIIS_SUBSPACE_SIZE {
273            diis_count += 1;
274        }
275        diis_index = (diis_index + 1) % DIIS_SUBSPACE_SIZE;
276
277        let mut fock_extrapolated = fock;
278        if diis_count > 1 {
279            // Build Pulay matrix B
280            let _b_size = diis_count + 1;
281            let mut b_matrix = ZeroHeapMatrix::<
282                f64,
283                { DIIS_SUBSPACE_SIZE + 1 },
284                { DIIS_SUBSPACE_SIZE + 1 },
285            >::zeros();
286            for i in 0..diis_count {
287                for j in 0..diis_count {
288                    let mut dot = 0.0;
289                    for mu in 0..N {
290                        for nu in 0..N {
291                            dot += error_vectors[i].get(mu, nu) * error_vectors[j].get(mu, nu);
292                        }
293                    }
294                    b_matrix.set(i, j, dot);
295                }
296                b_matrix.set(i, diis_count, -1.0);
297                b_matrix.set(diis_count, i, -1.0);
298            }
299            b_matrix.set(diis_count, diis_count, 0.0);
300
301            let mut rhs = [0.0; DIIS_SUBSPACE_SIZE + 1];
302            rhs[diis_count] = -1.0;
303
304            // Use our Gaussian elimination to solve B * c = rhs
305            if let Ok(c) = gaussian_elimination(b_matrix, rhs) {
306                fock_extrapolated = ZeroHeapMatrix::<f64, N, N>::zeros();
307                for i in 0..diis_count {
308                    for mu in 0..N {
309                        for nu in 0..N {
310                            fock_extrapolated.set(
311                                mu,
312                                nu,
313                                fock_extrapolated.get(mu, nu) + c[i] * fock_history[i].get(mu, nu),
314                            );
315                        }
316                    }
317                }
318            }
319        }
320
321        // 4. Transform Fock matrix to orthogonal basis: F' = X^T * F * X
322        let f_prime = x_t * fock_extrapolated * x;
323
324        // 5. Diagonalize F' to get eigenvalues and C'
325        let (_evals, c_prime) = jacobi_diagonalization(&f_prime)?;
326
327        // 6. Back transform C' to original basis: C = X * C'
328        let c = x * c_prime;
329
330        // 7. Build new density matrix P = 2 * C_occ * C_occ^T
331        let mut new_density = ZeroHeapMatrix::<f64, N, N>::zeros();
332        let num_occ = num_electrons / 2;
333        for mu in 0..N {
334            for nu in 0..N {
335                let mut sum = 0.0;
336                for a in 0..num_occ {
337                    sum += c.get(mu, a) * c.get(nu, a);
338                }
339                new_density.set(mu, nu, 2.0 * sum);
340            }
341        }
342
343        // 8. Calculate Electronic Energy
344        let mut energy = 0.0;
345        for mu in 0..N {
346            for nu in 0..N {
347                energy += 0.5
348                    * new_density.get(mu, nu)
349                    * (h_core.get(mu, nu) + fock_extrapolated.get(mu, nu));
350            }
351        }
352
353        // Check both Energy and DIIS error convergence
354        if iter > 0 && (energy - old_energy).abs() < SCF_CONVERGENCE_THRESHOLD && max_err < 1e-6 {
355            return Ok(energy);
356        }
357        old_energy = energy;
358        density = new_density;
359    }
360
361    Err(ScfError::ConvergenceFailed)
362}
363
364/// Converged RHF result: the electronic energy plus everything a caller needs to
365/// compute post-SCF observables (orbital energies for HOMO/LUMO, the density and
366/// overlap-consistent MO coefficients for Mulliken populations and the dipole).
367#[derive(Debug, Clone, Copy)]
368pub struct RhfResult<const N: usize> {
369    /// Electronic energy E_elec = ½ Σ_μν P_μν (H_μν + F_μν), in Hartree.
370    pub electronic_energy: f64,
371    /// Orbital (eigen)energies ε, ascending. `orbital_energies[0..num_occ]` are
372    /// occupied, the rest virtual.
373    pub orbital_energies: [f64; N],
374    /// Converged density matrix P_μν = 2 Σ_a^occ C_μa C_νa.
375    pub density: ZeroHeapMatrix<f64, N, N>,
376    /// MO coefficients C in the original (non-orthogonal) AO basis.
377    pub coefficients: ZeroHeapMatrix<f64, N, N>,
378    /// Number of doubly-occupied orbitals.
379    pub num_occ: usize,
380    /// SCF iterations taken to converge.
381    pub iterations: usize,
382}
383
384/// Full Restricted Hartree-Fock SCF with a REAL 4-index two-electron contraction
385/// and DIIS acceleration.
386///
387/// The Fock build is the genuine
388///   G_μν = Σ_λσ P_λσ [ (μν|λσ) − ½ (μσ|λν) ]
389/// over the supplied 4-index ERI tensor `eri[μ][ν][λ][σ] = (μν|λσ)` in chemists'
390/// notation — not the 2-D index-collapse mock used by [`solve_rhf_scf`]. The core
391/// Hamiltonian `h_core = T + V_nuc`, overlap `s`, and the ERI tensor must all be
392/// assembled from real molecular integrals by the caller.
393///
394/// Returns the converged [`RhfResult`] (electronic energy only — add the nuclear
395/// repulsion for the total). Requires an even electron count (closed shell).
396pub fn solve_rhf_scf_4index<const N: usize>(
397    h_core: &ZeroHeapMatrix<f64, N, N>,
398    s: &ZeroHeapMatrix<f64, N, N>,
399    eri: &[[[[f64; N]; N]; N]; N],
400    num_electrons: usize,
401) -> Result<RhfResult<N>, ScfError> {
402    let x = orthogonalization_matrix(s)?;
403    let x_t = transpose(&x);
404    let mut density = ZeroHeapMatrix::<f64, N, N>::zeros();
405    let mut old_energy = 0.0;
406    let num_occ = num_electrons / 2;
407
408    let mut error_vectors = [ZeroHeapMatrix::<f64, N, N>::zeros(); DIIS_SUBSPACE_SIZE];
409    let mut fock_history = [ZeroHeapMatrix::<f64, N, N>::zeros(); DIIS_SUBSPACE_SIZE];
410    let mut diis_count = 0;
411    let mut diis_index = 0;
412
413    for iter in 0..MAX_SCF_ITERATIONS {
414        // 1. Build the Fock matrix F = H + G(P) with the TRUE 4-index contraction.
415        let mut fock = *h_core;
416        for mu in 0..N {
417            for nu in 0..N {
418                let mut g = 0.0;
419                for lam in 0..N {
420                    for sig in 0..N {
421                        // Coulomb (μν|λσ) minus half exchange (μσ|λν).
422                        let coulomb = eri[mu][nu][lam][sig];
423                        let exchange = eri[mu][sig][lam][nu];
424                        g += density.get(lam, sig) * (coulomb - 0.5 * exchange);
425                    }
426                }
427                fock.set(mu, nu, fock.get(mu, nu) + g);
428            }
429        }
430
431        // 2. DIIS error vector e = FDS − SDF, transformed to the orthogonal basis.
432        let fds = fock * density * (*s);
433        let sdf = (*s) * density * fock;
434        let mut err_vec = ZeroHeapMatrix::<f64, N, N>::zeros();
435        for i in 0..N {
436            for j in 0..N {
437                err_vec.set(i, j, fds.get(i, j) - sdf.get(i, j));
438            }
439        }
440        let err_ortho = x_t * err_vec * x;
441        let mut max_err: f64 = 0.0;
442        for i in 0..N {
443            for j in 0..N {
444                max_err = max_err.max(err_ortho.get(i, j).abs());
445            }
446        }
447
448        // 3. DIIS extrapolation of the Fock matrix.
449        fock_history[diis_index] = fock;
450        error_vectors[diis_index] = err_ortho;
451        if diis_count < DIIS_SUBSPACE_SIZE {
452            diis_count += 1;
453        }
454        diis_index = (diis_index + 1) % DIIS_SUBSPACE_SIZE;
455
456        let mut fock_extrapolated = fock;
457        if diis_count > 1 {
458            let mut b_matrix = ZeroHeapMatrix::<
459                f64,
460                { DIIS_SUBSPACE_SIZE + 1 },
461                { DIIS_SUBSPACE_SIZE + 1 },
462            >::zeros();
463            for i in 0..diis_count {
464                for j in 0..diis_count {
465                    let mut dot = 0.0;
466                    for mu in 0..N {
467                        for nu in 0..N {
468                            dot += error_vectors[i].get(mu, nu) * error_vectors[j].get(mu, nu);
469                        }
470                    }
471                    b_matrix.set(i, j, dot);
472                }
473                b_matrix.set(i, diis_count, -1.0);
474                b_matrix.set(diis_count, i, -1.0);
475            }
476            b_matrix.set(diis_count, diis_count, 0.0);
477
478            let mut rhs = [0.0; DIIS_SUBSPACE_SIZE + 1];
479            rhs[diis_count] = -1.0;
480
481            if let Ok(c) = gaussian_elimination(b_matrix, rhs) {
482                fock_extrapolated = ZeroHeapMatrix::<f64, N, N>::zeros();
483                for i in 0..diis_count {
484                    for mu in 0..N {
485                        for nu in 0..N {
486                            fock_extrapolated.set(
487                                mu,
488                                nu,
489                                fock_extrapolated.get(mu, nu) + c[i] * fock_history[i].get(mu, nu),
490                            );
491                        }
492                    }
493                }
494            }
495        }
496
497        // 4. F' = X^T F X, diagonalize, back-transform C = X C'.
498        let f_prime = x_t * fock_extrapolated * x;
499        let (evals, c_prime) = jacobi_diagonalization(&f_prime)?;
500        let c = x * c_prime;
501
502        // 5. New density P = 2 Σ_a^occ C_μa C_νa.
503        let mut new_density = ZeroHeapMatrix::<f64, N, N>::zeros();
504        for mu in 0..N {
505            for nu in 0..N {
506                let mut sum = 0.0;
507                for a in 0..num_occ {
508                    sum += c.get(mu, a) * c.get(nu, a);
509                }
510                new_density.set(mu, nu, 2.0 * sum);
511            }
512        }
513
514        // 6. Electronic energy E = ½ Σ P_μν (H_μν + F_μν) using the UN-extrapolated
515        //    consistent Fock for the current density.
516        let mut fock_for_energy = *h_core;
517        for mu in 0..N {
518            for nu in 0..N {
519                let mut g = 0.0;
520                for lam in 0..N {
521                    for sig in 0..N {
522                        let coulomb = eri[mu][nu][lam][sig];
523                        let exchange = eri[mu][sig][lam][nu];
524                        g += new_density.get(lam, sig) * (coulomb - 0.5 * exchange);
525                    }
526                }
527                fock_for_energy.set(mu, nu, fock_for_energy.get(mu, nu) + g);
528            }
529        }
530        let mut energy = 0.0;
531        for mu in 0..N {
532            for nu in 0..N {
533                energy += 0.5
534                    * new_density.get(mu, nu)
535                    * (h_core.get(mu, nu) + fock_for_energy.get(mu, nu));
536            }
537        }
538
539        if iter > 0 && (energy - old_energy).abs() < SCF_CONVERGENCE_THRESHOLD && max_err < 1e-6 {
540            return Ok(RhfResult {
541                electronic_energy: energy,
542                orbital_energies: evals,
543                density: new_density,
544                coefficients: c,
545                num_occ,
546                iterations: iter + 1,
547            });
548        }
549        old_energy = energy;
550        density = new_density;
551    }
552
553    Err(ScfError::ConvergenceFailed)
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_gaussian_elimination() {
562        let mut a = ZeroHeapMatrix::<f64, 2, 2>::zeros();
563        a.set(0, 0, 3.0);
564        a.set(0, 1, 2.0);
565        a.set(1, 0, 1.0);
566        a.set(1, 1, 4.0);
567        let b = [7.0, 9.0];
568
569        let x = gaussian_elimination(a, b).unwrap();
570        assert!((x[0] - 1.0).abs() < 1e-10); // x = 1
571        assert!((x[1] - 2.0).abs() < 1e-10); // y = 2
572    }
573
574    #[test]
575    fn test_jacobi_diagonalization() {
576        let mut a = ZeroHeapMatrix::<f64, 2, 2>::zeros();
577        a.set(0, 0, 2.0);
578        a.set(0, 1, 1.0);
579        a.set(1, 0, 1.0);
580        a.set(1, 1, 2.0);
581
582        let (evals, _) = jacobi_diagonalization(&a).unwrap();
583        // Eigenvalues of [[2, 1], [1, 2]] are 1 and 3.
584        // It sorts ascending, so 1.0 then 3.0
585        assert!((evals[0] - 1.0).abs() < 1e-10);
586        assert!((evals[1] - 3.0).abs() < 1e-10);
587    }
588
589    #[test]
590    fn test_rhf_scf_convergence() {
591        // Extremely simple H2 minimal basis mock
592        let mut h_core = ZeroHeapMatrix::<f64, 2, 2>::zeros();
593        h_core.set(0, 0, -1.1);
594        h_core.set(1, 1, -1.1);
595        h_core.set(0, 1, -0.9);
596        h_core.set(1, 0, -0.9);
597
598        let mut s = ZeroHeapMatrix::<f64, 2, 2>::zeros();
599        s.set(0, 0, 1.0);
600        s.set(1, 1, 1.0);
601        s.set(0, 1, 0.5);
602        s.set(1, 0, 0.5);
603
604        let eri = ZeroHeapMatrix::<f64, 2, 2>::zeros();
605        let energy = solve_rhf_scf(&h_core, &s, &eri, 2).expect("SCF should converge");
606
607        assert!(energy < 0.0);
608    }
609}