use nalgebra::DMatrix;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpaResult {
pub natural_charges: Vec<f64>,
pub natural_config: Vec<NaturalConfig>,
pub rydberg_population: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NaturalConfig {
pub atom_index: usize,
pub element: u8,
pub s_pop: f64,
pub p_pop: f64,
pub d_pop: f64,
pub total: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NboResult {
pub npa: NpaResult,
pub bond_orbitals: Vec<NboBond>,
pub lone_pairs: Vec<NboLonePair>,
pub lewis_population_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NboBond {
pub atom_a: usize,
pub atom_b: usize,
pub occupancy: f64,
pub coeff_a: f64,
pub coeff_b: f64,
pub bond_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NboLonePair {
pub atom_index: usize,
pub occupancy: f64,
pub orbital_type: String,
}
pub fn compute_npa(
elements: &[u8],
overlap: &DMatrix<f64>,
density: &DMatrix<f64>,
basis_atom_map: &[usize],
) -> Result<NpaResult, String> {
let n_atoms = elements.len();
let n_basis = overlap.nrows();
if density.nrows() != n_basis || density.ncols() != n_basis {
return Err("Density matrix dimension mismatch".to_string());
}
if basis_atom_map.len() != n_basis {
return Err("basis_atom_map length must match basis size".to_string());
}
let s_eigen = nalgebra::SymmetricEigen::new(overlap.clone());
let mut s_inv_sqrt = DMatrix::zeros(n_basis, n_basis);
for i in 0..n_basis {
let ev = s_eigen.eigenvalues[i];
if ev > 1e-10 {
s_inv_sqrt[(i, i)] = 1.0 / ev.sqrt();
}
}
let s_half_inv = &s_eigen.eigenvectors * &s_inv_sqrt * s_eigen.eigenvectors.transpose();
let d_orth = &s_half_inv * density * &s_half_inv;
let mut natural_charges = vec![0.0; n_atoms];
let mut natural_configs = Vec::with_capacity(n_atoms);
let mut total_rydberg = 0.0;
for atom in 0..n_atoms {
let atom_basis: Vec<usize> = (0..n_basis)
.filter(|&mu| basis_atom_map[mu] == atom)
.collect();
let n_atom_basis = atom_basis.len();
if n_atom_basis == 0 {
natural_configs.push(NaturalConfig {
atom_index: atom,
element: elements[atom],
s_pop: 0.0,
p_pop: 0.0,
d_pop: 0.0,
total: 0.0,
});
continue;
}
let mut d_aa = DMatrix::zeros(n_atom_basis, n_atom_basis);
for (ii, &mu) in atom_basis.iter().enumerate() {
for (jj, &nu) in atom_basis.iter().enumerate() {
d_aa[(ii, jj)] = d_orth[(mu, nu)];
}
}
let eigen_aa = nalgebra::SymmetricEigen::new(d_aa);
let total_pop: f64 = eigen_aa.eigenvalues.iter().sum();
let z = elements[atom];
let (s_pop, p_pop, d_pop) = classify_shell_populations(&eigen_aa.eigenvalues, z);
let expected_valence = expected_valence_electrons(z);
if total_pop > expected_valence as f64 + 0.5 {
total_rydberg += total_pop - expected_valence as f64;
}
let nuclear_charge = z as f64;
natural_charges[atom] = nuclear_charge - total_pop;
natural_configs.push(NaturalConfig {
atom_index: atom,
element: z,
s_pop,
p_pop,
d_pop,
total: total_pop,
});
}
Ok(NpaResult {
natural_charges,
natural_config: natural_configs,
rydberg_population: total_rydberg,
})
}
pub fn compute_nbo(
elements: &[u8],
overlap: &DMatrix<f64>,
density: &DMatrix<f64>,
basis_atom_map: &[usize],
bonds: &[(usize, usize)],
) -> Result<NboResult, String> {
let npa = compute_npa(elements, overlap, density, basis_atom_map)?;
let n_basis = overlap.nrows();
let mut bond_orbitals = Vec::new();
let mut lone_pairs = Vec::new();
for &(a, b) in bonds {
let a_basis: Vec<usize> = (0..n_basis).filter(|&mu| basis_atom_map[mu] == a).collect();
let b_basis: Vec<usize> = (0..n_basis).filter(|&mu| basis_atom_map[mu] == b).collect();
if a_basis.is_empty() || b_basis.is_empty() {
continue;
}
let na = a_basis.len();
let nb = b_basis.len();
let mut d_ab = DMatrix::zeros(na + nb, na + nb);
for (ii, &mu) in a_basis.iter().chain(b_basis.iter()).enumerate() {
for (jj, &nu) in a_basis.iter().chain(b_basis.iter()).enumerate() {
d_ab[(ii, jj)] = density[(mu, nu)];
}
}
let eigen_ab = nalgebra::SymmetricEigen::new(d_ab);
for (k, &occ) in eigen_ab.eigenvalues.iter().enumerate() {
if occ > 1.5 {
let col = eigen_ab.eigenvectors.column(k);
let a_weight: f64 = col.rows(0, na).iter().map(|c| c * c).sum();
let b_weight: f64 = col.rows(na, nb).iter().map(|c| c * c).sum();
let total = a_weight + b_weight;
bond_orbitals.push(NboBond {
atom_a: a,
atom_b: b,
occupancy: occ,
coeff_a: (a_weight / (total + 1e-30)).sqrt(),
coeff_b: (b_weight / (total + 1e-30)).sqrt(),
bond_type: "sigma".to_string(),
});
}
}
}
let n_atoms = elements.len();
for atom in 0..n_atoms {
let atom_basis: Vec<usize> = (0..n_basis)
.filter(|&mu| basis_atom_map[mu] == atom)
.collect();
if atom_basis.is_empty() {
continue;
}
let na = atom_basis.len();
let mut d_aa = DMatrix::zeros(na, na);
for (ii, &mu) in atom_basis.iter().enumerate() {
for (jj, &nu) in atom_basis.iter().enumerate() {
d_aa[(ii, jj)] = density[(mu, nu)];
}
}
let eigen_aa = nalgebra::SymmetricEigen::new(d_aa);
let bonded_count = bonds
.iter()
.filter(|&&(a, b)| a == atom || b == atom)
.count();
for (k, &occ) in eigen_aa.eigenvalues.iter().enumerate() {
if occ > 1.8 && k >= bonded_count {
let orbital_type = if na == 1 {
"s"
} else if k == 0 {
"sp"
} else {
"p"
};
lone_pairs.push(NboLonePair {
atom_index: atom,
occupancy: occ,
orbital_type: orbital_type.to_string(),
});
}
}
}
let total_electrons: f64 = npa.natural_config.iter().map(|c| c.total).sum();
let lewis_pop: f64 = bond_orbitals.iter().map(|b| b.occupancy).sum::<f64>()
+ lone_pairs.iter().map(|l| l.occupancy).sum::<f64>();
let lewis_pct = if total_electrons > 0.0 {
100.0 * lewis_pop / total_electrons
} else {
0.0
};
Ok(NboResult {
npa,
bond_orbitals,
lone_pairs,
lewis_population_pct: lewis_pct,
})
}
fn classify_shell_populations(eigenvalues: &nalgebra::DVector<f64>, z: u8) -> (f64, f64, f64) {
let mut sorted: Vec<f64> = eigenvalues.iter().copied().collect();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
let (n_s, n_p, n_d) = shell_count(z);
let mut s_pop = 0.0;
let mut p_pop = 0.0;
let mut d_pop = 0.0;
for (i, &occ) in sorted.iter().enumerate() {
if occ < 0.0 {
continue;
}
if i < n_s {
s_pop += occ;
} else if i < n_s + n_p {
p_pop += occ;
} else if i < n_s + n_p + n_d {
d_pop += occ;
}
}
(s_pop, p_pop, d_pop)
}
fn expected_valence_electrons(z: u8) -> usize {
match z {
1 => 1,
2 => 2,
3..=4 => (z - 2) as usize,
5..=10 => (z - 2) as usize,
11..=12 => (z - 10) as usize,
13..=18 => (z - 10) as usize,
19..=20 => (z - 18) as usize,
21..=30 => (z - 18) as usize, 31..=36 => (z - 28) as usize,
_ => z.min(8) as usize,
}
}
fn shell_count(z: u8) -> (usize, usize, usize) {
match z {
1 => (1, 0, 0),
2 => (1, 0, 0),
3..=4 => (2, 0, 0),
5..=10 => (2, 3, 0),
11..=12 => (3, 0, 0),
13..=18 => (3, 3, 0),
19..=20 => (4, 0, 0),
21..=30 => (4, 3, 5),
31..=36 => (4, 3, 5),
_ => (1, 0, 0),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_npa_simple() {
let elements = vec![1u8, 1];
let overlap = DMatrix::from_row_slice(2, 2, &[1.0, 0.5, 0.5, 1.0]);
let density = DMatrix::from_row_slice(2, 2, &[0.5, 0.4, 0.4, 0.5]);
let basis_atom_map = vec![0, 1];
let result = compute_npa(&elements, &overlap, &density, &basis_atom_map);
assert!(result.is_ok());
let npa = result.unwrap();
assert_eq!(npa.natural_charges.len(), 2);
assert!((npa.natural_charges[0] - npa.natural_charges[1]).abs() < 0.01);
}
#[test]
fn test_expected_valence() {
assert_eq!(expected_valence_electrons(1), 1);
assert_eq!(expected_valence_electrons(6), 4);
assert_eq!(expected_valence_electrons(8), 6);
assert_eq!(expected_valence_electrons(26), 8); }
#[test]
fn test_shell_count() {
assert_eq!(shell_count(1), (1, 0, 0));
assert_eq!(shell_count(6), (2, 3, 0));
assert_eq!(shell_count(26), (4, 3, 5));
}
}