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/// Bond-order metrics for one atom pair.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct BondOrderEntry {
14    /// First atom index.
15    pub atom_i: usize,
16    /// Second atom index.
17    pub atom_j: usize,
18    /// Interatomic distance in Å.
19    pub distance: f64,
20    /// Wiberg-like bond index computed in a Löwdin-orthogonalized AO basis.
21    pub wiberg: f64,
22    /// Mayer-like bond index computed from the PS matrix.
23    pub mayer: f64,
24}
25
26/// Full bond-order analysis across all atom pairs.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct BondOrderResult {
29    /// Bond-order metrics for each unique atom pair.
30    pub bonds: Vec<BondOrderEntry>,
31    /// Number of atoms in the system.
32    pub num_atoms: usize,
33    /// Sum of Wiberg-like bond indices touching each atom.
34    pub wiberg_valence: Vec<f64>,
35    /// Sum of Mayer-like bond indices touching each atom.
36    pub mayer_valence: Vec<f64>,
37}
38
39/// Result of a population analysis.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct PopulationResult {
42    /// Mulliken partial charges per atom.
43    pub mulliken_charges: Vec<f64>,
44    /// Löwdin partial charges per atom.
45    pub lowdin_charges: Vec<f64>,
46    /// Mulliken gross orbital populations per AO.
47    pub mulliken_populations: Vec<f64>,
48    /// Number of atoms.
49    pub num_atoms: usize,
50    /// Total charge (should match net formal charge).
51    pub total_charge_mulliken: f64,
52    /// Total charge from Löwdin.
53    pub total_charge_lowdin: f64,
54}
55
56/// Build the density matrix P from MO coefficients and occupations.
57///
58/// P_{μν} = Σ_i n_i C_{μi} C_{νi}
59///
60/// - `coefficients`: rows = AO, cols = MO (same layout as EhtResult.coefficients)
61/// - `n_electrons`: total electrons (fills lowest MOs, 2 per orbital)
62pub(crate) fn build_density_matrix(coefficients: &[Vec<f64>], n_electrons: usize) -> DMatrix<f64> {
63    let n_ao = coefficients.len();
64    let n_occupied = n_electrons / 2;
65    let mut p = DMatrix::zeros(n_ao, n_ao);
66
67    for i in 0..n_occupied {
68        for mu in 0..n_ao {
69            for nu in 0..n_ao {
70                p[(mu, nu)] += 2.0 * coefficients[mu][i] * coefficients[nu][i];
71            }
72        }
73    }
74
75    // Handle odd electron (singly occupied HOMO)
76    if n_electrons % 2 == 1 {
77        let i = n_occupied;
78        for mu in 0..n_ao {
79            for nu in 0..n_ao {
80                p[(mu, nu)] += coefficients[mu][i] * coefficients[nu][i];
81            }
82        }
83    }
84
85    p
86}
87
88/// Count valence electrons for an element.
89fn valence_electrons(z: u8) -> f64 {
90    match z {
91        1 => 1.0,
92        5 => 3.0,
93        6 => 4.0,
94        7 => 5.0,
95        8 => 6.0,
96        9 => 7.0,
97        14 => 4.0,
98        15 => 5.0,
99        16 => 6.0,
100        17 => 7.0,
101        35 => 7.0,
102        53 => 7.0,
103        _ => 0.0,
104    }
105}
106
107/// Map each AO index to its parent atom index.
108pub(crate) fn ao_to_atom_map(basis: &[crate::eht::basis::AtomicOrbital]) -> Vec<usize> {
109    basis.iter().map(|ao| ao.atom_index).collect()
110}
111
112fn lowdin_orthogonalized_density(overlap: &DMatrix<f64>, density: &DMatrix<f64>) -> DMatrix<f64> {
113    let n_ao = overlap.nrows();
114    let s_eigen = SymmetricEigen::new(overlap.clone());
115    let mut s_sqrt_diag = DMatrix::zeros(n_ao, n_ao);
116    for i in 0..n_ao {
117        let val = s_eigen.eigenvalues[i];
118        if val > 1e-10 {
119            s_sqrt_diag[(i, i)] = val.sqrt();
120        }
121    }
122    let s_sqrt = &s_eigen.eigenvectors * &s_sqrt_diag * s_eigen.eigenvectors.transpose();
123    &s_sqrt * density * &s_sqrt
124}
125
126/// Compute Mulliken charges.
127///
128/// Mulliken population for AO μ: (PS)_{μμ}
129/// Atom charge: q_A = Z_A − Σ_{μ∈A} (PS)_{μμ}
130pub fn mulliken_charges(
131    elements: &[u8],
132    basis: &[crate::eht::basis::AtomicOrbital],
133    overlap: &DMatrix<f64>,
134    coefficients: &[Vec<f64>],
135    n_electrons: usize,
136) -> Vec<f64> {
137    let n_ao = basis.len();
138    let n_atoms = elements.len();
139    let ao_map = ao_to_atom_map(basis);
140    let p = build_density_matrix(coefficients, n_electrons);
141
142    // PS product
143    let ps = &p * overlap;
144
145    // Gross AO populations
146    let mut atom_pop = vec![0.0; n_atoms];
147    for mu in 0..n_ao {
148        atom_pop[ao_map[mu]] += ps[(mu, mu)];
149    }
150
151    // Charges = nuclear valence - electron population
152    (0..n_atoms)
153        .map(|a| valence_electrons(elements[a]) - atom_pop[a])
154        .collect()
155}
156
157/// Compute Löwdin charges.
158///
159/// Löwdin uses S^{1/2} P S^{1/2} instead of PS.
160/// q_A = Z_A − Σ_{μ∈A} (S^{1/2} P S^{1/2})_{μμ}
161pub fn lowdin_charges(
162    elements: &[u8],
163    basis: &[crate::eht::basis::AtomicOrbital],
164    overlap: &DMatrix<f64>,
165    coefficients: &[Vec<f64>],
166    n_electrons: usize,
167) -> Vec<f64> {
168    let n_ao = basis.len();
169    let n_atoms = elements.len();
170    let ao_map = ao_to_atom_map(basis);
171    let p = build_density_matrix(coefficients, n_electrons);
172
173    // Build S^{1/2}
174    let sps = lowdin_orthogonalized_density(overlap, &p);
175
176    let mut atom_pop = vec![0.0; n_atoms];
177    for mu in 0..n_ao {
178        atom_pop[ao_map[mu]] += sps[(mu, mu)];
179    }
180
181    (0..n_atoms)
182        .map(|a| valence_electrons(elements[a]) - atom_pop[a])
183        .collect()
184}
185
186/// Full population analysis: Mulliken + Löwdin in one pass.
187pub fn compute_population(
188    elements: &[u8],
189    positions: &[[f64; 3]],
190    coefficients: &[Vec<f64>],
191    n_electrons: usize,
192) -> PopulationResult {
193    let basis = crate::eht::basis::build_basis(elements, positions);
194    let overlap = crate::eht::build_overlap_matrix(&basis);
195    let n_ao = basis.len();
196    let n_atoms = elements.len();
197    let ao_map = ao_to_atom_map(&basis);
198    let p = build_density_matrix(coefficients, n_electrons);
199
200    // Mulliken: PS
201    let ps = &p * &overlap;
202    let mut mulliken_pop = vec![0.0; n_atoms];
203    let mut mulliken_ao_pop = vec![0.0; n_ao];
204    for mu in 0..n_ao {
205        mulliken_ao_pop[mu] = ps[(mu, mu)];
206        mulliken_pop[ao_map[mu]] += ps[(mu, mu)];
207    }
208    let mulliken_charges: Vec<f64> = (0..n_atoms)
209        .map(|a| valence_electrons(elements[a]) - mulliken_pop[a])
210        .collect();
211    let total_mulliken: f64 = mulliken_charges.iter().sum();
212
213    // Löwdin: S^{1/2} P S^{1/2}
214    let sps = lowdin_orthogonalized_density(&overlap, &p);
215    let mut lowdin_pop = vec![0.0; n_atoms];
216    for mu in 0..n_ao {
217        lowdin_pop[ao_map[mu]] += sps[(mu, mu)];
218    }
219    let lowdin_charges: Vec<f64> = (0..n_atoms)
220        .map(|a| valence_electrons(elements[a]) - lowdin_pop[a])
221        .collect();
222    let total_lowdin: f64 = lowdin_charges.iter().sum();
223
224    PopulationResult {
225        mulliken_charges,
226        lowdin_charges,
227        mulliken_populations: mulliken_ao_pop,
228        num_atoms: n_atoms,
229        total_charge_mulliken: total_mulliken,
230        total_charge_lowdin: total_lowdin,
231    }
232}
233
234/// Compute Wiberg-like and Mayer-like bond orders from an EHT density matrix.
235pub fn compute_bond_orders(
236    elements: &[u8],
237    positions: &[[f64; 3]],
238    coefficients: &[Vec<f64>],
239    n_electrons: usize,
240) -> BondOrderResult {
241    let basis = crate::eht::basis::build_basis(elements, positions);
242    let overlap = crate::eht::build_overlap_matrix(&basis);
243    let ao_map = ao_to_atom_map(&basis);
244    let density = build_density_matrix(coefficients, n_electrons);
245    let ps = &density * &overlap;
246    let p_orth = lowdin_orthogonalized_density(&overlap, &density);
247
248    let mut atom_aos = vec![Vec::new(); elements.len()];
249    for (ao_idx, &atom_idx) in ao_map.iter().enumerate() {
250        atom_aos[atom_idx].push(ao_idx);
251    }
252
253    let mut bonds = Vec::new();
254    let mut wiberg_valence = vec![0.0; elements.len()];
255    let mut mayer_valence = vec![0.0; elements.len()];
256
257    for atom_i in 0..elements.len() {
258        for atom_j in (atom_i + 1)..elements.len() {
259            let mut wiberg = 0.0;
260            let mut mayer = 0.0;
261
262            for &mu in &atom_aos[atom_i] {
263                for &nu in &atom_aos[atom_j] {
264                    let p_orth_mn = p_orth[(mu, nu)];
265                    wiberg += p_orth_mn * p_orth_mn;
266                    mayer += ps[(mu, nu)] * ps[(nu, mu)];
267                }
268            }
269
270            let dx = positions[atom_i][0] - positions[atom_j][0];
271            let dy = positions[atom_i][1] - positions[atom_j][1];
272            let dz = positions[atom_i][2] - positions[atom_j][2];
273            let distance = (dx * dx + dy * dy + dz * dz).sqrt();
274
275            wiberg_valence[atom_i] += wiberg;
276            wiberg_valence[atom_j] += wiberg;
277            mayer_valence[atom_i] += mayer;
278            mayer_valence[atom_j] += mayer;
279
280            bonds.push(BondOrderEntry {
281                atom_i,
282                atom_j,
283                distance,
284                wiberg,
285                mayer,
286            });
287        }
288    }
289
290    BondOrderResult {
291        bonds,
292        num_atoms: elements.len(),
293        wiberg_valence,
294        mayer_valence,
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::eht::solve_eht;
302
303    fn h2_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
304        (vec![1, 1], vec![[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]])
305    }
306
307    fn water_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
308        (
309            vec![8, 1, 1],
310            vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]],
311        )
312    }
313
314    #[test]
315    fn test_h2_symmetric_charges() {
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        // H₂ is symmetric → both atoms should have ~0 charge
321        assert!(
322            (pop.mulliken_charges[0] - pop.mulliken_charges[1]).abs() < 1e-6,
323            "H₂ Mulliken charges should be symmetric"
324        );
325        assert!(
326            pop.mulliken_charges[0].abs() < 0.01,
327            "H₂ charge should be ~0"
328        );
329    }
330
331    #[test]
332    fn test_water_oxygen_negative() {
333        let (elems, pos) = water_molecule();
334        let result = solve_eht(&elems, &pos, None).unwrap();
335        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
336
337        // Oxygen should be negative (more electronegative)
338        assert!(
339            pop.mulliken_charges[0] < 0.0,
340            "O in water should have negative Mulliken charge, got {}",
341            pop.mulliken_charges[0]
342        );
343        assert!(
344            pop.lowdin_charges[0] < 0.0,
345            "O in water should have negative Löwdin charge, got {}",
346            pop.lowdin_charges[0]
347        );
348    }
349
350    #[test]
351    fn test_charge_sum_conservation() {
352        let (elems, pos) = water_molecule();
353        let result = solve_eht(&elems, &pos, None).unwrap();
354        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
355
356        // Neutral molecule → charges sum ~ 0
357        assert!(
358            pop.total_charge_mulliken.abs() < 0.01,
359            "Mulliken total charge should be ~0, got {}",
360            pop.total_charge_mulliken
361        );
362        assert!(
363            pop.total_charge_lowdin.abs() < 0.01,
364            "Löwdin total charge should be ~0, got {}",
365            pop.total_charge_lowdin
366        );
367    }
368
369    #[test]
370    fn test_hydrogen_symmetry_in_water() {
371        let (elems, pos) = water_molecule();
372        let result = solve_eht(&elems, &pos, None).unwrap();
373        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
374
375        // Both H atoms should have identical charges (symmetric geometry)
376        assert!(
377            (pop.mulliken_charges[1] - pop.mulliken_charges[2]).abs() < 0.01,
378            "H charges in water should be symmetric: {} vs {}",
379            pop.mulliken_charges[1],
380            pop.mulliken_charges[2]
381        );
382    }
383
384    #[test]
385    fn test_lowdin_vs_mulliken_different() {
386        let (elems, pos) = water_molecule();
387        let result = solve_eht(&elems, &pos, None).unwrap();
388        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
389
390        // Löwdin and Mulliken typically give different charge magnitudes
391        // but same sign for electronegativity ordering
392        let m_o = pop.mulliken_charges[0];
393        let l_o = pop.lowdin_charges[0];
394        assert!(
395            m_o.signum() == l_o.signum(),
396            "Both methods should agree on sign for O"
397        );
398    }
399
400    #[test]
401    fn test_gross_orbital_populations_sum() {
402        let (elems, pos) = h2_molecule();
403        let result = solve_eht(&elems, &pos, None).unwrap();
404        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
405
406        // Sum of gross orbital populations should equal n_electrons
407        let total: f64 = pop.mulliken_populations.iter().sum();
408        assert!(
409            (total - result.n_electrons as f64).abs() < 0.01,
410            "AO pop sum {} should equal n_electrons {}",
411            total,
412            result.n_electrons
413        );
414    }
415
416    #[test]
417    fn test_h2_bond_order_is_positive() {
418        let (elems, pos) = h2_molecule();
419        let result = solve_eht(&elems, &pos, None).unwrap();
420        let bond_orders =
421            compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
422
423        assert_eq!(bond_orders.bonds.len(), 1);
424        assert!(bond_orders.bonds[0].wiberg > 0.1);
425        assert!(bond_orders.bonds[0].mayer > 0.1);
426    }
427
428    #[test]
429    fn test_water_oh_bonds_exceed_hh_bond_order() {
430        let (elems, pos) = water_molecule();
431        let result = solve_eht(&elems, &pos, None).unwrap();
432        let bond_orders =
433            compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
434
435        let oh_1 = bond_orders
436            .bonds
437            .iter()
438            .find(|bond| bond.atom_i == 0 && bond.atom_j == 1)
439            .unwrap();
440        let oh_2 = bond_orders
441            .bonds
442            .iter()
443            .find(|bond| bond.atom_i == 0 && bond.atom_j == 2)
444            .unwrap();
445        let hh = bond_orders
446            .bonds
447            .iter()
448            .find(|bond| bond.atom_i == 1 && bond.atom_j == 2)
449            .unwrap();
450
451        assert!(oh_1.wiberg > hh.wiberg);
452        assert!(oh_2.wiberg > hh.wiberg);
453        assert!(oh_1.mayer > hh.mayer);
454        assert!(oh_2.mayer > hh.mayer);
455    }
456}