1use nalgebra::{DMatrix, SymmetricEigen};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct BondOrderEntry {
14 pub atom_i: usize,
16 pub atom_j: usize,
18 pub distance: f64,
20 pub wiberg: f64,
22 pub mayer: f64,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct BondOrderResult {
29 pub bonds: Vec<BondOrderEntry>,
31 pub num_atoms: usize,
33 pub wiberg_valence: Vec<f64>,
35 pub mayer_valence: Vec<f64>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct PopulationResult {
47 pub mulliken_charges: Vec<f64>,
49 pub lowdin_charges: Vec<f64>,
51 pub mulliken_populations: Vec<f64>,
53 pub num_atoms: usize,
55 pub total_charge_mulliken: f64,
57 pub total_charge_lowdin: f64,
59 pub charge_conservation_error: f64,
62}
63
64pub(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 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
96pub(crate) fn valence_electrons(z: u8) -> f64 {
99 match z {
100 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, 81 => 3.0, 82 => 4.0, 83 => 5.0, _ => 0.0,
176 }
177}
178
179pub(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
198pub 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 let ps = &p * overlap;
216
217 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 (0..n_atoms)
225 .map(|a| valence_electrons(elements[a]) - atom_pop[a])
226 .collect()
227}
228
229pub 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 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
258pub 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 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 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
307pub 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 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 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 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 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 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 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}