Skip to main content

sci_form/hf/
cis.rs

1//! Configuration Interaction Singles (CIS) for UV-Vis excitation energies.
2//!
3//! Promotes one electron from an occupied MO to a virtual MO, forming
4//! singly-excited determinants. The CIS Hamiltonian eigenvalues give
5//! vertical excitation energies, and eigenvectors yield oscillator strengths.
6//!
7//! $$H^{CIS}_{ia,jb} = \delta_{ij}\delta_{ab}(\varepsilon_a - \varepsilon_i)
8//!   + (ia|jb) - (ij|ab)$$
9
10use super::integrals::get_eri;
11use nalgebra::{DMatrix, SymmetricEigen};
12use serde::{Deserialize, Serialize};
13
14/// A single electronic excitation.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Excitation {
17    /// Excitation energy (Hartree).
18    pub energy: f64,
19    /// Excitation energy (eV).
20    pub energy_ev: f64,
21    /// Excitation wavelength (nm).
22    pub wavelength_nm: f64,
23    /// Oscillator strength (dimensionless).
24    pub oscillator_strength: f64,
25    /// Dominant occupied→virtual transition (orbital indices).
26    pub dominant_transition: (usize, usize),
27}
28
29/// CIS calculation result.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CisResult {
32    /// Electronic excitations, sorted by energy.
33    pub excitations: Vec<Excitation>,
34}
35
36/// Hartree to eV conversion.
37const HARTREE_TO_EV: f64 = 27.21138602;
38
39/// Compute CIS excitation energies from converged SCF data.
40///
41/// When `positions_bohr` and `basis_to_atom` are provided, computes real
42/// oscillator strengths using the monopole approximation for transition
43/// dipole moments. Otherwise falls back to a rough CI-vector norm estimate.
44pub fn compute_cis(
45    orbital_energies: &[f64],
46    coefficients: &DMatrix<f64>,
47    eris: &[f64],
48    n_basis: usize,
49    n_occupied: usize,
50    n_states: usize,
51) -> CisResult {
52    compute_cis_with_dipole(
53        orbital_energies,
54        coefficients,
55        eris,
56        n_basis,
57        n_occupied,
58        n_states,
59        None,
60        None,
61    )
62}
63
64/// CIS with optional dipole integral data for proper oscillator strengths.
65pub fn compute_cis_with_dipole(
66    orbital_energies: &[f64],
67    coefficients: &DMatrix<f64>,
68    eris: &[f64],
69    n_basis: usize,
70    n_occupied: usize,
71    n_states: usize,
72    positions_bohr: Option<&[[f64; 3]]>,
73    basis_to_atom: Option<&[usize]>,
74) -> CisResult {
75    let n_virtual = n_basis - n_occupied;
76    let n_singles = n_occupied * n_virtual;
77
78    if n_singles == 0 {
79        return CisResult {
80            excitations: Vec::new(),
81        };
82    }
83
84    // Build CIS Hamiltonian
85    let mut h_cis = DMatrix::zeros(n_singles, n_singles);
86
87    for ia in 0..n_singles {
88        let i = ia / n_virtual;
89        let a = ia % n_virtual + n_occupied;
90
91        for jb in 0..=ia {
92            let j = jb / n_virtual;
93            let b = jb % n_virtual + n_occupied;
94
95            let mut val = 0.0;
96
97            // Diagonal: orbital energy difference
98            if ia == jb {
99                val += orbital_energies[a] - orbital_energies[i];
100            }
101
102            // Two-electron integrals in MO basis (using AO ERIs + MO coeffs)
103            let coulomb = mo_eri(coefficients, eris, n_basis, i, a, j, b);
104            let exchange = mo_eri(coefficients, eris, n_basis, i, j, a, b);
105
106            val += coulomb - exchange;
107
108            h_cis[(ia, jb)] = val;
109            h_cis[(jb, ia)] = val;
110        }
111    }
112
113    // Diagonalize CIS Hamiltonian
114    let eigen = SymmetricEigen::new(h_cis);
115
116    // Sort and collect excitations
117    let mut indices: Vec<usize> = (0..n_singles).collect();
118    indices.sort_by(|&a, &b| {
119        eigen.eigenvalues[a]
120            .partial_cmp(&eigen.eigenvalues[b])
121            .unwrap()
122    });
123
124    let n_out = n_states.min(n_singles);
125    let mut excitations = Vec::with_capacity(n_out);
126
127    for &idx in indices.iter().take(n_out) {
128        let energy = eigen.eigenvalues[idx];
129        if energy <= 0.0 {
130            continue;
131        }
132
133        let energy_ev = energy * HARTREE_TO_EV;
134        let wavelength_nm = 1239.84193 / energy_ev;
135
136        // Find dominant transition
137        let col = eigen.eigenvectors.column(idx);
138        let (dom_idx, _) = col
139            .iter()
140            .enumerate()
141            .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
142            .unwrap();
143        let dom_i = dom_idx / n_virtual;
144        let dom_a = dom_idx % n_virtual + n_occupied;
145
146        // Oscillator strength via transition dipole moment
147        let f_osc = match (positions_bohr, basis_to_atom) {
148            (Some(pos), Some(b2a)) => {
149                // Monopole approximation: μ_0k = √2 Σ_ia X_ia Σ_A q_ia(A) R_A
150                let mut tdm = [0.0f64; 3];
151                for single in 0..n_singles {
152                    let i_s = single / n_virtual;
153                    let a_s = single % n_virtual + n_occupied;
154                    let x_ia = col[single];
155                    if x_ia.abs() < 1e-12 {
156                        continue;
157                    }
158                    // Compute transition charge on each atom
159                    let n_atoms = pos.len();
160                    let mut q_atom = vec![0.0f64; n_atoms];
161                    for mu in 0..n_basis {
162                        let atom = b2a[mu];
163                        let mut s_contrib = 0.0;
164                        for nu in 0..n_basis {
165                            s_contrib += coefficients[(nu, a_s)] * if mu == nu { 1.0 } else { 0.0 };
166                        }
167                        q_atom[atom] += coefficients[(mu, i_s)] * s_contrib;
168                    }
169                    for atom in 0..n_atoms {
170                        tdm[0] += x_ia * q_atom[atom] * pos[atom][0];
171                        tdm[1] += x_ia * q_atom[atom] * pos[atom][1];
172                        tdm[2] += x_ia * q_atom[atom] * pos[atom][2];
173                    }
174                }
175                let sqrt2 = std::f64::consts::SQRT_2;
176                tdm[0] *= sqrt2;
177                tdm[1] *= sqrt2;
178                tdm[2] *= sqrt2;
179                let tdm_sq = tdm[0] * tdm[0] + tdm[1] * tdm[1] + tdm[2] * tdm[2];
180                2.0 / 3.0 * energy * tdm_sq
181            }
182            _ => {
183                // Fallback: rough estimate using CI vector norm (always ~2/3 * E)
184                2.0 / 3.0 * energy * transition_dipole_sq(col.as_slice(), n_occupied, n_virtual)
185            }
186        };
187
188        excitations.push(Excitation {
189            energy,
190            energy_ev,
191            wavelength_nm,
192            oscillator_strength: f_osc,
193            dominant_transition: (dom_i, dom_a),
194        });
195    }
196
197    CisResult { excitations }
198}
199
200/// Compute MO-basis ERI from AO-basis ERIs using half-transformed intermediates.
201///
202/// Standard 4-index transformation: (pq|rs) = Σ C_μp C_νq C_λr C_σs (μν|λσ).
203/// Uses intermediate contraction to reduce O(N⁵) per integral to O(N⁴) total via
204/// sequential index transformation, with coefficient screening for sparsity.
205fn mo_eri(c: &DMatrix<f64>, eris: &[f64], n: usize, p: usize, q: usize, r: usize, s: usize) -> f64 {
206    // First half-transform: contract σ → s to get (μν|λs)
207    let mut half1 = vec![0.0f64; n * n * n]; // [mu][nu][lam]
208    for lam in 0..n {
209        for sig in 0..n {
210            let c_sig_s = c[(sig, s)];
211            if c_sig_s.abs() < 1e-12 {
212                continue;
213            }
214            for mu in 0..n {
215                for nu in 0..n {
216                    half1[mu * n * n + nu * n + lam] +=
217                        c_sig_s * get_eri(eris, mu, nu, lam, sig, n);
218                }
219            }
220        }
221    }
222
223    // Second half-transform: contract λ → r to get (μν|rs)
224    let mut half2 = vec![0.0f64; n * n]; // [mu][nu]
225    for lam in 0..n {
226        let c_lam_r = c[(lam, r)];
227        if c_lam_r.abs() < 1e-12 {
228            continue;
229        }
230        for mu in 0..n {
231            for nu in 0..n {
232                half2[mu * n + nu] += c_lam_r * half1[mu * n * n + nu * n + lam];
233            }
234        }
235    }
236
237    // Third quarter-transform: contract ν → q, then μ → p
238    let mut val = 0.0;
239    for mu in 0..n {
240        let c_mu_p = c[(mu, p)];
241        if c_mu_p.abs() < 1e-12 {
242            continue;
243        }
244        for nu in 0..n {
245            let c_nu_q = c[(nu, q)];
246            if c_nu_q.abs() < 1e-12 {
247                continue;
248            }
249            val += c_mu_p * c_nu_q * half2[mu * n + nu];
250        }
251    }
252    val
253}
254
255/// Simplified transition dipole squared estimate.
256fn transition_dipole_sq(cis_vec: &[f64], _n_occ: usize, _n_virt: usize) -> f64 {
257    // Approximate: sum of squared coefficients as proxy
258    cis_vec.iter().map(|c| c * c).sum::<f64>()
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_cis_dimensions() {
267        let n_occ = 2;
268        let n_virt = 3;
269        let n_basis = n_occ + n_virt;
270        let n_singles = n_occ * n_virt;
271
272        // With dummy data, CIS should produce excitations
273        let energies = vec![-2.0, -1.0, 0.5, 1.0, 1.5];
274        let coeffs = DMatrix::identity(n_basis, n_basis);
275        let eris = vec![0.0; n_basis * (n_basis + 1) / 2 * (n_basis * (n_basis + 1) / 2 + 1) / 2];
276
277        let result = compute_cis(&energies, &coeffs, &eris, n_basis, n_occ, 3);
278        assert!(
279            result.excitations.len() <= n_singles,
280            "CIS states ≤ n_singles"
281        );
282    }
283}