use nalgebra::{DMatrix, SymmetricEigen};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BondOrderEntry {
pub atom_i: usize,
pub atom_j: usize,
pub distance: f64,
pub wiberg: f64,
pub mayer: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BondOrderResult {
pub bonds: Vec<BondOrderEntry>,
pub num_atoms: usize,
pub wiberg_valence: Vec<f64>,
pub mayer_valence: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PopulationResult {
pub mulliken_charges: Vec<f64>,
pub lowdin_charges: Vec<f64>,
pub mulliken_populations: Vec<f64>,
pub num_atoms: usize,
pub total_charge_mulliken: f64,
pub total_charge_lowdin: f64,
pub charge_conservation_error: f64,
}
pub(crate) fn build_density_matrix(coefficients: &[Vec<f64>], n_electrons: usize) -> DMatrix<f64> {
let n_ao = coefficients.len();
let n_occupied = n_electrons / 2;
let mut p = DMatrix::zeros(n_ao, n_ao);
for i in 0..n_occupied {
for mu in 0..n_ao {
for nu in 0..n_ao {
p[(mu, nu)] += 2.0 * coefficients[mu][i] * coefficients[nu][i];
}
}
}
if n_electrons % 2 == 1 {
let i = n_occupied;
for mu in 0..n_ao {
for nu in 0..n_ao {
p[(mu, nu)] += coefficients[mu][i] * coefficients[nu][i];
}
}
}
p
}
pub(crate) fn valence_electrons(z: u8) -> f64 {
match z {
1 => 1.0, 2 => 2.0, 3 => 1.0, 4 => 2.0, 5 => 3.0, 6 => 4.0, 7 => 5.0, 8 => 6.0, 9 => 7.0, 10 => 8.0, 11 => 1.0, 12 => 2.0, 13 => 3.0, 14 => 4.0, 15 => 5.0, 16 => 6.0, 17 => 7.0, 18 => 8.0, 19 => 1.0, 20 => 2.0, 31 => 3.0, 32 => 4.0, 33 => 5.0, 34 => 6.0, 35 => 7.0, 36 => 8.0, 37 => 1.0, 38 => 2.0, 49 => 3.0, 50 => 4.0, 51 => 5.0, 52 => 6.0, 53 => 7.0, 54 => 8.0, 21 => 3.0, 22 => 4.0, 23 => 5.0, 24 => 6.0, 25 => 7.0, 26 => 8.0, 27 => 9.0, 28 => 10.0, 29 => 11.0, 30 => 12.0, 39 => 3.0, 40 => 4.0, 41 => 5.0, 42 => 6.0, 43 => 7.0, 44 => 8.0, 45 => 9.0, 46 => 10.0, 47 => 11.0, 48 => 12.0, 72 => 4.0, 73 => 5.0, 74 => 6.0, 75 => 7.0, 76 => 8.0, 77 => 9.0, 78 => 10.0, 79 => 11.0, 80 => 12.0, 55 => 1.0, 56 => 2.0, 81 => 3.0, 82 => 4.0, 83 => 5.0, 84 => 6.0, 85 => 7.0, 86 => 8.0, 57 => 3.0, 58 => 4.0, 59 => 5.0, 60 => 6.0, 61 => 7.0, 62 => 8.0, 63 => 9.0, 64 => 10.0, 65 => 11.0, 66 => 12.0, 67 => 13.0, 68 => 14.0, 69 => 15.0, 70 => 16.0, 71 => 17.0, _ => 0.0,
}
}
pub(crate) fn ao_to_atom_map(basis: &[crate::eht::basis::AtomicOrbital]) -> Vec<usize> {
basis.iter().map(|ao| ao.atom_index).collect()
}
fn lowdin_orthogonalized_density(overlap: &DMatrix<f64>, density: &DMatrix<f64>) -> DMatrix<f64> {
let n_ao = overlap.nrows();
let s_eigen = SymmetricEigen::new(overlap.clone());
let mut s_sqrt_diag = DMatrix::zeros(n_ao, n_ao);
for i in 0..n_ao {
let val = s_eigen.eigenvalues[i];
if val > 1e-10 {
s_sqrt_diag[(i, i)] = val.sqrt();
}
}
let s_sqrt = &s_eigen.eigenvectors * &s_sqrt_diag * s_eigen.eigenvectors.transpose();
&s_sqrt * density * &s_sqrt
}
pub fn mulliken_charges(
elements: &[u8],
basis: &[crate::eht::basis::AtomicOrbital],
overlap: &DMatrix<f64>,
coefficients: &[Vec<f64>],
n_electrons: usize,
) -> Vec<f64> {
let n_ao = basis.len();
let n_atoms = elements.len();
let ao_map = ao_to_atom_map(basis);
let p = build_density_matrix(coefficients, n_electrons);
let ps = &p * overlap;
let mut atom_pop = vec![0.0; n_atoms];
for mu in 0..n_ao {
atom_pop[ao_map[mu]] += ps[(mu, mu)];
}
(0..n_atoms)
.map(|a| valence_electrons(elements[a]) - atom_pop[a])
.collect()
}
pub fn lowdin_charges(
elements: &[u8],
basis: &[crate::eht::basis::AtomicOrbital],
overlap: &DMatrix<f64>,
coefficients: &[Vec<f64>],
n_electrons: usize,
) -> Vec<f64> {
let n_ao = basis.len();
let n_atoms = elements.len();
let ao_map = ao_to_atom_map(basis);
let p = build_density_matrix(coefficients, n_electrons);
let sps = lowdin_orthogonalized_density(overlap, &p);
let mut atom_pop = vec![0.0; n_atoms];
for mu in 0..n_ao {
atom_pop[ao_map[mu]] += sps[(mu, mu)];
}
(0..n_atoms)
.map(|a| valence_electrons(elements[a]) - atom_pop[a])
.collect()
}
pub fn compute_population(
elements: &[u8],
positions: &[[f64; 3]],
coefficients: &[Vec<f64>],
n_electrons: usize,
) -> PopulationResult {
let basis = crate::eht::basis::build_basis(elements, positions);
let overlap = crate::eht::build_overlap_matrix(&basis);
let n_ao = basis.len();
let n_atoms = elements.len();
let ao_map = ao_to_atom_map(&basis);
let p = build_density_matrix(coefficients, n_electrons);
let ps = &p * &overlap;
let mut mulliken_pop = vec![0.0; n_atoms];
let mut mulliken_ao_pop = vec![0.0; n_ao];
for mu in 0..n_ao {
mulliken_ao_pop[mu] = ps[(mu, mu)];
mulliken_pop[ao_map[mu]] += ps[(mu, mu)];
}
let mulliken_charges: Vec<f64> = (0..n_atoms)
.map(|a| valence_electrons(elements[a]) - mulliken_pop[a])
.collect();
let total_mulliken: f64 = mulliken_charges.iter().sum();
let sps = lowdin_orthogonalized_density(&overlap, &p);
let mut lowdin_pop = vec![0.0; n_atoms];
for mu in 0..n_ao {
lowdin_pop[ao_map[mu]] += sps[(mu, mu)];
}
let lowdin_charges: Vec<f64> = (0..n_atoms)
.map(|a| valence_electrons(elements[a]) - lowdin_pop[a])
.collect();
let total_lowdin: f64 = lowdin_charges.iter().sum();
PopulationResult {
mulliken_charges,
lowdin_charges,
mulliken_populations: mulliken_ao_pop,
num_atoms: n_atoms,
total_charge_mulliken: total_mulliken,
total_charge_lowdin: total_lowdin,
charge_conservation_error: (total_mulliken - total_mulliken.round()).abs(),
}
}
#[cfg(feature = "parallel")]
pub fn compute_population_parallel(
elements: &[u8],
positions: &[[f64; 3]],
coefficients: &[Vec<f64>],
n_electrons: usize,
) -> PopulationResult {
use rayon::prelude::*;
let basis = crate::eht::basis::build_basis(elements, positions);
let overlap = crate::eht::build_overlap_matrix(&basis);
let n_ao = basis.len();
let n_atoms = elements.len();
let ao_map = ao_to_atom_map(&basis);
let p = build_density_matrix(coefficients, n_electrons);
let ps = &p * &overlap;
let mut mulliken_pop = vec![0.0; n_atoms];
let mut mulliken_ao_pop = vec![0.0; n_ao];
for mu in 0..n_ao {
mulliken_ao_pop[mu] = ps[(mu, mu)];
mulliken_pop[ao_map[mu]] += ps[(mu, mu)];
}
let mulliken_charges: Vec<f64> = (0..n_atoms)
.into_par_iter()
.map(|a| valence_electrons(elements[a]) - mulliken_pop[a])
.collect();
let total_mulliken: f64 = mulliken_charges.iter().sum();
let sps = lowdin_orthogonalized_density(&overlap, &p);
let lowdin_charges: Vec<f64> = (0..n_atoms)
.into_par_iter()
.map(|a| {
let mut pop = 0.0;
for mu in 0..n_ao {
if ao_map[mu] == a {
pop += sps[(mu, mu)];
}
}
valence_electrons(elements[a]) - pop
})
.collect();
let total_lowdin: f64 = lowdin_charges.iter().sum();
PopulationResult {
mulliken_charges,
lowdin_charges,
mulliken_populations: mulliken_ao_pop,
num_atoms: n_atoms,
total_charge_mulliken: total_mulliken,
total_charge_lowdin: total_lowdin,
charge_conservation_error: (total_mulliken - total_mulliken.round()).abs(),
}
}
pub fn compute_bond_orders(
elements: &[u8],
positions: &[[f64; 3]],
coefficients: &[Vec<f64>],
n_electrons: usize,
) -> BondOrderResult {
let basis = crate::eht::basis::build_basis(elements, positions);
let overlap = crate::eht::build_overlap_matrix(&basis);
let ao_map = ao_to_atom_map(&basis);
let density = build_density_matrix(coefficients, n_electrons);
let ps = &density * &overlap;
let p_orth = lowdin_orthogonalized_density(&overlap, &density);
let mut atom_aos = vec![Vec::new(); elements.len()];
for (ao_idx, &atom_idx) in ao_map.iter().enumerate() {
atom_aos[atom_idx].push(ao_idx);
}
let mut bonds = Vec::new();
let mut wiberg_valence = vec![0.0; elements.len()];
let mut mayer_valence = vec![0.0; elements.len()];
for atom_i in 0..elements.len() {
for atom_j in (atom_i + 1)..elements.len() {
let mut wiberg = 0.0;
let mut mayer = 0.0;
for &mu in &atom_aos[atom_i] {
for &nu in &atom_aos[atom_j] {
let p_orth_mn = p_orth[(mu, nu)];
wiberg += p_orth_mn * p_orth_mn;
mayer += ps[(mu, nu)] * ps[(nu, mu)];
}
}
let dx = positions[atom_i][0] - positions[atom_j][0];
let dy = positions[atom_i][1] - positions[atom_j][1];
let dz = positions[atom_i][2] - positions[atom_j][2];
let distance = (dx * dx + dy * dy + dz * dz).sqrt();
wiberg_valence[atom_i] += wiberg;
wiberg_valence[atom_j] += wiberg;
mayer_valence[atom_i] += mayer;
mayer_valence[atom_j] += mayer;
bonds.push(BondOrderEntry {
atom_i,
atom_j,
distance,
wiberg,
mayer,
});
}
}
BondOrderResult {
bonds,
num_atoms: elements.len(),
wiberg_valence,
mayer_valence,
}
}
#[cfg(feature = "parallel")]
pub fn compute_bond_orders_parallel(
elements: &[u8],
positions: &[[f64; 3]],
coefficients: &[Vec<f64>],
n_electrons: usize,
) -> BondOrderResult {
use rayon::prelude::*;
let basis = crate::eht::basis::build_basis(elements, positions);
let overlap = crate::eht::build_overlap_matrix(&basis);
let ao_map = ao_to_atom_map(&basis);
let density = build_density_matrix(coefficients, n_electrons);
let ps = &density * &overlap;
let p_orth = lowdin_orthogonalized_density(&overlap, &density);
let mut atom_aos = vec![Vec::new(); elements.len()];
for (ao_idx, &atom_idx) in ao_map.iter().enumerate() {
atom_aos[atom_idx].push(ao_idx);
}
let n_atoms = elements.len();
let pairs: Vec<(usize, usize)> = (0..n_atoms)
.flat_map(|i| ((i + 1)..n_atoms).map(move |j| (i, j)))
.collect();
let bonds: Vec<BondOrderEntry> = pairs
.par_iter()
.map(|&(atom_i, atom_j)| {
let mut wiberg = 0.0;
let mut mayer = 0.0;
for &mu in &atom_aos[atom_i] {
for &nu in &atom_aos[atom_j] {
let p_orth_mn = p_orth[(mu, nu)];
wiberg += p_orth_mn * p_orth_mn;
mayer += ps[(mu, nu)] * ps[(nu, mu)];
}
}
let dx = positions[atom_i][0] - positions[atom_j][0];
let dy = positions[atom_i][1] - positions[atom_j][1];
let dz = positions[atom_i][2] - positions[atom_j][2];
let distance = (dx * dx + dy * dy + dz * dz).sqrt();
BondOrderEntry {
atom_i,
atom_j,
distance,
wiberg,
mayer,
}
})
.collect();
let mut wiberg_valence = vec![0.0; n_atoms];
let mut mayer_valence = vec![0.0; n_atoms];
for bond in &bonds {
wiberg_valence[bond.atom_i] += bond.wiberg;
wiberg_valence[bond.atom_j] += bond.wiberg;
mayer_valence[bond.atom_i] += bond.mayer;
mayer_valence[bond.atom_j] += bond.mayer;
}
BondOrderResult {
bonds,
num_atoms: n_atoms,
wiberg_valence,
mayer_valence,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eht::solve_eht;
fn h2_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
(vec![1, 1], vec![[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]])
}
fn water_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
(
vec![8, 1, 1],
vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]],
)
}
#[test]
fn test_h2_symmetric_charges() {
let (elems, pos) = h2_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
assert!(
(pop.mulliken_charges[0] - pop.mulliken_charges[1]).abs() < 1e-6,
"H₂ Mulliken charges should be symmetric"
);
assert!(
pop.mulliken_charges[0].abs() < 0.01,
"H₂ charge should be ~0"
);
}
#[test]
fn test_water_oxygen_negative() {
let (elems, pos) = water_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
assert!(
pop.mulliken_charges[0] < 0.0,
"O in water should have negative Mulliken charge, got {}",
pop.mulliken_charges[0]
);
assert!(
pop.lowdin_charges[0] < 0.0,
"O in water should have negative Löwdin charge, got {}",
pop.lowdin_charges[0]
);
}
#[test]
fn test_charge_sum_conservation() {
let (elems, pos) = water_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
assert!(
pop.total_charge_mulliken.abs() < 0.01,
"Mulliken total charge should be ~0, got {}",
pop.total_charge_mulliken
);
assert!(
pop.total_charge_lowdin.abs() < 0.01,
"Löwdin total charge should be ~0, got {}",
pop.total_charge_lowdin
);
}
#[test]
fn test_hydrogen_symmetry_in_water() {
let (elems, pos) = water_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
assert!(
(pop.mulliken_charges[1] - pop.mulliken_charges[2]).abs() < 0.01,
"H charges in water should be symmetric: {} vs {}",
pop.mulliken_charges[1],
pop.mulliken_charges[2]
);
}
#[test]
fn test_lowdin_vs_mulliken_different() {
let (elems, pos) = water_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
let m_o = pop.mulliken_charges[0];
let l_o = pop.lowdin_charges[0];
assert!(
m_o.signum() == l_o.signum(),
"Both methods should agree on sign for O"
);
}
#[test]
fn test_gross_orbital_populations_sum() {
let (elems, pos) = h2_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
let total: f64 = pop.mulliken_populations.iter().sum();
assert!(
(total - result.n_electrons as f64).abs() < 0.01,
"AO pop sum {} should equal n_electrons {}",
total,
result.n_electrons
);
}
#[test]
fn test_h2_bond_order_is_positive() {
let (elems, pos) = h2_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let bond_orders =
compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
assert_eq!(bond_orders.bonds.len(), 1);
assert!(bond_orders.bonds[0].wiberg > 0.1);
assert!(bond_orders.bonds[0].mayer > 0.1);
}
#[test]
fn test_water_oh_bonds_exceed_hh_bond_order() {
let (elems, pos) = water_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let bond_orders =
compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
let oh_1 = bond_orders
.bonds
.iter()
.find(|bond| bond.atom_i == 0 && bond.atom_j == 1)
.unwrap();
let oh_2 = bond_orders
.bonds
.iter()
.find(|bond| bond.atom_i == 0 && bond.atom_j == 2)
.unwrap();
let hh = bond_orders
.bonds
.iter()
.find(|bond| bond.atom_i == 1 && bond.atom_j == 2)
.unwrap();
assert!(oh_1.wiberg > hh.wiberg);
assert!(oh_2.wiberg > hh.wiberg);
assert!(oh_1.mayer > hh.mayer);
assert!(oh_2.mayer > hh.mayer);
}
#[test]
fn test_valence_electrons_period1_period2() {
assert_eq!(valence_electrons(1), 1.0); assert_eq!(valence_electrons(2), 2.0); assert_eq!(valence_electrons(6), 4.0); assert_eq!(valence_electrons(7), 5.0); assert_eq!(valence_electrons(8), 6.0); assert_eq!(valence_electrons(9), 7.0); assert_eq!(valence_electrons(10), 8.0); }
#[test]
fn test_valence_electrons_period6_main_group() {
assert_eq!(valence_electrons(55), 1.0); assert_eq!(valence_electrons(56), 2.0); assert_eq!(valence_electrons(81), 3.0); assert_eq!(valence_electrons(82), 4.0); assert_eq!(valence_electrons(83), 5.0); assert_eq!(valence_electrons(84), 6.0); assert_eq!(valence_electrons(85), 7.0); assert_eq!(valence_electrons(86), 8.0); }
#[test]
fn test_valence_electrons_lanthanides() {
assert_eq!(valence_electrons(57), 3.0); assert_eq!(valence_electrons(58), 4.0); assert_eq!(valence_electrons(63), 9.0); assert_eq!(valence_electrons(64), 10.0); assert_eq!(valence_electrons(70), 16.0); assert_eq!(valence_electrons(71), 17.0); }
#[test]
fn test_valence_electrons_3d_transition_metals() {
assert_eq!(valence_electrons(21), 3.0); assert_eq!(valence_electrons(22), 4.0); assert_eq!(valence_electrons(26), 8.0); assert_eq!(valence_electrons(29), 11.0); assert_eq!(valence_electrons(30), 12.0); }
#[test]
fn test_valence_electrons_4d_transition_metals() {
assert_eq!(valence_electrons(39), 3.0); assert_eq!(valence_electrons(44), 8.0); assert_eq!(valence_electrons(46), 10.0); assert_eq!(valence_electrons(47), 11.0); assert_eq!(valence_electrons(48), 12.0); }
#[test]
fn test_valence_electrons_5d_transition_metals() {
assert_eq!(valence_electrons(72), 4.0); assert_eq!(valence_electrons(74), 6.0); assert_eq!(valence_electrons(76), 8.0); assert_eq!(valence_electrons(78), 10.0); assert_eq!(valence_electrons(79), 11.0); assert_eq!(valence_electrons(80), 12.0); }
#[test]
fn test_valence_electrons_unknown_returns_zero() {
assert_eq!(valence_electrons(87), 0.0); assert_eq!(valence_electrons(0), 0.0); assert_eq!(valence_electrons(255), 0.0);
}
#[cfg(feature = "parallel")]
#[test]
fn test_population_parallel_matches_serial() {
let (elems, pos) = water_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let serial = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
let parallel =
compute_population_parallel(&elems, &pos, &result.coefficients, result.n_electrons);
for i in 0..elems.len() {
assert!(
(serial.mulliken_charges[i] - parallel.mulliken_charges[i]).abs() < 1e-10,
"Mulliken mismatch at atom {}: serial={:.6} vs parallel={:.6}",
i,
serial.mulliken_charges[i],
parallel.mulliken_charges[i]
);
assert!(
(serial.lowdin_charges[i] - parallel.lowdin_charges[i]).abs() < 1e-10,
"Löwdin mismatch at atom {}: serial={:.6} vs parallel={:.6}",
i,
serial.lowdin_charges[i],
parallel.lowdin_charges[i]
);
}
}
#[cfg(feature = "parallel")]
#[test]
fn test_bond_orders_parallel_matches_serial() {
let (elems, pos) = water_molecule();
let result = solve_eht(&elems, &pos, None).unwrap();
let serial = compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
let parallel =
compute_bond_orders_parallel(&elems, &pos, &result.coefficients, result.n_electrons);
assert_eq!(serial.bonds.len(), parallel.bonds.len());
for (s, p) in serial.bonds.iter().zip(parallel.bonds.iter()) {
assert_eq!(s.atom_i, p.atom_i);
assert_eq!(s.atom_j, p.atom_j);
assert!(
(s.wiberg - p.wiberg).abs() < 1e-10,
"Wiberg mismatch ({},{}): {:.6} vs {:.6}",
s.atom_i,
s.atom_j,
s.wiberg,
p.wiberg
);
assert!(
(s.mayer - p.mayer).abs() < 1e-10,
"Mayer mismatch ({},{}): {:.6} vs {:.6}",
s.atom_i,
s.atom_j,
s.mayer,
p.mayer
);
}
}
}