1use nalgebra::{DMatrix, SymmetricEigen};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PopulationResult {
14 pub mulliken_charges: Vec<f64>,
16 pub lowdin_charges: Vec<f64>,
18 pub mulliken_populations: Vec<f64>,
20 pub num_atoms: usize,
22 pub total_charge_mulliken: f64,
24 pub total_charge_lowdin: f64,
26}
27
28fn build_density_matrix(coefficients: &[Vec<f64>], n_electrons: usize) -> DMatrix<f64> {
35 let n_ao = coefficients.len();
36 let n_occupied = n_electrons / 2;
37 let mut p = DMatrix::zeros(n_ao, n_ao);
38
39 for i in 0..n_occupied {
40 for mu in 0..n_ao {
41 for nu in 0..n_ao {
42 p[(mu, nu)] += 2.0 * coefficients[mu][i] * coefficients[nu][i];
43 }
44 }
45 }
46
47 if n_electrons % 2 == 1 {
49 let i = n_occupied;
50 for mu in 0..n_ao {
51 for nu in 0..n_ao {
52 p[(mu, nu)] += coefficients[mu][i] * coefficients[nu][i];
53 }
54 }
55 }
56
57 p
58}
59
60fn valence_electrons(z: u8) -> f64 {
62 match z {
63 1 => 1.0,
64 5 => 3.0,
65 6 => 4.0,
66 7 => 5.0,
67 8 => 6.0,
68 9 => 7.0,
69 14 => 4.0,
70 15 => 5.0,
71 16 => 6.0,
72 17 => 7.0,
73 35 => 7.0,
74 53 => 7.0,
75 _ => 0.0,
76 }
77}
78
79fn ao_to_atom_map(basis: &[crate::eht::basis::AtomicOrbital]) -> Vec<usize> {
81 basis.iter().map(|ao| ao.atom_index).collect()
82}
83
84pub fn mulliken_charges(
89 elements: &[u8],
90 basis: &[crate::eht::basis::AtomicOrbital],
91 overlap: &DMatrix<f64>,
92 coefficients: &[Vec<f64>],
93 n_electrons: usize,
94) -> Vec<f64> {
95 let n_ao = basis.len();
96 let n_atoms = elements.len();
97 let ao_map = ao_to_atom_map(basis);
98 let p = build_density_matrix(coefficients, n_electrons);
99
100 let ps = &p * overlap;
102
103 let mut atom_pop = vec![0.0; n_atoms];
105 for mu in 0..n_ao {
106 atom_pop[ao_map[mu]] += ps[(mu, mu)];
107 }
108
109 (0..n_atoms)
111 .map(|a| valence_electrons(elements[a]) - atom_pop[a])
112 .collect()
113}
114
115pub fn lowdin_charges(
120 elements: &[u8],
121 basis: &[crate::eht::basis::AtomicOrbital],
122 overlap: &DMatrix<f64>,
123 coefficients: &[Vec<f64>],
124 n_electrons: usize,
125) -> Vec<f64> {
126 let n_ao = basis.len();
127 let n_atoms = elements.len();
128 let ao_map = ao_to_atom_map(basis);
129 let p = build_density_matrix(coefficients, n_electrons);
130
131 let s_eigen = SymmetricEigen::new(overlap.clone());
133 let mut s_sqrt_diag = DMatrix::zeros(n_ao, n_ao);
134 for i in 0..n_ao {
135 let val = s_eigen.eigenvalues[i];
136 if val > 1e-10 {
137 s_sqrt_diag[(i, i)] = val.sqrt();
138 }
139 }
140 let s_sqrt = &s_eigen.eigenvectors * &s_sqrt_diag * s_eigen.eigenvectors.transpose();
141
142 let sps = &s_sqrt * &p * &s_sqrt;
144
145 let mut atom_pop = vec![0.0; n_atoms];
146 for mu in 0..n_ao {
147 atom_pop[ao_map[mu]] += sps[(mu, mu)];
148 }
149
150 (0..n_atoms)
151 .map(|a| valence_electrons(elements[a]) - atom_pop[a])
152 .collect()
153}
154
155pub fn compute_population(
157 elements: &[u8],
158 positions: &[[f64; 3]],
159 coefficients: &[Vec<f64>],
160 n_electrons: usize,
161) -> PopulationResult {
162 let basis = crate::eht::basis::build_basis(elements, positions);
163 let overlap = crate::eht::build_overlap_matrix(&basis);
164 let n_ao = basis.len();
165 let n_atoms = elements.len();
166 let ao_map = ao_to_atom_map(&basis);
167 let p = build_density_matrix(coefficients, n_electrons);
168
169 let ps = &p * &overlap;
171 let mut mulliken_pop = vec![0.0; n_atoms];
172 let mut mulliken_ao_pop = vec![0.0; n_ao];
173 for mu in 0..n_ao {
174 mulliken_ao_pop[mu] = ps[(mu, mu)];
175 mulliken_pop[ao_map[mu]] += ps[(mu, mu)];
176 }
177 let mulliken_charges: Vec<f64> = (0..n_atoms)
178 .map(|a| valence_electrons(elements[a]) - mulliken_pop[a])
179 .collect();
180 let total_mulliken: f64 = mulliken_charges.iter().sum();
181
182 let s_eigen = SymmetricEigen::new(overlap.clone());
184 let mut s_sqrt_diag = DMatrix::zeros(n_ao, n_ao);
185 for i in 0..n_ao {
186 let val = s_eigen.eigenvalues[i];
187 if val > 1e-10 {
188 s_sqrt_diag[(i, i)] = val.sqrt();
189 }
190 }
191 let s_sqrt = &s_eigen.eigenvectors * &s_sqrt_diag * s_eigen.eigenvectors.transpose();
192 let sps = &s_sqrt * &p * &s_sqrt;
193 let mut lowdin_pop = vec![0.0; n_atoms];
194 for mu in 0..n_ao {
195 lowdin_pop[ao_map[mu]] += sps[(mu, mu)];
196 }
197 let lowdin_charges: Vec<f64> = (0..n_atoms)
198 .map(|a| valence_electrons(elements[a]) - lowdin_pop[a])
199 .collect();
200 let total_lowdin: f64 = lowdin_charges.iter().sum();
201
202 PopulationResult {
203 mulliken_charges,
204 lowdin_charges,
205 mulliken_populations: mulliken_ao_pop,
206 num_atoms: n_atoms,
207 total_charge_mulliken: total_mulliken,
208 total_charge_lowdin: total_lowdin,
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::eht::solve_eht;
216
217 fn h2_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
218 (vec![1, 1], vec![[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]])
219 }
220
221 fn water_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
222 (
223 vec![8, 1, 1],
224 vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]],
225 )
226 }
227
228 #[test]
229 fn test_h2_symmetric_charges() {
230 let (elems, pos) = h2_molecule();
231 let result = solve_eht(&elems, &pos, None).unwrap();
232 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
233
234 assert!(
236 (pop.mulliken_charges[0] - pop.mulliken_charges[1]).abs() < 1e-6,
237 "H₂ Mulliken charges should be symmetric"
238 );
239 assert!(
240 pop.mulliken_charges[0].abs() < 0.01,
241 "H₂ charge should be ~0"
242 );
243 }
244
245 #[test]
246 fn test_water_oxygen_negative() {
247 let (elems, pos) = water_molecule();
248 let result = solve_eht(&elems, &pos, None).unwrap();
249 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
250
251 assert!(
253 pop.mulliken_charges[0] < 0.0,
254 "O in water should have negative Mulliken charge, got {}",
255 pop.mulliken_charges[0]
256 );
257 assert!(
258 pop.lowdin_charges[0] < 0.0,
259 "O in water should have negative Löwdin charge, got {}",
260 pop.lowdin_charges[0]
261 );
262 }
263
264 #[test]
265 fn test_charge_sum_conservation() {
266 let (elems, pos) = water_molecule();
267 let result = solve_eht(&elems, &pos, None).unwrap();
268 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
269
270 assert!(
272 pop.total_charge_mulliken.abs() < 0.01,
273 "Mulliken total charge should be ~0, got {}",
274 pop.total_charge_mulliken
275 );
276 assert!(
277 pop.total_charge_lowdin.abs() < 0.01,
278 "Löwdin total charge should be ~0, got {}",
279 pop.total_charge_lowdin
280 );
281 }
282
283 #[test]
284 fn test_hydrogen_symmetry_in_water() {
285 let (elems, pos) = water_molecule();
286 let result = solve_eht(&elems, &pos, None).unwrap();
287 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
288
289 assert!(
291 (pop.mulliken_charges[1] - pop.mulliken_charges[2]).abs() < 0.01,
292 "H charges in water should be symmetric: {} vs {}",
293 pop.mulliken_charges[1],
294 pop.mulliken_charges[2]
295 );
296 }
297
298 #[test]
299 fn test_lowdin_vs_mulliken_different() {
300 let (elems, pos) = water_molecule();
301 let result = solve_eht(&elems, &pos, None).unwrap();
302 let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
303
304 let m_o = pop.mulliken_charges[0];
307 let l_o = pop.lowdin_charges[0];
308 assert!(
309 m_o.signum() == l_o.signum(),
310 "Both methods should agree on sign for O"
311 );
312 }
313
314 #[test]
315 fn test_gross_orbital_populations_sum() {
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 let total: f64 = pop.mulliken_populations.iter().sum();
322 assert!(
323 (total - result.n_electrons as f64).abs() < 0.01,
324 "AO pop sum {} should equal n_electrons {}",
325 total,
326 result.n_electrons
327 );
328 }
329}