Skip to main content

sci_form/hf/
scf.rs

1//! Self-Consistent Field (SCF) solver with DIIS acceleration.
2//!
3//! Solves the Roothaan-Hall equations iteratively:
4//! $$\mathbf{FC} = \mathbf{SC}\boldsymbol{\varepsilon}$$
5//!
6//! Uses Löwdin orthogonalization ($S^{-1/2}$) to transform the generalized
7//! eigenvalue problem into a standard one, then applies DIIS for convergence.
8
9use super::fock::{build_fock, electronic_energy};
10use nalgebra::{DMatrix, DVector, SymmetricEigen};
11
12#[cfg(feature = "experimental-gpu")]
13fn matrix_to_row_major(matrix: &DMatrix<f64>) -> Vec<f64> {
14    let nrows = matrix.nrows();
15    let ncols = matrix.ncols();
16    let mut data = Vec::with_capacity(nrows * ncols);
17    for row in 0..nrows {
18        for col in 0..ncols {
19            data.push(matrix[(row, col)]);
20        }
21    }
22    data
23}
24
25#[cfg(feature = "experimental-gpu")]
26fn expand_packed_eris(eris: &[f64], n_basis: usize) -> Vec<f64> {
27    let mut full = vec![0.0; n_basis * n_basis * n_basis * n_basis];
28    for mu in 0..n_basis {
29        for nu in 0..n_basis {
30            for lam in 0..n_basis {
31                for sig in 0..n_basis {
32                    let packed = super::integrals::get_eri(eris, mu, nu, lam, sig, n_basis);
33                    let idx = mu * n_basis * n_basis * n_basis
34                        + nu * n_basis * n_basis
35                        + lam * n_basis
36                        + sig;
37                    full[idx] = packed;
38                }
39            }
40        }
41    }
42    full
43}
44
45/// SCF convergence result.
46///
47/// Contains converged matrices (density, MO coefficients) and derived
48/// scalars. The `DMatrix<f64>` fields prevent `Serialize`/`Deserialize`;
49/// use `Hf3cResult` for the serializable public API.
50#[derive(Debug, Clone)]
51pub struct ScfResult {
52    /// Converged electronic energy (Hartree).
53    pub energy: f64,
54    /// Orbital energies (eigenvalues).
55    pub orbital_energies: Vec<f64>,
56    /// MO coefficient matrix C (columns are MOs).
57    pub coefficients: DMatrix<f64>,
58    /// Converged density matrix P.
59    pub density: DMatrix<f64>,
60    /// Number of SCF iterations.
61    pub iterations: usize,
62    /// Whether SCF converged.
63    pub converged: bool,
64}
65
66/// SCF solver configuration.
67pub struct ScfConfig {
68    pub max_iter: usize,
69    pub energy_threshold: f64,
70    pub density_threshold: f64,
71    pub diis_size: usize,
72    /// Level shift for virtual orbitals (Hartree). 0.0 = disabled.
73    /// Typical values: 0.1-0.5 Eh for difficult convergence.
74    pub level_shift: f64,
75}
76
77impl Default for ScfConfig {
78    fn default() -> Self {
79        ScfConfig {
80            max_iter: 100,
81            energy_threshold: 1e-8,
82            density_threshold: 1e-6,
83            diis_size: 6,
84            level_shift: 0.0,
85        }
86    }
87}
88
89/// Run the SCF procedure.
90pub fn solve_scf(
91    h_core: &DMatrix<f64>,
92    s_mat: &DMatrix<f64>,
93    eris: &[f64],
94    _gpu_eris_full: Option<&[f64]>,
95    n_electrons: usize,
96    config: &ScfConfig,
97) -> ScfResult {
98    let n = h_core.nrows();
99    let n_occ = n_electrons / 2;
100
101    #[cfg(feature = "experimental-gpu")]
102    let gpu_ctx = if n >= 4 {
103        crate::gpu::context::GpuContext::try_create().ok()
104    } else {
105        None
106    };
107
108    #[cfg(feature = "experimental-gpu")]
109    let h_core_row_major = matrix_to_row_major(h_core);
110
111    #[cfg(feature = "experimental-gpu")]
112    let eris_full = _gpu_eris_full
113        .map(|tensor| tensor.to_vec())
114        .or_else(|| gpu_ctx.as_ref().map(|_| expand_packed_eris(eris, n)));
115
116    // Löwdin orthogonalization: S^{-1/2}
117    let s_half_inv = lowdin_orthogonalization(s_mat);
118
119    // Initial guess: diagonalize H_core
120    let (mut energies, mut coeffs) = diagonalize_fock(h_core, &s_half_inv);
121    let mut density = build_density(&coeffs, n_occ);
122
123    let mut prev_energy = 0.0;
124    let mut converged = false;
125    let mut iterations = 0;
126
127    // DIIS storage
128    let mut diis_focks: Vec<DMatrix<f64>> = Vec::new();
129    let mut diis_errors: Vec<DMatrix<f64>> = Vec::new();
130
131    let mut min_error_norm = f64::MAX;
132    let mut consecutive_increases = 0u32;
133
134    for iter in 0..config.max_iter {
135        iterations = iter + 1;
136
137        let fock = {
138            #[cfg(feature = "experimental-gpu")]
139            {
140                if let (Some(ctx), Some(eri_tensor)) = (gpu_ctx.as_ref(), eris_full.as_ref()) {
141                    let density_row_major = matrix_to_row_major(&density);
142                    if let Ok(fock_flat) = crate::gpu::fock_build_gpu::build_fock_gpu(
143                        ctx,
144                        &h_core_row_major,
145                        &density_row_major,
146                        eri_tensor,
147                        n,
148                    ) {
149                        DMatrix::from_row_slice(n, n, &fock_flat)
150                    } else {
151                        build_fock(h_core, &density, eris, n)
152                    }
153                } else {
154                    build_fock(h_core, &density, eris, n)
155                }
156            }
157
158            #[cfg(not(feature = "experimental-gpu"))]
159            {
160                build_fock(h_core, &density, eris, n)
161            }
162        };
163        let energy = electronic_energy(&density, h_core, &fock);
164
165        // DIIS error: FPS - SPF
166        let error = &fock * &density * s_mat - s_mat * &density * &fock;
167        let error_norm = error.iter().map(|v| v * v).sum::<f64>().sqrt();
168
169        // Robust DIIS reset: require 3 consecutive error increases before resetting,
170        // rather than a single large jump (which can occur during normal oscillations).
171        if error_norm > min_error_norm * 1.5 {
172            consecutive_increases += 1;
173        } else {
174            consecutive_increases = 0;
175        }
176        if consecutive_increases >= 3 && diis_focks.len() > 2 {
177            diis_focks.clear();
178            diis_errors.clear();
179            consecutive_increases = 0;
180        }
181        if error_norm < min_error_norm {
182            min_error_norm = error_norm;
183        }
184
185        // DIIS extrapolation
186        diis_focks.push(fock.clone());
187        diis_errors.push(error);
188        if diis_focks.len() > config.diis_size {
189            diis_focks.remove(0);
190            diis_errors.remove(0);
191        }
192
193        let fock_diis = if diis_focks.len() >= 2 {
194            diis_extrapolate(&diis_focks, &diis_errors)
195        } else {
196            fock
197        };
198
199        // Apply level shifting to virtual orbitals if configured
200        let fock_shifted = if config.level_shift > 0.0 && n_occ < n {
201            // Level shifting: add shift to virtual orbital block of Fock matrix
202            // in the orthogonal basis. This helps convergence for metallic systems.
203            let f_orth = s_half_inv.transpose() * &fock_diis * &s_half_inv;
204            let eigen = SymmetricEigen::new(f_orth);
205            let mut shifted_evals = eigen.eigenvalues.clone();
206            let mut idx_sorted: Vec<usize> = (0..n).collect();
207            idx_sorted.sort_by(|&a, &b| shifted_evals[a].partial_cmp(&shifted_evals[b]).unwrap());
208            for &idx in idx_sorted.iter().skip(n_occ) {
209                shifted_evals[idx] += config.level_shift;
210            }
211            let v = &eigen.eigenvectors;
212            let d = DMatrix::from_diagonal(&shifted_evals);
213            let f_shifted = v * d * v.transpose();
214            // Transform back to AO basis: F_AO = S^{1/2} * F_orth * S^{1/2}^T
215            // But for diagonalize_fock, it transforms again, so keep in AO
216            &s_half_inv * f_shifted * s_half_inv.transpose()
217        } else {
218            fock_diis
219        };
220
221        let (new_energies, new_coeffs) = diagonalize_fock(&fock_shifted, &s_half_inv);
222        let new_density = build_density(&new_coeffs, n_occ);
223
224        let de = (energy - prev_energy).abs();
225
226        if de < config.energy_threshold && error_norm < config.density_threshold {
227            converged = true;
228            energies = new_energies;
229            coeffs = new_coeffs;
230            density = new_density;
231            break;
232        }
233
234        prev_energy = energy;
235        energies = new_energies;
236        coeffs = new_coeffs;
237        density = new_density;
238    }
239
240    let final_energy = electronic_energy(&density, h_core, &build_fock(h_core, &density, eris, n));
241
242    ScfResult {
243        energy: final_energy,
244        orbital_energies: energies.as_slice().to_vec(),
245        coefficients: coeffs,
246        density,
247        iterations,
248        converged,
249    }
250}
251
252fn lowdin_orthogonalization(s: &DMatrix<f64>) -> DMatrix<f64> {
253    let eigen = SymmetricEigen::new(s.clone());
254    let n = s.nrows();
255    let mut s_inv_half = DMatrix::zeros(n, n);
256
257    let max_val = eigen
258        .eigenvalues
259        .iter()
260        .copied()
261        .fold(0.0f64, |a, b| a.max(b));
262    let threshold = max_val * 1e-10;
263
264    for i in 0..n {
265        let val = eigen.eigenvalues[i];
266        if val > threshold {
267            let factor = 1.0 / val.sqrt();
268            let col = eigen.eigenvectors.column(i);
269            s_inv_half += factor * col * col.transpose();
270        }
271    }
272    s_inv_half
273}
274
275fn diagonalize_fock(
276    fock: &DMatrix<f64>,
277    s_half_inv: &DMatrix<f64>,
278) -> (DVector<f64>, DMatrix<f64>) {
279    let f_prime = s_half_inv.transpose() * fock * s_half_inv;
280    let eigen = SymmetricEigen::new(f_prime);
281
282    // Sort eigenvalues/vectors by energy
283    let n = eigen.eigenvalues.len();
284    let mut indices: Vec<usize> = (0..n).collect();
285    indices.sort_by(|&a, &b| {
286        eigen.eigenvalues[a]
287            .partial_cmp(&eigen.eigenvalues[b])
288            .unwrap()
289    });
290
291    let sorted_energies = DVector::from_fn(n, |i, _| eigen.eigenvalues[indices[i]]);
292    let sorted_vecs = DMatrix::from_fn(n, n, |r, c| eigen.eigenvectors[(r, indices[c])]);
293
294    // Back-transform: C = S^{-1/2} C'
295    let coeffs = s_half_inv * sorted_vecs;
296    (sorted_energies, coeffs)
297}
298
299fn build_density(coeffs: &DMatrix<f64>, n_occ: usize) -> DMatrix<f64> {
300    let n = coeffs.nrows();
301    let mut density = DMatrix::zeros(n, n);
302    for i in 0..n_occ {
303        let col = coeffs.column(i);
304        density += 2.0 * &col * col.transpose();
305    }
306    density
307}
308
309fn diis_extrapolate(focks: &[DMatrix<f64>], errors: &[DMatrix<f64>]) -> DMatrix<f64> {
310    let m = errors.len();
311    let mut b = DMatrix::zeros(m + 1, m + 1);
312
313    for i in 0..m {
314        for j in 0..=i {
315            let bij: f64 = errors[i]
316                .iter()
317                .zip(errors[j].iter())
318                .map(|(a, b)| a * b)
319                .sum();
320            b[(i, j)] = bij;
321            b[(j, i)] = bij;
322        }
323    }
324    for i in 0..m {
325        b[(m, i)] = -1.0;
326        b[(i, m)] = -1.0;
327    }
328
329    let mut rhs = DVector::zeros(m + 1);
330    rhs[m] = -1.0;
331
332    // Solve B·c = rhs using pseudo-inverse
333    let svd = b.svd(true, true);
334    let c = match svd.solve(&rhs, 1e-10) {
335        Ok(c) => c,
336        Err(_) => {
337            // Fallback: use latest Fock
338            return focks.last().unwrap().clone();
339        }
340    };
341
342    // Guard against ill-conditioned DIIS: if any coefficient is too large,
343    // the extrapolation is unreliable (undershoot/overshoot). Fall back.
344    let max_coeff = c.iter().take(m).map(|v| v.abs()).fold(0.0f64, f64::max);
345    if max_coeff > 10.0 {
346        return focks.last().unwrap().clone();
347    }
348
349    let mut f_diis = DMatrix::zeros(focks[0].nrows(), focks[0].ncols());
350    for i in 0..m {
351        f_diis += c[i] * &focks[i];
352    }
353    f_diis
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_lowdin_identity() {
362        let s = DMatrix::identity(3, 3);
363        let s_inv = lowdin_orthogonalization(&s);
364        for i in 0..3 {
365            for j in 0..3 {
366                let expected = if i == j { 1.0 } else { 0.0 };
367                assert!(
368                    (s_inv[(i, j)] - expected).abs() < 1e-10,
369                    "S^{{-1/2}} of identity should be identity"
370                );
371            }
372        }
373    }
374}