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)]
41pub struct PopulationResult {
42 pub mulliken_charges: Vec<f64>,
44 pub lowdin_charges: Vec<f64>,
46 pub mulliken_populations: Vec<f64>,
48 pub num_atoms: usize,
50 pub total_charge_mulliken: f64,
52 pub total_charge_lowdin: f64,
54}
55
56pub(crate) fn build_density_matrix(coefficients: &[Vec<f64>], n_electrons: usize) -> DMatrix<f64> {
63 let n_ao = coefficients.len();
64 let n_occupied = n_electrons / 2;
65 let mut p = DMatrix::zeros(n_ao, n_ao);
66
67 for i in 0..n_occupied {
68 for mu in 0..n_ao {
69 for nu in 0..n_ao {
70 p[(mu, nu)] += 2.0 * coefficients[mu][i] * coefficients[nu][i];
71 }
72 }
73 }
74
75 if n_electrons % 2 == 1 {
77 let i = n_occupied;
78 for mu in 0..n_ao {
79 for nu in 0..n_ao {
80 p[(mu, nu)] += coefficients[mu][i] * coefficients[nu][i];
81 }
82 }
83 }
84
85 p
86}
87
88fn valence_electrons(z: u8) -> f64 {
90 match z {
91 1 => 1.0,
92 5 => 3.0,
93 6 => 4.0,
94 7 => 5.0,
95 8 => 6.0,
96 9 => 7.0,
97 14 => 4.0,
98 15 => 5.0,
99 16 => 6.0,
100 17 => 7.0,
101 35 => 7.0,
102 53 => 7.0,
103 _ => 0.0,
104 }
105}
106
107pub(crate) fn ao_to_atom_map(basis: &[crate::eht::basis::AtomicOrbital]) -> Vec<usize> {
109 basis.iter().map(|ao| ao.atom_index).collect()
110}
111
112fn lowdin_orthogonalized_density(overlap: &DMatrix<f64>, density: &DMatrix<f64>) -> DMatrix<f64> {
113 let n_ao = overlap.nrows();
114 let s_eigen = SymmetricEigen::new(overlap.clone());
115 let mut s_sqrt_diag = DMatrix::zeros(n_ao, n_ao);
116 for i in 0..n_ao {
117 let val = s_eigen.eigenvalues[i];
118 if val > 1e-10 {
119 s_sqrt_diag[(i, i)] = val.sqrt();
120 }
121 }
122 let s_sqrt = &s_eigen.eigenvectors * &s_sqrt_diag * s_eigen.eigenvectors.transpose();
123 &s_sqrt * density * &s_sqrt
124}
125
126pub fn mulliken_charges(
131 elements: &[u8],
132 basis: &[crate::eht::basis::AtomicOrbital],
133 overlap: &DMatrix<f64>,
134 coefficients: &[Vec<f64>],
135 n_electrons: usize,
136) -> Vec<f64> {
137 let n_ao = basis.len();
138 let n_atoms = elements.len();
139 let ao_map = ao_to_atom_map(basis);
140 let p = build_density_matrix(coefficients, n_electrons);
141
142 let ps = &p * overlap;
144
145 let mut atom_pop = vec![0.0; n_atoms];
147 for mu in 0..n_ao {
148 atom_pop[ao_map[mu]] += ps[(mu, mu)];
149 }
150
151 (0..n_atoms)
153 .map(|a| valence_electrons(elements[a]) - atom_pop[a])
154 .collect()
155}
156
157pub fn lowdin_charges(
162 elements: &[u8],
163 basis: &[crate::eht::basis::AtomicOrbital],
164 overlap: &DMatrix<f64>,
165 coefficients: &[Vec<f64>],
166 n_electrons: usize,
167) -> Vec<f64> {
168 let n_ao = basis.len();
169 let n_atoms = elements.len();
170 let ao_map = ao_to_atom_map(basis);
171 let p = build_density_matrix(coefficients, n_electrons);
172
173 let sps = lowdin_orthogonalized_density(overlap, &p);
175
176 let mut atom_pop = vec![0.0; n_atoms];
177 for mu in 0..n_ao {
178 atom_pop[ao_map[mu]] += sps[(mu, mu)];
179 }
180
181 (0..n_atoms)
182 .map(|a| valence_electrons(elements[a]) - atom_pop[a])
183 .collect()
184}
185
186pub fn compute_population(
188 elements: &[u8],
189 positions: &[[f64; 3]],
190 coefficients: &[Vec<f64>],
191 n_electrons: usize,
192) -> PopulationResult {
193 let basis = crate::eht::basis::build_basis(elements, positions);
194 let overlap = crate::eht::build_overlap_matrix(&basis);
195 let n_ao = basis.len();
196 let n_atoms = elements.len();
197 let ao_map = ao_to_atom_map(&basis);
198 let p = build_density_matrix(coefficients, n_electrons);
199
200 let ps = &p * &overlap;
202 let mut mulliken_pop = vec![0.0; n_atoms];
203 let mut mulliken_ao_pop = vec![0.0; n_ao];
204 for mu in 0..n_ao {
205 mulliken_ao_pop[mu] = ps[(mu, mu)];
206 mulliken_pop[ao_map[mu]] += ps[(mu, mu)];
207 }
208 let mulliken_charges: Vec<f64> = (0..n_atoms)
209 .map(|a| valence_electrons(elements[a]) - mulliken_pop[a])
210 .collect();
211 let total_mulliken: f64 = mulliken_charges.iter().sum();
212
213 let sps = lowdin_orthogonalized_density(&overlap, &p);
215 let mut lowdin_pop = vec![0.0; n_atoms];
216 for mu in 0..n_ao {
217 lowdin_pop[ao_map[mu]] += sps[(mu, mu)];
218 }
219 let lowdin_charges: Vec<f64> = (0..n_atoms)
220 .map(|a| valence_electrons(elements[a]) - lowdin_pop[a])
221 .collect();
222 let total_lowdin: f64 = lowdin_charges.iter().sum();
223
224 PopulationResult {
225 mulliken_charges,
226 lowdin_charges,
227 mulliken_populations: mulliken_ao_pop,
228 num_atoms: n_atoms,
229 total_charge_mulliken: total_mulliken,
230 total_charge_lowdin: total_lowdin,
231 }
232}
233
234pub fn compute_bond_orders(
236 elements: &[u8],
237 positions: &[[f64; 3]],
238 coefficients: &[Vec<f64>],
239 n_electrons: usize,
240) -> BondOrderResult {
241 let basis = crate::eht::basis::build_basis(elements, positions);
242 let overlap = crate::eht::build_overlap_matrix(&basis);
243 let ao_map = ao_to_atom_map(&basis);
244 let density = build_density_matrix(coefficients, n_electrons);
245 let ps = &density * &overlap;
246 let p_orth = lowdin_orthogonalized_density(&overlap, &density);
247
248 let mut atom_aos = vec![Vec::new(); elements.len()];
249 for (ao_idx, &atom_idx) in ao_map.iter().enumerate() {
250 atom_aos[atom_idx].push(ao_idx);
251 }
252
253 let mut bonds = Vec::new();
254 let mut wiberg_valence = vec![0.0; elements.len()];
255 let mut mayer_valence = vec![0.0; elements.len()];
256
257 for atom_i in 0..elements.len() {
258 for atom_j in (atom_i + 1)..elements.len() {
259 let mut wiberg = 0.0;
260 let mut mayer = 0.0;
261
262 for &mu in &atom_aos[atom_i] {
263 for &nu in &atom_aos[atom_j] {
264 let p_orth_mn = p_orth[(mu, nu)];
265 wiberg += p_orth_mn * p_orth_mn;
266 mayer += ps[(mu, nu)] * ps[(nu, mu)];
267 }
268 }
269
270 let dx = positions[atom_i][0] - positions[atom_j][0];
271 let dy = positions[atom_i][1] - positions[atom_j][1];
272 let dz = positions[atom_i][2] - positions[atom_j][2];
273 let distance = (dx * dx + dy * dy + dz * dz).sqrt();
274
275 wiberg_valence[atom_i] += wiberg;
276 wiberg_valence[atom_j] += wiberg;
277 mayer_valence[atom_i] += mayer;
278 mayer_valence[atom_j] += mayer;
279
280 bonds.push(BondOrderEntry {
281 atom_i,
282 atom_j,
283 distance,
284 wiberg,
285 mayer,
286 });
287 }
288 }
289
290 BondOrderResult {
291 bonds,
292 num_atoms: elements.len(),
293 wiberg_valence,
294 mayer_valence,
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::eht::solve_eht;
302
303 fn h2_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
304 (vec![1, 1], vec![[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]])
305 }
306
307 fn water_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
308 (
309 vec![8, 1, 1],
310 vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]],
311 )
312 }
313
314 #[test]
315 fn test_h2_symmetric_charges() {
316 let (elems, pos) = h2_molecule();
317 let result = solve_eht(&elems, &pos, None).unwrap();
318 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
319
320 assert!(
322 (pop.mulliken_charges[0] - pop.mulliken_charges[1]).abs() < 1e-6,
323 "H₂ Mulliken charges should be symmetric"
324 );
325 assert!(
326 pop.mulliken_charges[0].abs() < 0.01,
327 "H₂ charge should be ~0"
328 );
329 }
330
331 #[test]
332 fn test_water_oxygen_negative() {
333 let (elems, pos) = water_molecule();
334 let result = solve_eht(&elems, &pos, None).unwrap();
335 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
336
337 assert!(
339 pop.mulliken_charges[0] < 0.0,
340 "O in water should have negative Mulliken charge, got {}",
341 pop.mulliken_charges[0]
342 );
343 assert!(
344 pop.lowdin_charges[0] < 0.0,
345 "O in water should have negative Löwdin charge, got {}",
346 pop.lowdin_charges[0]
347 );
348 }
349
350 #[test]
351 fn test_charge_sum_conservation() {
352 let (elems, pos) = water_molecule();
353 let result = solve_eht(&elems, &pos, None).unwrap();
354 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
355
356 assert!(
358 pop.total_charge_mulliken.abs() < 0.01,
359 "Mulliken total charge should be ~0, got {}",
360 pop.total_charge_mulliken
361 );
362 assert!(
363 pop.total_charge_lowdin.abs() < 0.01,
364 "Löwdin total charge should be ~0, got {}",
365 pop.total_charge_lowdin
366 );
367 }
368
369 #[test]
370 fn test_hydrogen_symmetry_in_water() {
371 let (elems, pos) = water_molecule();
372 let result = solve_eht(&elems, &pos, None).unwrap();
373 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
374
375 assert!(
377 (pop.mulliken_charges[1] - pop.mulliken_charges[2]).abs() < 0.01,
378 "H charges in water should be symmetric: {} vs {}",
379 pop.mulliken_charges[1],
380 pop.mulliken_charges[2]
381 );
382 }
383
384 #[test]
385 fn test_lowdin_vs_mulliken_different() {
386 let (elems, pos) = water_molecule();
387 let result = solve_eht(&elems, &pos, None).unwrap();
388 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
389
390 let m_o = pop.mulliken_charges[0];
393 let l_o = pop.lowdin_charges[0];
394 assert!(
395 m_o.signum() == l_o.signum(),
396 "Both methods should agree on sign for O"
397 );
398 }
399
400 #[test]
401 fn test_gross_orbital_populations_sum() {
402 let (elems, pos) = h2_molecule();
403 let result = solve_eht(&elems, &pos, None).unwrap();
404 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
405
406 let total: f64 = pop.mulliken_populations.iter().sum();
408 assert!(
409 (total - result.n_electrons as f64).abs() < 0.01,
410 "AO pop sum {} should equal n_electrons {}",
411 total,
412 result.n_electrons
413 );
414 }
415
416 #[test]
417 fn test_h2_bond_order_is_positive() {
418 let (elems, pos) = h2_molecule();
419 let result = solve_eht(&elems, &pos, None).unwrap();
420 let bond_orders =
421 compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
422
423 assert_eq!(bond_orders.bonds.len(), 1);
424 assert!(bond_orders.bonds[0].wiberg > 0.1);
425 assert!(bond_orders.bonds[0].mayer > 0.1);
426 }
427
428 #[test]
429 fn test_water_oh_bonds_exceed_hh_bond_order() {
430 let (elems, pos) = water_molecule();
431 let result = solve_eht(&elems, &pos, None).unwrap();
432 let bond_orders =
433 compute_bond_orders(&elems, &pos, &result.coefficients, result.n_electrons);
434
435 let oh_1 = bond_orders
436 .bonds
437 .iter()
438 .find(|bond| bond.atom_i == 0 && bond.atom_j == 1)
439 .unwrap();
440 let oh_2 = bond_orders
441 .bonds
442 .iter()
443 .find(|bond| bond.atom_i == 0 && bond.atom_j == 2)
444 .unwrap();
445 let hh = bond_orders
446 .bonds
447 .iter()
448 .find(|bond| bond.atom_i == 1 && bond.atom_j == 2)
449 .unwrap();
450
451 assert!(oh_1.wiberg > hh.wiberg);
452 assert!(oh_2.wiberg > hh.wiberg);
453 assert!(oh_1.mayer > hh.mayer);
454 assert!(oh_2.mayer > hh.mayer);
455 }
456}