Skip to main content

sci_form/scf/
density_matrix.rs

1//! Density matrix construction from MO coefficients.
2//!
3//! P_μν = 2 Σ_{k ∈ occ} C_μk C_νk
4//!
5//! The factor of 2 accounts for double occupancy in RHF (closed-shell).
6
7use nalgebra::DMatrix;
8
9/// Build the density matrix from MO coefficients.
10///
11/// P_μν = 2 Σ_{k=0}^{n_occ-1} C_μk · C_νk
12pub fn build_density_matrix(coefficients: &DMatrix<f64>, n_occupied: usize) -> DMatrix<f64> {
13    let n = coefficients.nrows();
14    let mut p = DMatrix::zeros(n, n);
15
16    for i in 0..n {
17        for j in 0..=i {
18            let mut p_ij = 0.0;
19            for k in 0..n_occupied {
20                p_ij += coefficients[(i, k)] * coefficients[(j, k)];
21            }
22            p_ij *= 2.0;
23            p[(i, j)] = p_ij;
24            p[(j, i)] = p_ij;
25        }
26    }
27
28    p
29}
30
31/// Build the density matrix with fractional occupation numbers (FON).
32///
33/// Useful for HOMO-LUMO near-degeneracy where integer occupation causes
34/// SCF oscillations. Applies a Fermi smearing around the HOMO-LUMO gap.
35///
36/// P_μν = Σ_k f_k · C_μk · C_νk  (f_k = Fermi occupation 0..2)
37pub fn build_density_matrix_fon(
38    coefficients: &DMatrix<f64>,
39    orbital_energies: &[f64],
40    n_electrons: usize,
41    temperature_au: f64,
42) -> DMatrix<f64> {
43    let n = coefficients.nrows();
44    let n_orb = orbital_energies.len();
45    let _n_occ = n_electrons / 2;
46
47    // Determine chemical potential (Fermi level) by bisection
48    let mu = find_fermi_level(orbital_energies, n_electrons, temperature_au);
49
50    // Compute fractional occupations
51    let occupations: Vec<f64> = orbital_energies
52        .iter()
53        .map(|&e| 2.0 * fermi_dirac(e, mu, temperature_au))
54        .collect();
55
56    let mut p = DMatrix::zeros(n, n);
57    for i in 0..n {
58        for j in 0..=i {
59            let mut p_ij = 0.0;
60            for k in 0..n_orb.min(n) {
61                p_ij += occupations[k] * coefficients[(i, k)] * coefficients[(j, k)];
62            }
63            p[(i, j)] = p_ij;
64            p[(j, i)] = p_ij;
65        }
66    }
67    p
68}
69
70fn fermi_dirac(energy: f64, mu: f64, temperature: f64) -> f64 {
71    if temperature < 1e-15 {
72        return if energy < mu {
73            1.0
74        } else if energy > mu {
75            0.0
76        } else {
77            0.5
78        };
79    }
80    let x = (energy - mu) / temperature;
81    if x > 50.0 {
82        0.0
83    } else if x < -50.0 {
84        1.0
85    } else {
86        1.0 / (1.0 + x.exp())
87    }
88}
89
90fn find_fermi_level(orbital_energies: &[f64], n_electrons: usize, temperature: f64) -> f64 {
91    let target = n_electrons as f64;
92    let mut mu_lo = orbital_energies
93        .iter()
94        .cloned()
95        .fold(f64::INFINITY, f64::min)
96        - 1.0;
97    let mut mu_hi = orbital_energies
98        .iter()
99        .cloned()
100        .fold(f64::NEG_INFINITY, f64::max)
101        + 1.0;
102
103    for _ in 0..100 {
104        let mu = 0.5 * (mu_lo + mu_hi);
105        let n: f64 = orbital_energies
106            .iter()
107            .map(|&e| 2.0 * fermi_dirac(e, mu, temperature))
108            .sum();
109        if n < target {
110            mu_lo = mu;
111        } else {
112            mu_hi = mu;
113        }
114        if (mu_hi - mu_lo).abs() < 1e-12 {
115            break;
116        }
117    }
118    0.5 * (mu_lo + mu_hi)
119}
120
121/// Compute the density matrix change (RMS difference).
122pub fn density_rms_change(p_new: &DMatrix<f64>, p_old: &DMatrix<f64>) -> f64 {
123    let n = p_new.nrows();
124    let diff = p_new - p_old;
125    let mut sum_sq = 0.0;
126    for i in 0..n {
127        for j in 0..n {
128            sum_sq += diff[(i, j)] * diff[(i, j)];
129        }
130    }
131    (sum_sq / (n * n) as f64).sqrt()
132}
133
134/// Compute the number of electrons from the density matrix.
135///
136/// N_e = Tr(PS) = Σ_μν P_μν S_μν
137pub fn electron_count(p: &DMatrix<f64>, s: &DMatrix<f64>) -> f64 {
138    let ps = p * s;
139    let mut trace = 0.0;
140    for i in 0..ps.nrows() {
141        trace += ps[(i, i)];
142    }
143    trace
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_density_matrix_symmetric() {
152        let c = DMatrix::from_row_slice(3, 3, &[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
153        let p = build_density_matrix(&c, 1);
154
155        for i in 0..3 {
156            for j in 0..3 {
157                assert!((p[(i, j)] - p[(j, i)]).abs() < 1e-14);
158            }
159        }
160    }
161
162    #[test]
163    fn test_density_trace() {
164        let c = DMatrix::identity(3, 3);
165        let p = build_density_matrix(&c, 2);
166        let s = DMatrix::identity(3, 3);
167        let n_e = electron_count(&p, &s);
168        assert!((n_e - 4.0).abs() < 1e-10);
169    }
170
171    #[test]
172    fn test_density_change() {
173        let p1 = DMatrix::identity(2, 2);
174        let mut p2 = DMatrix::identity(2, 2);
175        p2[(0, 0)] = 1.1;
176        let rms = density_rms_change(&p1, &p2);
177        assert!(rms > 0.0);
178    }
179
180    #[test]
181    fn test_fon_density_conserves_electrons() {
182        let c = DMatrix::identity(4, 4);
183        let energies = [-1.0, -0.5, 0.5, 1.0];
184        let n_electrons = 4; // 2 occupied orbs
185        let temp_au = 0.001; // very cold — should be close to integer occupation
186        let p = build_density_matrix_fon(&c, &energies, n_electrons, temp_au);
187        let s = DMatrix::identity(4, 4);
188        let n_e = electron_count(&p, &s);
189        assert!(
190            (n_e - 4.0).abs() < 0.1,
191            "FON should conserve ~4 electrons, got {n_e}"
192        );
193    }
194
195    #[test]
196    fn test_fon_at_zero_temp_matches_integer() {
197        let c = DMatrix::identity(3, 3);
198        let energies = [-1.0, -0.5, 0.5];
199        let p_fon = build_density_matrix_fon(&c, &energies, 2, 1e-20);
200        let p_int = build_density_matrix(&c, 1);
201        // At zero temperature, FON should match integer occupation
202        for i in 0..3 {
203            for j in 0..3 {
204                assert!(
205                    (p_fon[(i, j)] - p_int[(i, j)]).abs() < 1e-6,
206                    "FON(T→0) should match integer density at ({i},{j})"
207                );
208            }
209        }
210    }
211
212    #[test]
213    fn test_fon_high_temp_smears_occupation() {
214        let c = DMatrix::identity(3, 3);
215        let energies = [-0.1, 0.0, 0.1]; // near-degenerate
216        let p = build_density_matrix_fon(&c, &energies, 2, 1.0);
217        // All orbitals should have partial occupation — no P_ii should be exactly 0 or 2
218        for i in 0..3 {
219            assert!(p[(i, i)] > 0.01, "at high T, all orbitals get partial occ");
220            assert!(p[(i, i)] < 1.99, "at high T, no orbital is fully occupied");
221        }
222    }
223}