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///
41/// Contains Mulliken and Löwdin charges plus a charge-conservation check.
42/// `charge_conservation_error` gives the absolute deviation of the
43/// Mulliken total charge from the expected net charge. A value above
44/// ~0.01 e suggests a basis-set or occupancy issue.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct PopulationResult {
47    /// Mulliken partial charges per atom.
48    pub mulliken_charges: Vec<f64>,
49    /// Löwdin partial charges per atom.
50    pub lowdin_charges: Vec<f64>,
51    /// Mulliken gross orbital populations per AO.
52    pub mulliken_populations: Vec<f64>,
53    /// Number of atoms.
54    pub num_atoms: usize,
55    /// Total charge (should match net formal charge).
56    pub total_charge_mulliken: f64,
57    /// Total charge from Löwdin.
58    pub total_charge_lowdin: f64,
59    /// Absolute deviation of Mulliken total charge from integer expectation.
60    /// Small values (~0) indicate proper charge conservation.
61    pub charge_conservation_error: f64,
62}
63
64/// Build the density matrix P from MO coefficients and occupations.
65///
66/// P_{μν} = Σ_i n_i C_{μi} C_{νi}
67///
68/// - `coefficients`: rows = AO, cols = MO (same layout as EhtResult.coefficients)
69/// - `n_electrons`: total electrons (fills lowest MOs, 2 per orbital)
70pub(crate) fn build_density_matrix(coefficients: &[Vec<f64>], n_electrons: usize) -> DMatrix<f64> {
71    let n_ao = coefficients.len();
72    let n_occupied = n_electrons / 2;
73    let mut p = DMatrix::zeros(n_ao, n_ao);
74
75    for i in 0..n_occupied {
76        for mu in 0..n_ao {
77            for nu in 0..n_ao {
78                p[(mu, nu)] += 2.0 * coefficients[mu][i] * coefficients[nu][i];
79            }
80        }
81    }
82
83    // Handle odd electron (singly occupied HOMO)
84    if n_electrons % 2 == 1 {
85        let i = n_occupied;
86        for mu in 0..n_ao {
87            for nu in 0..n_ao {
88                p[(mu, nu)] += coefficients[mu][i] * coefficients[nu][i];
89            }
90        }
91    }
92
93    p
94}
95
96/// Count valence electrons for an element.
97/// Effective valence electron count for EHT-based population analysis.
98pub(crate) fn valence_electrons(z: u8) -> f64 {
99    match z {
100        // Period 1
101        1 => 1.0, // H
102        2 => 2.0, // He
103        // Period 2
104        3 => 1.0,  // Li
105        4 => 2.0,  // Be
106        5 => 3.0,  // B
107        6 => 4.0,  // C
108        7 => 5.0,  // N
109        8 => 6.0,  // O
110        9 => 7.0,  // F
111        10 => 8.0, // Ne
112        // Period 3
113        11 => 1.0, // Na
114        12 => 2.0, // Mg
115        13 => 3.0, // Al
116        14 => 4.0, // Si
117        15 => 5.0, // P
118        16 => 6.0, // S
119        17 => 7.0, // Cl
120        18 => 8.0, // Ar
121        // Period 4 main-group
122        19 => 1.0, // K
123        20 => 2.0, // Ca
124        31 => 3.0, // Ga
125        32 => 4.0, // Ge
126        33 => 5.0, // As
127        34 => 6.0, // Se
128        35 => 7.0, // Br
129        36 => 8.0, // Kr
130        // Period 5 main-group
131        37 => 1.0, // Rb
132        38 => 2.0, // Sr
133        49 => 3.0, // In
134        50 => 4.0, // Sn
135        51 => 5.0, // Sb
136        52 => 6.0, // Te
137        53 => 7.0, // I
138        54 => 8.0, // Xe
139        // 3d transition metals (valence = 4s + 3d electrons)
140        21 => 3.0,  // Sc
141        22 => 4.0,  // Ti
142        23 => 5.0,  // V
143        24 => 6.0,  // Cr
144        25 => 7.0,  // Mn
145        26 => 8.0,  // Fe
146        27 => 9.0,  // Co
147        28 => 10.0, // Ni
148        29 => 11.0, // Cu
149        30 => 12.0, // Zn
150        // 4d transition metals
151        39 => 3.0,  // Y
152        40 => 4.0,  // Zr
153        41 => 5.0,  // Nb
154        42 => 6.0,  // Mo
155        43 => 7.0,  // Tc
156        44 => 8.0,  // Ru
157        45 => 9.0,  // Rh
158        46 => 10.0, // Pd
159        47 => 11.0, // Ag
160        48 => 12.0, // Cd
161        // 5d transition metals
162        72 => 4.0,  // Hf
163        73 => 5.0,  // Ta
164        74 => 6.0,  // W
165        75 => 7.0,  // Re
166        76 => 8.0,  // Os
167        77 => 9.0,  // Ir
168        78 => 10.0, // Pt
169        79 => 11.0, // Au
170        80 => 12.0, // Hg
171        // Period 6 main-group
172        81 => 3.0, // Tl
173        82 => 4.0, // Pb
174        83 => 5.0, // Bi
175        _ => 0.0,
176    }
177}
178
179/// Map each AO index to its parent atom index.
180pub(crate) fn ao_to_atom_map(basis: &[crate::eht::basis::AtomicOrbital]) -> Vec<usize> {
181    basis.iter().map(|ao| ao.atom_index).collect()
182}
183
184fn lowdin_orthogonalized_density(overlap: &DMatrix<f64>, density: &DMatrix<f64>) -> DMatrix<f64> {
185    let n_ao = overlap.nrows();
186    let s_eigen = SymmetricEigen::new(overlap.clone());
187    let mut s_sqrt_diag = DMatrix::zeros(n_ao, n_ao);
188    for i in 0..n_ao {
189        let val = s_eigen.eigenvalues[i];
190        if val > 1e-10 {
191            s_sqrt_diag[(i, i)] = val.sqrt();
192        }
193    }
194    let s_sqrt = &s_eigen.eigenvectors * &s_sqrt_diag * s_eigen.eigenvectors.transpose();
195    &s_sqrt * density * &s_sqrt
196}
197
198/// Compute Mulliken charges.
199///
200/// Mulliken population for AO μ: (PS)_{μμ}
201/// Atom charge: q_A = Z_A − Σ_{μ∈A} (PS)_{μμ}
202pub fn mulliken_charges(
203    elements: &[u8],
204    basis: &[crate::eht::basis::AtomicOrbital],
205    overlap: &DMatrix<f64>,
206    coefficients: &[Vec<f64>],
207    n_electrons: usize,
208) -> Vec<f64> {
209    let n_ao = basis.len();
210    let n_atoms = elements.len();
211    let ao_map = ao_to_atom_map(basis);
212    let p = build_density_matrix(coefficients, n_electrons);
213
214    // PS product
215    let ps = &p * overlap;
216
217    // Gross AO populations
218    let mut atom_pop = vec![0.0; n_atoms];
219    for mu in 0..n_ao {
220        atom_pop[ao_map[mu]] += ps[(mu, mu)];
221    }
222
223    // Charges = nuclear valence - electron population
224    (0..n_atoms)
225        .map(|a| valence_electrons(elements[a]) - atom_pop[a])
226        .collect()
227}
228
229/// Compute Löwdin charges.
230///
231/// Löwdin uses S^{1/2} P S^{1/2} instead of PS.
232/// q_A = Z_A − Σ_{μ∈A} (S^{1/2} P S^{1/2})_{μμ}
233pub fn lowdin_charges(
234    elements: &[u8],
235    basis: &[crate::eht::basis::AtomicOrbital],
236    overlap: &DMatrix<f64>,
237    coefficients: &[Vec<f64>],
238    n_electrons: usize,
239) -> Vec<f64> {
240    let n_ao = basis.len();
241    let n_atoms = elements.len();
242    let ao_map = ao_to_atom_map(basis);
243    let p = build_density_matrix(coefficients, n_electrons);
244
245    // Build S^{1/2}
246    let sps = lowdin_orthogonalized_density(overlap, &p);
247
248    let mut atom_pop = vec![0.0; n_atoms];
249    for mu in 0..n_ao {
250        atom_pop[ao_map[mu]] += sps[(mu, mu)];
251    }
252
253    (0..n_atoms)
254        .map(|a| valence_electrons(elements[a]) - atom_pop[a])
255        .collect()
256}
257
258/// Full population analysis: Mulliken + Löwdin in one pass.
259pub fn compute_population(
260    elements: &[u8],
261    positions: &[[f64; 3]],
262    coefficients: &[Vec<f64>],
263    n_electrons: usize,
264) -> PopulationResult {
265    let basis = crate::eht::basis::build_basis(elements, positions);
266    let overlap = crate::eht::build_overlap_matrix(&basis);
267    let n_ao = basis.len();
268    let n_atoms = elements.len();
269    let ao_map = ao_to_atom_map(&basis);
270    let p = build_density_matrix(coefficients, n_electrons);
271
272    // Mulliken: PS
273    let ps = &p * &overlap;
274    let mut mulliken_pop = vec![0.0; n_atoms];
275    let mut mulliken_ao_pop = vec![0.0; n_ao];
276    for mu in 0..n_ao {
277        mulliken_ao_pop[mu] = ps[(mu, mu)];
278        mulliken_pop[ao_map[mu]] += ps[(mu, mu)];
279    }
280    let mulliken_charges: Vec<f64> = (0..n_atoms)
281        .map(|a| valence_electrons(elements[a]) - mulliken_pop[a])
282        .collect();
283    let total_mulliken: f64 = mulliken_charges.iter().sum();
284
285    // Löwdin: S^{1/2} P S^{1/2}
286    let sps = lowdin_orthogonalized_density(&overlap, &p);
287    let mut lowdin_pop = vec![0.0; n_atoms];
288    for mu in 0..n_ao {
289        lowdin_pop[ao_map[mu]] += sps[(mu, mu)];
290    }
291    let lowdin_charges: Vec<f64> = (0..n_atoms)
292        .map(|a| valence_electrons(elements[a]) - lowdin_pop[a])
293        .collect();
294    let total_lowdin: f64 = lowdin_charges.iter().sum();
295
296    PopulationResult {
297        mulliken_charges,
298        lowdin_charges,
299        mulliken_populations: mulliken_ao_pop,
300        num_atoms: n_atoms,
301        total_charge_mulliken: total_mulliken,
302        total_charge_lowdin: total_lowdin,
303        charge_conservation_error: (total_mulliken - total_mulliken.round()).abs(),
304    }
305}
306
307/// Compute Wiberg-like and Mayer-like bond orders from an EHT density matrix.
308pub fn compute_bond_orders(
309    elements: &[u8],
310    positions: &[[f64; 3]],
311    coefficients: &[Vec<f64>],
312    n_electrons: usize,
313) -> BondOrderResult {
314    let basis = crate::eht::basis::build_basis(elements, positions);
315    let overlap = crate::eht::build_overlap_matrix(&basis);
316    let ao_map = ao_to_atom_map(&basis);
317    let density = build_density_matrix(coefficients, n_electrons);
318    let ps = &density * &overlap;
319    let p_orth = lowdin_orthogonalized_density(&overlap, &density);
320
321    let mut atom_aos = vec![Vec::new(); elements.len()];
322    for (ao_idx, &atom_idx) in ao_map.iter().enumerate() {
323        atom_aos[atom_idx].push(ao_idx);
324    }
325
326    let mut bonds = Vec::new();
327    let mut wiberg_valence = vec![0.0; elements.len()];
328    let mut mayer_valence = vec![0.0; elements.len()];
329
330    for atom_i in 0..elements.len() {
331        for atom_j in (atom_i + 1)..elements.len() {
332            let mut wiberg = 0.0;
333            let mut mayer = 0.0;
334
335            for &mu in &atom_aos[atom_i] {
336                for &nu in &atom_aos[atom_j] {
337                    let p_orth_mn = p_orth[(mu, nu)];
338                    wiberg += p_orth_mn * p_orth_mn;
339                    mayer += ps[(mu, nu)] * ps[(nu, mu)];
340                }
341            }
342
343            let dx = positions[atom_i][0] - positions[atom_j][0];
344            let dy = positions[atom_i][1] - positions[atom_j][1];
345            let dz = positions[atom_i][2] - positions[atom_j][2];
346            let distance = (dx * dx + dy * dy + dz * dz).sqrt();
347
348            wiberg_valence[atom_i] += wiberg;
349            wiberg_valence[atom_j] += wiberg;
350            mayer_valence[atom_i] += mayer;
351            mayer_valence[atom_j] += mayer;
352
353            bonds.push(BondOrderEntry {
354                atom_i,
355                atom_j,
356                distance,
357                wiberg,
358                mayer,
359            });
360        }
361    }
362
363    BondOrderResult {
364        bonds,
365        num_atoms: elements.len(),
366        wiberg_valence,
367        mayer_valence,
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::eht::solve_eht;
375
376    fn h2_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
377        (vec![1, 1], vec![[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]])
378    }
379
380    fn water_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
381        (
382            vec![8, 1, 1],
383            vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]],
384        )
385    }
386
387    #[test]
388    fn test_h2_symmetric_charges() {
389        let (elems, pos) = h2_molecule();
390        let result = solve_eht(&elems, &pos, None).unwrap();
391        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
392
393        // H₂ is symmetric → both atoms should have ~0 charge
394        assert!(
395            (pop.mulliken_charges[0] - pop.mulliken_charges[1]).abs() < 1e-6,
396            "H₂ Mulliken charges should be symmetric"
397        );
398        assert!(
399            pop.mulliken_charges[0].abs() < 0.01,
400            "H₂ charge should be ~0"
401        );
402    }
403
404    #[test]
405    fn test_water_oxygen_negative() {
406        let (elems, pos) = water_molecule();
407        let result = solve_eht(&elems, &pos, None).unwrap();
408        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
409
410        // Oxygen should be negative (more electronegative)
411        assert!(
412            pop.mulliken_charges[0] < 0.0,
413            "O in water should have negative Mulliken charge, got {}",
414            pop.mulliken_charges[0]
415        );
416        assert!(
417            pop.lowdin_charges[0] < 0.0,
418            "O in water should have negative Löwdin charge, got {}",
419            pop.lowdin_charges[0]
420        );
421    }
422
423    #[test]
424    fn test_charge_sum_conservation() {
425        let (elems, pos) = water_molecule();
426        let result = solve_eht(&elems, &pos, None).unwrap();
427        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
428
429        // Neutral molecule → charges sum ~ 0
430        assert!(
431            pop.total_charge_mulliken.abs() < 0.01,
432            "Mulliken total charge should be ~0, got {}",
433            pop.total_charge_mulliken
434        );
435        assert!(
436            pop.total_charge_lowdin.abs() < 0.01,
437            "Löwdin total charge should be ~0, got {}",
438            pop.total_charge_lowdin
439        );
440    }
441
442    #[test]
443    fn test_hydrogen_symmetry_in_water() {
444        let (elems, pos) = water_molecule();
445        let result = solve_eht(&elems, &pos, None).unwrap();
446        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
447
448        // Both H atoms should have identical charges (symmetric geometry)
449        assert!(
450            (pop.mulliken_charges[1] - pop.mulliken_charges[2]).abs() < 0.01,
451            "H charges in water should be symmetric: {} vs {}",
452            pop.mulliken_charges[1],
453            pop.mulliken_charges[2]
454        );
455    }
456
457    #[test]
458    fn test_lowdin_vs_mulliken_different() {
459        let (elems, pos) = water_molecule();
460        let result = solve_eht(&elems, &pos, None).unwrap();
461        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
462
463        // Löwdin and Mulliken typically give different charge magnitudes
464        // but same sign for electronegativity ordering
465        let m_o = pop.mulliken_charges[0];
466        let l_o = pop.lowdin_charges[0];
467        assert!(
468            m_o.signum() == l_o.signum(),
469            "Both methods should agree on sign for O"
470        );
471    }
472
473    #[test]
474    fn test_gross_orbital_populations_sum() {
475        let (elems, pos) = h2_molecule();
476        let result = solve_eht(&elems, &pos, None).unwrap();
477        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
478
479        // Sum of gross orbital populations should equal n_electrons
480        let total: f64 = pop.mulliken_populations.iter().sum();
481        assert!(
482            (total - result.n_electrons as f64).abs() < 0.01,
483            "AO pop sum {} should equal n_electrons {}",
484            total,
485            result.n_electrons
486        );
487    }
488
489    #[test]
490    fn test_h2_bond_order_is_positive() {
491        let (elems, pos) = h2_molecule();
492        let result = solve_eht(&elems, &pos, None).unwrap();
493        let bond_orders =
494            compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
495
496        assert_eq!(bond_orders.bonds.len(), 1);
497        assert!(bond_orders.bonds[0].wiberg > 0.1);
498        assert!(bond_orders.bonds[0].mayer > 0.1);
499    }
500
501    #[test]
502    fn test_water_oh_bonds_exceed_hh_bond_order() {
503        let (elems, pos) = water_molecule();
504        let result = solve_eht(&elems, &pos, None).unwrap();
505        let bond_orders =
506            compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
507
508        let oh_1 = bond_orders
509            .bonds
510            .iter()
511            .find(|bond| bond.atom_i == 0 && bond.atom_j == 1)
512            .unwrap();
513        let oh_2 = bond_orders
514            .bonds
515            .iter()
516            .find(|bond| bond.atom_i == 0 && bond.atom_j == 2)
517            .unwrap();
518        let hh = bond_orders
519            .bonds
520            .iter()
521            .find(|bond| bond.atom_i == 1 && bond.atom_j == 2)
522            .unwrap();
523
524        assert!(oh_1.wiberg > hh.wiberg);
525        assert!(oh_2.wiberg > hh.wiberg);
526        assert!(oh_1.mayer > hh.mayer);
527        assert!(oh_2.mayer > hh.mayer);
528    }
529}