Skip to main content

sci_form/population/
population.rs

1//! Mulliken and Löwdin population analysis.
2//!
3//! **Mulliken:** q_A = Z_A − Σ_{μ∈A} (PS)_{μμ}
4//! **Löwdin:**  q_A = Z_A − Σ_{μ∈A} (S^{1/2} P S^{1/2})_{μμ}
5//!
6//! where P is the density matrix P = Σ_i^{occ} n_i c_i c_i^T
7
8use nalgebra::{DMatrix, SymmetricEigen};
9use serde::{Deserialize, Serialize};
10
11/// Result of a population analysis.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PopulationResult {
14    /// Mulliken partial charges per atom.
15    pub mulliken_charges: Vec<f64>,
16    /// Löwdin partial charges per atom.
17    pub lowdin_charges: Vec<f64>,
18    /// Mulliken gross orbital populations per AO.
19    pub mulliken_populations: Vec<f64>,
20    /// Number of atoms.
21    pub num_atoms: usize,
22    /// Total charge (should match net formal charge).
23    pub total_charge_mulliken: f64,
24    /// Total charge from Löwdin.
25    pub total_charge_lowdin: f64,
26}
27
28/// Build the density matrix P from MO coefficients and occupations.
29///
30/// P_{μν} = Σ_i n_i C_{μi} C_{νi}
31///
32/// - `coefficients`: rows = AO, cols = MO (same layout as EhtResult.coefficients)
33/// - `n_electrons`: total electrons (fills lowest MOs, 2 per orbital)
34fn build_density_matrix(coefficients: &[Vec<f64>], n_electrons: usize) -> DMatrix<f64> {
35    let n_ao = coefficients.len();
36    let n_occupied = n_electrons / 2;
37    let mut p = DMatrix::zeros(n_ao, n_ao);
38
39    for i in 0..n_occupied {
40        for mu in 0..n_ao {
41            for nu in 0..n_ao {
42                p[(mu, nu)] += 2.0 * coefficients[mu][i] * coefficients[nu][i];
43            }
44        }
45    }
46
47    // Handle odd electron (singly occupied HOMO)
48    if n_electrons % 2 == 1 {
49        let i = n_occupied;
50        for mu in 0..n_ao {
51            for nu in 0..n_ao {
52                p[(mu, nu)] += coefficients[mu][i] * coefficients[nu][i];
53            }
54        }
55    }
56
57    p
58}
59
60/// Count valence electrons for an element.
61fn valence_electrons(z: u8) -> f64 {
62    match z {
63        1 => 1.0,
64        5 => 3.0,
65        6 => 4.0,
66        7 => 5.0,
67        8 => 6.0,
68        9 => 7.0,
69        14 => 4.0,
70        15 => 5.0,
71        16 => 6.0,
72        17 => 7.0,
73        35 => 7.0,
74        53 => 7.0,
75        _ => 0.0,
76    }
77}
78
79/// Map each AO index to its parent atom index.
80fn ao_to_atom_map(basis: &[crate::eht::basis::AtomicOrbital]) -> Vec<usize> {
81    basis.iter().map(|ao| ao.atom_index).collect()
82}
83
84/// Compute Mulliken charges.
85///
86/// Mulliken population for AO μ: (PS)_{μμ}
87/// Atom charge: q_A = Z_A − Σ_{μ∈A} (PS)_{μμ}
88pub fn mulliken_charges(
89    elements: &[u8],
90    basis: &[crate::eht::basis::AtomicOrbital],
91    overlap: &DMatrix<f64>,
92    coefficients: &[Vec<f64>],
93    n_electrons: usize,
94) -> Vec<f64> {
95    let n_ao = basis.len();
96    let n_atoms = elements.len();
97    let ao_map = ao_to_atom_map(basis);
98    let p = build_density_matrix(coefficients, n_electrons);
99
100    // PS product
101    let ps = &p * overlap;
102
103    // Gross AO populations
104    let mut atom_pop = vec![0.0; n_atoms];
105    for mu in 0..n_ao {
106        atom_pop[ao_map[mu]] += ps[(mu, mu)];
107    }
108
109    // Charges = nuclear valence - electron population
110    (0..n_atoms)
111        .map(|a| valence_electrons(elements[a]) - atom_pop[a])
112        .collect()
113}
114
115/// Compute Löwdin charges.
116///
117/// Löwdin uses S^{1/2} P S^{1/2} instead of PS.
118/// q_A = Z_A − Σ_{μ∈A} (S^{1/2} P S^{1/2})_{μμ}
119pub fn lowdin_charges(
120    elements: &[u8],
121    basis: &[crate::eht::basis::AtomicOrbital],
122    overlap: &DMatrix<f64>,
123    coefficients: &[Vec<f64>],
124    n_electrons: usize,
125) -> Vec<f64> {
126    let n_ao = basis.len();
127    let n_atoms = elements.len();
128    let ao_map = ao_to_atom_map(basis);
129    let p = build_density_matrix(coefficients, n_electrons);
130
131    // Build S^{1/2}
132    let s_eigen = SymmetricEigen::new(overlap.clone());
133    let mut s_sqrt_diag = DMatrix::zeros(n_ao, n_ao);
134    for i in 0..n_ao {
135        let val = s_eigen.eigenvalues[i];
136        if val > 1e-10 {
137            s_sqrt_diag[(i, i)] = val.sqrt();
138        }
139    }
140    let s_sqrt = &s_eigen.eigenvectors * &s_sqrt_diag * s_eigen.eigenvectors.transpose();
141
142    // S^{1/2} P S^{1/2}
143    let sps = &s_sqrt * &p * &s_sqrt;
144
145    let mut atom_pop = vec![0.0; n_atoms];
146    for mu in 0..n_ao {
147        atom_pop[ao_map[mu]] += sps[(mu, mu)];
148    }
149
150    (0..n_atoms)
151        .map(|a| valence_electrons(elements[a]) - atom_pop[a])
152        .collect()
153}
154
155/// Full population analysis: Mulliken + Löwdin in one pass.
156pub fn compute_population(
157    elements: &[u8],
158    positions: &[[f64; 3]],
159    coefficients: &[Vec<f64>],
160    n_electrons: usize,
161) -> PopulationResult {
162    let basis = crate::eht::basis::build_basis(elements, positions);
163    let overlap = crate::eht::build_overlap_matrix(&basis);
164    let n_ao = basis.len();
165    let n_atoms = elements.len();
166    let ao_map = ao_to_atom_map(&basis);
167    let p = build_density_matrix(coefficients, n_electrons);
168
169    // Mulliken: PS
170    let ps = &p * &overlap;
171    let mut mulliken_pop = vec![0.0; n_atoms];
172    let mut mulliken_ao_pop = vec![0.0; n_ao];
173    for mu in 0..n_ao {
174        mulliken_ao_pop[mu] = ps[(mu, mu)];
175        mulliken_pop[ao_map[mu]] += ps[(mu, mu)];
176    }
177    let mulliken_charges: Vec<f64> = (0..n_atoms)
178        .map(|a| valence_electrons(elements[a]) - mulliken_pop[a])
179        .collect();
180    let total_mulliken: f64 = mulliken_charges.iter().sum();
181
182    // Löwdin: S^{1/2} P S^{1/2}
183    let s_eigen = SymmetricEigen::new(overlap.clone());
184    let mut s_sqrt_diag = DMatrix::zeros(n_ao, n_ao);
185    for i in 0..n_ao {
186        let val = s_eigen.eigenvalues[i];
187        if val > 1e-10 {
188            s_sqrt_diag[(i, i)] = val.sqrt();
189        }
190    }
191    let s_sqrt = &s_eigen.eigenvectors * &s_sqrt_diag * s_eigen.eigenvectors.transpose();
192    let sps = &s_sqrt * &p * &s_sqrt;
193    let mut lowdin_pop = vec![0.0; n_atoms];
194    for mu in 0..n_ao {
195        lowdin_pop[ao_map[mu]] += sps[(mu, mu)];
196    }
197    let lowdin_charges: Vec<f64> = (0..n_atoms)
198        .map(|a| valence_electrons(elements[a]) - lowdin_pop[a])
199        .collect();
200    let total_lowdin: f64 = lowdin_charges.iter().sum();
201
202    PopulationResult {
203        mulliken_charges,
204        lowdin_charges,
205        mulliken_populations: mulliken_ao_pop,
206        num_atoms: n_atoms,
207        total_charge_mulliken: total_mulliken,
208        total_charge_lowdin: total_lowdin,
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::eht::solve_eht;
216
217    fn h2_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
218        (vec![1, 1], vec![[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]])
219    }
220
221    fn water_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
222        (
223            vec![8, 1, 1],
224            vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]],
225        )
226    }
227
228    #[test]
229    fn test_h2_symmetric_charges() {
230        let (elems, pos) = h2_molecule();
231        let result = solve_eht(&elems, &pos, None).unwrap();
232        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
233
234        // H₂ is symmetric → both atoms should have ~0 charge
235        assert!(
236            (pop.mulliken_charges[0] - pop.mulliken_charges[1]).abs() < 1e-6,
237            "H₂ Mulliken charges should be symmetric"
238        );
239        assert!(
240            pop.mulliken_charges[0].abs() < 0.01,
241            "H₂ charge should be ~0"
242        );
243    }
244
245    #[test]
246    fn test_water_oxygen_negative() {
247        let (elems, pos) = water_molecule();
248        let result = solve_eht(&elems, &pos, None).unwrap();
249        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
250
251        // Oxygen should be negative (more electronegative)
252        assert!(
253            pop.mulliken_charges[0] < 0.0,
254            "O in water should have negative Mulliken charge, got {}",
255            pop.mulliken_charges[0]
256        );
257        assert!(
258            pop.lowdin_charges[0] < 0.0,
259            "O in water should have negative Löwdin charge, got {}",
260            pop.lowdin_charges[0]
261        );
262    }
263
264    #[test]
265    fn test_charge_sum_conservation() {
266        let (elems, pos) = water_molecule();
267        let result = solve_eht(&elems, &pos, None).unwrap();
268        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
269
270        // Neutral molecule → charges sum ~ 0
271        assert!(
272            pop.total_charge_mulliken.abs() < 0.01,
273            "Mulliken total charge should be ~0, got {}",
274            pop.total_charge_mulliken
275        );
276        assert!(
277            pop.total_charge_lowdin.abs() < 0.01,
278            "Löwdin total charge should be ~0, got {}",
279            pop.total_charge_lowdin
280        );
281    }
282
283    #[test]
284    fn test_hydrogen_symmetry_in_water() {
285        let (elems, pos) = water_molecule();
286        let result = solve_eht(&elems, &pos, None).unwrap();
287        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
288
289        // Both H atoms should have identical charges (symmetric geometry)
290        assert!(
291            (pop.mulliken_charges[1] - pop.mulliken_charges[2]).abs() < 0.01,
292            "H charges in water should be symmetric: {} vs {}",
293            pop.mulliken_charges[1],
294            pop.mulliken_charges[2]
295        );
296    }
297
298    #[test]
299    fn test_lowdin_vs_mulliken_different() {
300        let (elems, pos) = water_molecule();
301        let result = solve_eht(&elems, &pos, None).unwrap();
302        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
303
304        // Löwdin and Mulliken typically give different charge magnitudes
305        // but same sign for electronegativity ordering
306        let m_o = pop.mulliken_charges[0];
307        let l_o = pop.lowdin_charges[0];
308        assert!(
309            m_o.signum() == l_o.signum(),
310            "Both methods should agree on sign for O"
311        );
312    }
313
314    #[test]
315    fn test_gross_orbital_populations_sum() {
316        let (elems, pos) = h2_molecule();
317        let result = solve_eht(&elems, &pos, None).unwrap();
318        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
319
320        // Sum of gross orbital populations should equal n_electrons
321        let total: f64 = pop.mulliken_populations.iter().sum();
322        assert!(
323            (total - result.n_electrons as f64).abs() < 0.01,
324            "AO pop sum {} should equal n_electrons {}",
325            total,
326            result.n_electrons
327        );
328    }
329}