1use nalgebra::DMatrix;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct NpaResult {
16 pub natural_charges: Vec<f64>,
18 pub natural_config: Vec<NaturalConfig>,
20 pub rydberg_population: f64,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct NaturalConfig {
27 pub atom_index: usize,
29 pub element: u8,
31 pub s_pop: f64,
33 pub p_pop: f64,
35 pub d_pop: f64,
37 pub total: f64,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct NboResult {
44 pub npa: NpaResult,
46 pub bond_orbitals: Vec<NboBond>,
48 pub lone_pairs: Vec<NboLonePair>,
50 pub lewis_population_pct: f64,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct NboBond {
57 pub atom_a: usize,
59 pub atom_b: usize,
61 pub occupancy: f64,
63 pub coeff_a: f64,
65 pub coeff_b: f64,
67 pub bond_type: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct NboLonePair {
74 pub atom_index: usize,
76 pub occupancy: f64,
78 pub orbital_type: String,
80}
81
82pub fn compute_npa(
91 elements: &[u8],
92 overlap: &DMatrix<f64>,
93 density: &DMatrix<f64>,
94 basis_atom_map: &[usize],
95) -> Result<NpaResult, String> {
96 let n_atoms = elements.len();
97 let n_basis = overlap.nrows();
98
99 if density.nrows() != n_basis || density.ncols() != n_basis {
100 return Err("Density matrix dimension mismatch".to_string());
101 }
102 if basis_atom_map.len() != n_basis {
103 return Err("basis_atom_map length must match basis size".to_string());
104 }
105
106 let s_eigen = nalgebra::SymmetricEigen::new(overlap.clone());
108 let mut s_inv_sqrt = DMatrix::zeros(n_basis, n_basis);
109 for i in 0..n_basis {
110 let ev = s_eigen.eigenvalues[i];
111 if ev > 1e-10 {
112 s_inv_sqrt[(i, i)] = 1.0 / ev.sqrt();
113 }
114 }
115 let s_half_inv = &s_eigen.eigenvectors * &s_inv_sqrt * s_eigen.eigenvectors.transpose();
116
117 let d_orth = &s_half_inv * density * &s_half_inv;
120
121 let mut natural_charges = vec![0.0; n_atoms];
123 let mut natural_configs = Vec::with_capacity(n_atoms);
124 let mut total_rydberg = 0.0;
125
126 for atom in 0..n_atoms {
127 let atom_basis: Vec<usize> = (0..n_basis)
129 .filter(|&mu| basis_atom_map[mu] == atom)
130 .collect();
131
132 let n_atom_basis = atom_basis.len();
134 if n_atom_basis == 0 {
135 natural_configs.push(NaturalConfig {
136 atom_index: atom,
137 element: elements[atom],
138 s_pop: 0.0,
139 p_pop: 0.0,
140 d_pop: 0.0,
141 total: 0.0,
142 });
143 continue;
144 }
145
146 let mut d_aa = DMatrix::zeros(n_atom_basis, n_atom_basis);
147 for (ii, &mu) in atom_basis.iter().enumerate() {
148 for (jj, &nu) in atom_basis.iter().enumerate() {
149 d_aa[(ii, jj)] = d_orth[(mu, nu)];
150 }
151 }
152
153 let eigen_aa = nalgebra::SymmetricEigen::new(d_aa);
155 let total_pop: f64 = eigen_aa.eigenvalues.iter().sum();
156
157 let z = elements[atom];
159 let (s_pop, p_pop, d_pop) = classify_shell_populations(&eigen_aa.eigenvalues, z);
160
161 let expected_valence = expected_valence_electrons(z);
163 if total_pop > expected_valence as f64 + 0.5 {
164 total_rydberg += total_pop - expected_valence as f64;
165 }
166
167 let nuclear_charge = z as f64;
168 natural_charges[atom] = nuclear_charge - total_pop;
169
170 natural_configs.push(NaturalConfig {
171 atom_index: atom,
172 element: z,
173 s_pop,
174 p_pop,
175 d_pop,
176 total: total_pop,
177 });
178 }
179
180 Ok(NpaResult {
181 natural_charges,
182 natural_config: natural_configs,
183 rydberg_population: total_rydberg,
184 })
185}
186
187pub fn compute_nbo(
192 elements: &[u8],
193 overlap: &DMatrix<f64>,
194 density: &DMatrix<f64>,
195 basis_atom_map: &[usize],
196 bonds: &[(usize, usize)],
197) -> Result<NboResult, String> {
198 let npa = compute_npa(elements, overlap, density, basis_atom_map)?;
199 let n_basis = overlap.nrows();
200
201 let mut bond_orbitals = Vec::new();
203 let mut lone_pairs = Vec::new();
204
205 for &(a, b) in bonds {
206 let a_basis: Vec<usize> = (0..n_basis).filter(|&mu| basis_atom_map[mu] == a).collect();
207 let b_basis: Vec<usize> = (0..n_basis).filter(|&mu| basis_atom_map[mu] == b).collect();
208
209 if a_basis.is_empty() || b_basis.is_empty() {
210 continue;
211 }
212
213 let na = a_basis.len();
215 let nb = b_basis.len();
216 let mut d_ab = DMatrix::zeros(na + nb, na + nb);
217
218 for (ii, &mu) in a_basis.iter().chain(b_basis.iter()).enumerate() {
219 for (jj, &nu) in a_basis.iter().chain(b_basis.iter()).enumerate() {
220 d_ab[(ii, jj)] = density[(mu, nu)];
221 }
222 }
223
224 let eigen_ab = nalgebra::SymmetricEigen::new(d_ab);
226
227 for (k, &occ) in eigen_ab.eigenvalues.iter().enumerate() {
229 if occ > 1.5 {
230 let col = eigen_ab.eigenvectors.column(k);
231 let a_weight: f64 = col.rows(0, na).iter().map(|c| c * c).sum();
232 let b_weight: f64 = col.rows(na, nb).iter().map(|c| c * c).sum();
233 let total = a_weight + b_weight;
234
235 bond_orbitals.push(NboBond {
236 atom_a: a,
237 atom_b: b,
238 occupancy: occ,
239 coeff_a: (a_weight / (total + 1e-30)).sqrt(),
240 coeff_b: (b_weight / (total + 1e-30)).sqrt(),
241 bond_type: "sigma".to_string(),
242 });
243 }
244 }
245 }
246
247 let n_atoms = elements.len();
249 for atom in 0..n_atoms {
250 let atom_basis: Vec<usize> = (0..n_basis)
251 .filter(|&mu| basis_atom_map[mu] == atom)
252 .collect();
253
254 if atom_basis.is_empty() {
255 continue;
256 }
257
258 let na = atom_basis.len();
259 let mut d_aa = DMatrix::zeros(na, na);
260 for (ii, &mu) in atom_basis.iter().enumerate() {
261 for (jj, &nu) in atom_basis.iter().enumerate() {
262 d_aa[(ii, jj)] = density[(mu, nu)];
263 }
264 }
265
266 let eigen_aa = nalgebra::SymmetricEigen::new(d_aa);
267
268 let bonded_count = bonds
270 .iter()
271 .filter(|&&(a, b)| a == atom || b == atom)
272 .count();
273
274 for (k, &occ) in eigen_aa.eigenvalues.iter().enumerate() {
275 if occ > 1.8 && k >= bonded_count {
276 let orbital_type = if na == 1 {
277 "s"
278 } else if k == 0 {
279 "sp"
280 } else {
281 "p"
282 };
283 lone_pairs.push(NboLonePair {
284 atom_index: atom,
285 occupancy: occ,
286 orbital_type: orbital_type.to_string(),
287 });
288 }
289 }
290 }
291
292 let total_electrons: f64 = npa.natural_config.iter().map(|c| c.total).sum();
294 let lewis_pop: f64 = bond_orbitals.iter().map(|b| b.occupancy).sum::<f64>()
295 + lone_pairs.iter().map(|l| l.occupancy).sum::<f64>();
296 let lewis_pct = if total_electrons > 0.0 {
297 100.0 * lewis_pop / total_electrons
298 } else {
299 0.0
300 };
301
302 Ok(NboResult {
303 npa,
304 bond_orbitals,
305 lone_pairs,
306 lewis_population_pct: lewis_pct,
307 })
308}
309
310fn classify_shell_populations(eigenvalues: &nalgebra::DVector<f64>, z: u8) -> (f64, f64, f64) {
312 let mut sorted: Vec<f64> = eigenvalues.iter().copied().collect();
313 sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
314
315 let (n_s, n_p, n_d) = shell_count(z);
316
317 let mut s_pop = 0.0;
318 let mut p_pop = 0.0;
319 let mut d_pop = 0.0;
320
321 for (i, &occ) in sorted.iter().enumerate() {
322 if occ < 0.0 {
323 continue;
324 }
325 if i < n_s {
326 s_pop += occ;
327 } else if i < n_s + n_p {
328 p_pop += occ;
329 } else if i < n_s + n_p + n_d {
330 d_pop += occ;
331 }
332 }
333
334 (s_pop, p_pop, d_pop)
335}
336
337fn expected_valence_electrons(z: u8) -> usize {
339 match z {
340 1 => 1,
341 2 => 2,
342 3..=4 => (z - 2) as usize,
343 5..=10 => (z - 2) as usize,
344 11..=12 => (z - 10) as usize,
345 13..=18 => (z - 10) as usize,
346 19..=20 => (z - 18) as usize,
347 21..=30 => (z - 18) as usize, 31..=36 => (z - 28) as usize,
349 _ => z.min(8) as usize,
350 }
351}
352
353fn shell_count(z: u8) -> (usize, usize, usize) {
355 match z {
356 1 => (1, 0, 0),
357 2 => (1, 0, 0),
358 3..=4 => (2, 0, 0),
359 5..=10 => (2, 3, 0),
360 11..=12 => (3, 0, 0),
361 13..=18 => (3, 3, 0),
362 19..=20 => (4, 0, 0),
363 21..=30 => (4, 3, 5),
364 31..=36 => (4, 3, 5),
365 _ => (1, 0, 0),
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn test_npa_simple() {
375 let elements = vec![1u8, 1];
377 let overlap = DMatrix::from_row_slice(2, 2, &[1.0, 0.5, 0.5, 1.0]);
378 let density = DMatrix::from_row_slice(2, 2, &[0.5, 0.4, 0.4, 0.5]);
379 let basis_atom_map = vec![0, 1];
380
381 let result = compute_npa(&elements, &overlap, &density, &basis_atom_map);
382 assert!(result.is_ok());
383 let npa = result.unwrap();
384 assert_eq!(npa.natural_charges.len(), 2);
385 assert!((npa.natural_charges[0] - npa.natural_charges[1]).abs() < 0.01);
387 }
388
389 #[test]
390 fn test_expected_valence() {
391 assert_eq!(expected_valence_electrons(1), 1);
392 assert_eq!(expected_valence_electrons(6), 4);
393 assert_eq!(expected_valence_electrons(8), 6);
394 assert_eq!(expected_valence_electrons(26), 8); }
396
397 #[test]
398 fn test_shell_count() {
399 assert_eq!(shell_count(1), (1, 0, 0));
400 assert_eq!(shell_count(6), (2, 3, 0));
401 assert_eq!(shell_count(26), (4, 3, 5));
402 }
403}