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        55 => 1.0, // Cs
173        56 => 2.0, // Ba
174        81 => 3.0, // Tl
175        82 => 4.0, // Pb
176        83 => 5.0, // Bi
177        84 => 6.0, // Po
178        85 => 7.0, // At
179        86 => 8.0, // Rn
180        // Lanthanides (4f + 5d + 6s valence)
181        57 => 3.0,  // La
182        58 => 4.0,  // Ce
183        59 => 5.0,  // Pr
184        60 => 6.0,  // Nd
185        61 => 7.0,  // Pm
186        62 => 8.0,  // Sm
187        63 => 9.0,  // Eu
188        64 => 10.0, // Gd
189        65 => 11.0, // Tb
190        66 => 12.0, // Dy
191        67 => 13.0, // Ho
192        68 => 14.0, // Er
193        69 => 15.0, // Tm
194        70 => 16.0, // Yb
195        71 => 17.0, // Lu
196        _ => 0.0,
197    }
198}
199
200/// Map each AO index to its parent atom index.
201pub(crate) fn ao_to_atom_map(basis: &[crate::eht::basis::AtomicOrbital]) -> Vec<usize> {
202    basis.iter().map(|ao| ao.atom_index).collect()
203}
204
205fn lowdin_orthogonalized_density(overlap: &DMatrix<f64>, density: &DMatrix<f64>) -> DMatrix<f64> {
206    let n_ao = overlap.nrows();
207    let s_eigen = SymmetricEigen::new(overlap.clone());
208    let mut s_sqrt_diag = DMatrix::zeros(n_ao, n_ao);
209    for i in 0..n_ao {
210        let val = s_eigen.eigenvalues[i];
211        if val > 1e-10 {
212            s_sqrt_diag[(i, i)] = val.sqrt();
213        }
214    }
215    let s_sqrt = &s_eigen.eigenvectors * &s_sqrt_diag * s_eigen.eigenvectors.transpose();
216    &s_sqrt * density * &s_sqrt
217}
218
219/// Compute Mulliken charges.
220///
221/// Mulliken population for AO μ: (PS)_{μμ}
222/// Atom charge: q_A = Z_A − Σ_{μ∈A} (PS)_{μμ}
223pub fn mulliken_charges(
224    elements: &[u8],
225    basis: &[crate::eht::basis::AtomicOrbital],
226    overlap: &DMatrix<f64>,
227    coefficients: &[Vec<f64>],
228    n_electrons: usize,
229) -> Vec<f64> {
230    let n_ao = basis.len();
231    let n_atoms = elements.len();
232    let ao_map = ao_to_atom_map(basis);
233    let p = build_density_matrix(coefficients, n_electrons);
234
235    // PS product
236    let ps = &p * overlap;
237
238    // Gross AO populations
239    let mut atom_pop = vec![0.0; n_atoms];
240    for mu in 0..n_ao {
241        atom_pop[ao_map[mu]] += ps[(mu, mu)];
242    }
243
244    // Charges = nuclear valence - electron population
245    (0..n_atoms)
246        .map(|a| valence_electrons(elements[a]) - atom_pop[a])
247        .collect()
248}
249
250/// Compute Löwdin charges.
251///
252/// Löwdin uses S^{1/2} P S^{1/2} instead of PS.
253/// q_A = Z_A − Σ_{μ∈A} (S^{1/2} P S^{1/2})_{μμ}
254pub fn lowdin_charges(
255    elements: &[u8],
256    basis: &[crate::eht::basis::AtomicOrbital],
257    overlap: &DMatrix<f64>,
258    coefficients: &[Vec<f64>],
259    n_electrons: usize,
260) -> Vec<f64> {
261    let n_ao = basis.len();
262    let n_atoms = elements.len();
263    let ao_map = ao_to_atom_map(basis);
264    let p = build_density_matrix(coefficients, n_electrons);
265
266    // Build S^{1/2}
267    let sps = lowdin_orthogonalized_density(overlap, &p);
268
269    let mut atom_pop = vec![0.0; n_atoms];
270    for mu in 0..n_ao {
271        atom_pop[ao_map[mu]] += sps[(mu, mu)];
272    }
273
274    (0..n_atoms)
275        .map(|a| valence_electrons(elements[a]) - atom_pop[a])
276        .collect()
277}
278
279/// Full population analysis: Mulliken + Löwdin in one pass.
280pub fn compute_population(
281    elements: &[u8],
282    positions: &[[f64; 3]],
283    coefficients: &[Vec<f64>],
284    n_electrons: usize,
285) -> PopulationResult {
286    let basis = crate::eht::basis::build_basis(elements, positions);
287    let overlap = crate::eht::build_overlap_matrix(&basis);
288    let n_ao = basis.len();
289    let n_atoms = elements.len();
290    let ao_map = ao_to_atom_map(&basis);
291    let p = build_density_matrix(coefficients, n_electrons);
292
293    // Mulliken: PS
294    let ps = &p * &overlap;
295    let mut mulliken_pop = vec![0.0; n_atoms];
296    let mut mulliken_ao_pop = vec![0.0; n_ao];
297    for mu in 0..n_ao {
298        mulliken_ao_pop[mu] = ps[(mu, mu)];
299        mulliken_pop[ao_map[mu]] += ps[(mu, mu)];
300    }
301    let mulliken_charges: Vec<f64> = (0..n_atoms)
302        .map(|a| valence_electrons(elements[a]) - mulliken_pop[a])
303        .collect();
304    let total_mulliken: f64 = mulliken_charges.iter().sum();
305
306    // Löwdin: S^{1/2} P S^{1/2}
307    let sps = lowdin_orthogonalized_density(&overlap, &p);
308    let mut lowdin_pop = vec![0.0; n_atoms];
309    for mu in 0..n_ao {
310        lowdin_pop[ao_map[mu]] += sps[(mu, mu)];
311    }
312    let lowdin_charges: Vec<f64> = (0..n_atoms)
313        .map(|a| valence_electrons(elements[a]) - lowdin_pop[a])
314        .collect();
315    let total_lowdin: f64 = lowdin_charges.iter().sum();
316
317    PopulationResult {
318        mulliken_charges,
319        lowdin_charges,
320        mulliken_populations: mulliken_ao_pop,
321        num_atoms: n_atoms,
322        total_charge_mulliken: total_mulliken,
323        total_charge_lowdin: total_lowdin,
324        charge_conservation_error: (total_mulliken - total_mulliken.round()).abs(),
325    }
326}
327
328/// Compute population analysis with parallel matrix operations (rayon).
329#[cfg(feature = "parallel")]
330pub fn compute_population_parallel(
331    elements: &[u8],
332    positions: &[[f64; 3]],
333    coefficients: &[Vec<f64>],
334    n_electrons: usize,
335) -> PopulationResult {
336    use rayon::prelude::*;
337
338    let basis = crate::eht::basis::build_basis(elements, positions);
339    let overlap = crate::eht::build_overlap_matrix(&basis);
340    let n_ao = basis.len();
341    let n_atoms = elements.len();
342    let ao_map = ao_to_atom_map(&basis);
343    let p = build_density_matrix(coefficients, n_electrons);
344
345    // Mulliken: PS
346    let ps = &p * &overlap;
347    let mut mulliken_pop = vec![0.0; n_atoms];
348    let mut mulliken_ao_pop = vec![0.0; n_ao];
349    for mu in 0..n_ao {
350        mulliken_ao_pop[mu] = ps[(mu, mu)];
351        mulliken_pop[ao_map[mu]] += ps[(mu, mu)];
352    }
353    let mulliken_charges: Vec<f64> = (0..n_atoms)
354        .into_par_iter()
355        .map(|a| valence_electrons(elements[a]) - mulliken_pop[a])
356        .collect();
357    let total_mulliken: f64 = mulliken_charges.iter().sum();
358
359    // Löwdin: S^{1/2} P S^{1/2}
360    let sps = lowdin_orthogonalized_density(&overlap, &p);
361    let lowdin_charges: Vec<f64> = (0..n_atoms)
362        .into_par_iter()
363        .map(|a| {
364            let mut pop = 0.0;
365            for mu in 0..n_ao {
366                if ao_map[mu] == a {
367                    pop += sps[(mu, mu)];
368                }
369            }
370            valence_electrons(elements[a]) - pop
371        })
372        .collect();
373    let total_lowdin: f64 = lowdin_charges.iter().sum();
374
375    PopulationResult {
376        mulliken_charges,
377        lowdin_charges,
378        mulliken_populations: mulliken_ao_pop,
379        num_atoms: n_atoms,
380        total_charge_mulliken: total_mulliken,
381        total_charge_lowdin: total_lowdin,
382        charge_conservation_error: (total_mulliken - total_mulliken.round()).abs(),
383    }
384}
385
386/// Compute Wiberg-like and Mayer-like bond orders from an EHT density matrix.
387pub fn compute_bond_orders(
388    elements: &[u8],
389    positions: &[[f64; 3]],
390    coefficients: &[Vec<f64>],
391    n_electrons: usize,
392) -> BondOrderResult {
393    let basis = crate::eht::basis::build_basis(elements, positions);
394    let overlap = crate::eht::build_overlap_matrix(&basis);
395    let ao_map = ao_to_atom_map(&basis);
396    let density = build_density_matrix(coefficients, n_electrons);
397    let ps = &density * &overlap;
398    let p_orth = lowdin_orthogonalized_density(&overlap, &density);
399
400    let mut atom_aos = vec![Vec::new(); elements.len()];
401    for (ao_idx, &atom_idx) in ao_map.iter().enumerate() {
402        atom_aos[atom_idx].push(ao_idx);
403    }
404
405    let mut bonds = Vec::new();
406    let mut wiberg_valence = vec![0.0; elements.len()];
407    let mut mayer_valence = vec![0.0; elements.len()];
408
409    for atom_i in 0..elements.len() {
410        for atom_j in (atom_i + 1)..elements.len() {
411            let mut wiberg = 0.0;
412            let mut mayer = 0.0;
413
414            for &mu in &atom_aos[atom_i] {
415                for &nu in &atom_aos[atom_j] {
416                    let p_orth_mn = p_orth[(mu, nu)];
417                    wiberg += p_orth_mn * p_orth_mn;
418                    mayer += ps[(mu, nu)] * ps[(nu, mu)];
419                }
420            }
421
422            let dx = positions[atom_i][0] - positions[atom_j][0];
423            let dy = positions[atom_i][1] - positions[atom_j][1];
424            let dz = positions[atom_i][2] - positions[atom_j][2];
425            let distance = (dx * dx + dy * dy + dz * dz).sqrt();
426
427            wiberg_valence[atom_i] += wiberg;
428            wiberg_valence[atom_j] += wiberg;
429            mayer_valence[atom_i] += mayer;
430            mayer_valence[atom_j] += mayer;
431
432            bonds.push(BondOrderEntry {
433                atom_i,
434                atom_j,
435                distance,
436                wiberg,
437                mayer,
438            });
439        }
440    }
441
442    BondOrderResult {
443        bonds,
444        num_atoms: elements.len(),
445        wiberg_valence,
446        mayer_valence,
447    }
448}
449
450/// Compute bond orders with parallel atom-pair processing (rayon).
451#[cfg(feature = "parallel")]
452pub fn compute_bond_orders_parallel(
453    elements: &[u8],
454    positions: &[[f64; 3]],
455    coefficients: &[Vec<f64>],
456    n_electrons: usize,
457) -> BondOrderResult {
458    use rayon::prelude::*;
459
460    let basis = crate::eht::basis::build_basis(elements, positions);
461    let overlap = crate::eht::build_overlap_matrix(&basis);
462    let ao_map = ao_to_atom_map(&basis);
463    let density = build_density_matrix(coefficients, n_electrons);
464    let ps = &density * &overlap;
465    let p_orth = lowdin_orthogonalized_density(&overlap, &density);
466
467    let mut atom_aos = vec![Vec::new(); elements.len()];
468    for (ao_idx, &atom_idx) in ao_map.iter().enumerate() {
469        atom_aos[atom_idx].push(ao_idx);
470    }
471
472    // Build atom pair list
473    let n_atoms = elements.len();
474    let pairs: Vec<(usize, usize)> = (0..n_atoms)
475        .flat_map(|i| ((i + 1)..n_atoms).map(move |j| (i, j)))
476        .collect();
477
478    let bonds: Vec<BondOrderEntry> = pairs
479        .par_iter()
480        .map(|&(atom_i, atom_j)| {
481            let mut wiberg = 0.0;
482            let mut mayer = 0.0;
483
484            for &mu in &atom_aos[atom_i] {
485                for &nu in &atom_aos[atom_j] {
486                    let p_orth_mn = p_orth[(mu, nu)];
487                    wiberg += p_orth_mn * p_orth_mn;
488                    mayer += ps[(mu, nu)] * ps[(nu, mu)];
489                }
490            }
491
492            let dx = positions[atom_i][0] - positions[atom_j][0];
493            let dy = positions[atom_i][1] - positions[atom_j][1];
494            let dz = positions[atom_i][2] - positions[atom_j][2];
495            let distance = (dx * dx + dy * dy + dz * dz).sqrt();
496
497            BondOrderEntry {
498                atom_i,
499                atom_j,
500                distance,
501                wiberg,
502                mayer,
503            }
504        })
505        .collect();
506
507    let mut wiberg_valence = vec![0.0; n_atoms];
508    let mut mayer_valence = vec![0.0; n_atoms];
509    for bond in &bonds {
510        wiberg_valence[bond.atom_i] += bond.wiberg;
511        wiberg_valence[bond.atom_j] += bond.wiberg;
512        mayer_valence[bond.atom_i] += bond.mayer;
513        mayer_valence[bond.atom_j] += bond.mayer;
514    }
515
516    BondOrderResult {
517        bonds,
518        num_atoms: n_atoms,
519        wiberg_valence,
520        mayer_valence,
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::eht::solve_eht;
528
529    fn h2_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
530        (vec![1, 1], vec![[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]])
531    }
532
533    fn water_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
534        (
535            vec![8, 1, 1],
536            vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]],
537        )
538    }
539
540    #[test]
541    fn test_h2_symmetric_charges() {
542        let (elems, pos) = h2_molecule();
543        let result = solve_eht(&elems, &pos, None).unwrap();
544        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
545
546        // H₂ is symmetric → both atoms should have ~0 charge
547        assert!(
548            (pop.mulliken_charges[0] - pop.mulliken_charges[1]).abs() < 1e-6,
549            "H₂ Mulliken charges should be symmetric"
550        );
551        assert!(
552            pop.mulliken_charges[0].abs() < 0.01,
553            "H₂ charge should be ~0"
554        );
555    }
556
557    #[test]
558    fn test_water_oxygen_negative() {
559        let (elems, pos) = water_molecule();
560        let result = solve_eht(&elems, &pos, None).unwrap();
561        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
562
563        // Oxygen should be negative (more electronegative)
564        assert!(
565            pop.mulliken_charges[0] < 0.0,
566            "O in water should have negative Mulliken charge, got {}",
567            pop.mulliken_charges[0]
568        );
569        assert!(
570            pop.lowdin_charges[0] < 0.0,
571            "O in water should have negative Löwdin charge, got {}",
572            pop.lowdin_charges[0]
573        );
574    }
575
576    #[test]
577    fn test_charge_sum_conservation() {
578        let (elems, pos) = water_molecule();
579        let result = solve_eht(&elems, &pos, None).unwrap();
580        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
581
582        // Neutral molecule → charges sum ~ 0
583        assert!(
584            pop.total_charge_mulliken.abs() < 0.01,
585            "Mulliken total charge should be ~0, got {}",
586            pop.total_charge_mulliken
587        );
588        assert!(
589            pop.total_charge_lowdin.abs() < 0.01,
590            "Löwdin total charge should be ~0, got {}",
591            pop.total_charge_lowdin
592        );
593    }
594
595    #[test]
596    fn test_hydrogen_symmetry_in_water() {
597        let (elems, pos) = water_molecule();
598        let result = solve_eht(&elems, &pos, None).unwrap();
599        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
600
601        // Both H atoms should have identical charges (symmetric geometry)
602        assert!(
603            (pop.mulliken_charges[1] - pop.mulliken_charges[2]).abs() < 0.01,
604            "H charges in water should be symmetric: {} vs {}",
605            pop.mulliken_charges[1],
606            pop.mulliken_charges[2]
607        );
608    }
609
610    #[test]
611    fn test_lowdin_vs_mulliken_different() {
612        let (elems, pos) = water_molecule();
613        let result = solve_eht(&elems, &pos, None).unwrap();
614        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
615
616        // Löwdin and Mulliken typically give different charge magnitudes
617        // but same sign for electronegativity ordering
618        let m_o = pop.mulliken_charges[0];
619        let l_o = pop.lowdin_charges[0];
620        assert!(
621            m_o.signum() == l_o.signum(),
622            "Both methods should agree on sign for O"
623        );
624    }
625
626    #[test]
627    fn test_gross_orbital_populations_sum() {
628        let (elems, pos) = h2_molecule();
629        let result = solve_eht(&elems, &pos, None).unwrap();
630        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
631
632        // Sum of gross orbital populations should equal n_electrons
633        let total: f64 = pop.mulliken_populations.iter().sum();
634        assert!(
635            (total - result.n_electrons as f64).abs() < 0.01,
636            "AO pop sum {} should equal n_electrons {}",
637            total,
638            result.n_electrons
639        );
640    }
641
642    #[test]
643    fn test_h2_bond_order_is_positive() {
644        let (elems, pos) = h2_molecule();
645        let result = solve_eht(&elems, &pos, None).unwrap();
646        let bond_orders =
647            compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
648
649        assert_eq!(bond_orders.bonds.len(), 1);
650        assert!(bond_orders.bonds[0].wiberg > 0.1);
651        assert!(bond_orders.bonds[0].mayer > 0.1);
652    }
653
654    #[test]
655    fn test_water_oh_bonds_exceed_hh_bond_order() {
656        let (elems, pos) = water_molecule();
657        let result = solve_eht(&elems, &pos, None).unwrap();
658        let bond_orders =
659            compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
660
661        let oh_1 = bond_orders
662            .bonds
663            .iter()
664            .find(|bond| bond.atom_i == 0 && bond.atom_j == 1)
665            .unwrap();
666        let oh_2 = bond_orders
667            .bonds
668            .iter()
669            .find(|bond| bond.atom_i == 0 && bond.atom_j == 2)
670            .unwrap();
671        let hh = bond_orders
672            .bonds
673            .iter()
674            .find(|bond| bond.atom_i == 1 && bond.atom_j == 2)
675            .unwrap();
676
677        assert!(oh_1.wiberg > hh.wiberg);
678        assert!(oh_2.wiberg > hh.wiberg);
679        assert!(oh_1.mayer > hh.mayer);
680        assert!(oh_2.mayer > hh.mayer);
681    }
682
683    // ═══════════════════════════════════════════════════════════════════════════
684    // valence_electrons coverage — Period 6 main-group + lanthanides + all TMs
685    // ═══════════════════════════════════════════════════════════════════════════
686
687    #[test]
688    fn test_valence_electrons_period1_period2() {
689        assert_eq!(valence_electrons(1), 1.0); // H
690        assert_eq!(valence_electrons(2), 2.0); // He
691        assert_eq!(valence_electrons(6), 4.0); // C
692        assert_eq!(valence_electrons(7), 5.0); // N
693        assert_eq!(valence_electrons(8), 6.0); // O
694        assert_eq!(valence_electrons(9), 7.0); // F
695        assert_eq!(valence_electrons(10), 8.0); // Ne
696    }
697
698    #[test]
699    fn test_valence_electrons_period6_main_group() {
700        assert_eq!(valence_electrons(55), 1.0); // Cs
701        assert_eq!(valence_electrons(56), 2.0); // Ba
702        assert_eq!(valence_electrons(81), 3.0); // Tl
703        assert_eq!(valence_electrons(82), 4.0); // Pb
704        assert_eq!(valence_electrons(83), 5.0); // Bi
705        assert_eq!(valence_electrons(84), 6.0); // Po
706        assert_eq!(valence_electrons(85), 7.0); // At
707        assert_eq!(valence_electrons(86), 8.0); // Rn
708    }
709
710    #[test]
711    fn test_valence_electrons_lanthanides() {
712        // La(57)→Lu(71): valence = 4f + 5d + 6s
713        assert_eq!(valence_electrons(57), 3.0); // La
714        assert_eq!(valence_electrons(58), 4.0); // Ce
715        assert_eq!(valence_electrons(63), 9.0); // Eu
716        assert_eq!(valence_electrons(64), 10.0); // Gd
717        assert_eq!(valence_electrons(70), 16.0); // Yb
718        assert_eq!(valence_electrons(71), 17.0); // Lu
719    }
720
721    #[test]
722    fn test_valence_electrons_3d_transition_metals() {
723        assert_eq!(valence_electrons(21), 3.0); // Sc
724        assert_eq!(valence_electrons(22), 4.0); // Ti
725        assert_eq!(valence_electrons(26), 8.0); // Fe
726        assert_eq!(valence_electrons(29), 11.0); // Cu
727        assert_eq!(valence_electrons(30), 12.0); // Zn
728    }
729
730    #[test]
731    fn test_valence_electrons_4d_transition_metals() {
732        assert_eq!(valence_electrons(39), 3.0); // Y
733        assert_eq!(valence_electrons(44), 8.0); // Ru
734        assert_eq!(valence_electrons(46), 10.0); // Pd
735        assert_eq!(valence_electrons(47), 11.0); // Ag
736        assert_eq!(valence_electrons(48), 12.0); // Cd
737    }
738
739    #[test]
740    fn test_valence_electrons_5d_transition_metals() {
741        assert_eq!(valence_electrons(72), 4.0); // Hf
742        assert_eq!(valence_electrons(74), 6.0); // W
743        assert_eq!(valence_electrons(76), 8.0); // Os
744        assert_eq!(valence_electrons(78), 10.0); // Pt
745        assert_eq!(valence_electrons(79), 11.0); // Au
746        assert_eq!(valence_electrons(80), 12.0); // Hg
747    }
748
749    #[test]
750    fn test_valence_electrons_unknown_returns_zero() {
751        // Elements beyond the lookup table should return 0.0
752        assert_eq!(valence_electrons(87), 0.0); // Fr (not in table)
753        assert_eq!(valence_electrons(0), 0.0); // invalid
754        assert_eq!(valence_electrons(255), 0.0);
755    }
756
757    // ═══════════════════════════════════════════════════════════════════════════
758    // Parallel population / bond-order equivalence
759    // ═══════════════════════════════════════════════════════════════════════════
760
761    #[cfg(feature = "parallel")]
762    #[test]
763    fn test_population_parallel_matches_serial() {
764        let (elems, pos) = water_molecule();
765        let result = solve_eht(&elems, &pos, None).unwrap();
766
767        let serial = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
768        let parallel =
769            compute_population_parallel(&elems, &pos, &result.coefficients, result.n_electrons);
770
771        for i in 0..elems.len() {
772            assert!(
773                (serial.mulliken_charges[i] - parallel.mulliken_charges[i]).abs() < 1e-10,
774                "Mulliken mismatch at atom {}: serial={:.6} vs parallel={:.6}",
775                i,
776                serial.mulliken_charges[i],
777                parallel.mulliken_charges[i]
778            );
779            assert!(
780                (serial.lowdin_charges[i] - parallel.lowdin_charges[i]).abs() < 1e-10,
781                "Löwdin mismatch at atom {}: serial={:.6} vs parallel={:.6}",
782                i,
783                serial.lowdin_charges[i],
784                parallel.lowdin_charges[i]
785            );
786        }
787    }
788
789    #[cfg(feature = "parallel")]
790    #[test]
791    fn test_bond_orders_parallel_matches_serial() {
792        let (elems, pos) = water_molecule();
793        let result = solve_eht(&elems, &pos, None).unwrap();
794
795        let serial = compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
796        let parallel =
797            compute_bond_orders_parallel(&elems, &pos, &result.coefficients, result.n_electrons);
798
799        assert_eq!(serial.bonds.len(), parallel.bonds.len());
800        for (s, p) in serial.bonds.iter().zip(parallel.bonds.iter()) {
801            assert_eq!(s.atom_i, p.atom_i);
802            assert_eq!(s.atom_j, p.atom_j);
803            assert!(
804                (s.wiberg - p.wiberg).abs() < 1e-10,
805                "Wiberg mismatch ({},{}): {:.6} vs {:.6}",
806                s.atom_i,
807                s.atom_j,
808                s.wiberg,
809                p.wiberg
810            );
811            assert!(
812                (s.mayer - p.mayer).abs() < 1e-10,
813                "Mayer mismatch ({},{}): {:.6} vs {:.6}",
814                s.atom_i,
815                s.atom_j,
816                s.mayer,
817                p.mayer
818            );
819        }
820    }
821}