Skip to main content

sci_form/population/
npa.rs

1//! Natural Population Analysis (NPA) and Natural Bond Orbital (NBO) analysis.
2//!
3//! NPA provides basis-independent atomic charges by constructing Natural
4//! Atomic Orbitals (NAOs) that diagonalize the on-atom density blocks.
5//! NBO extends this to identify Lewis structure bonding patterns.
6//!
7//! Reference: Reed, A. E.; Weinstock, R. B.; Weinhold, F.
8//! "Natural population analysis." JCP 83 (1985): 735.
9
10use nalgebra::DMatrix;
11use serde::{Deserialize, Serialize};
12
13/// Natural Population Analysis result.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct NpaResult {
16    /// Natural charges per atom (e).
17    pub natural_charges: Vec<f64>,
18    /// Natural electron configuration per atom (s, p, d contributions).
19    pub natural_config: Vec<NaturalConfig>,
20    /// Total Rydberg population (electron count in diffuse/Rydberg NAOs).
21    pub rydberg_population: f64,
22}
23
24/// Natural electron configuration for one atom.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct NaturalConfig {
27    /// Atom index.
28    pub atom_index: usize,
29    /// Element atomic number.
30    pub element: u8,
31    /// s-orbital population.
32    pub s_pop: f64,
33    /// p-orbital population.
34    pub p_pop: f64,
35    /// d-orbital population.
36    pub d_pop: f64,
37    /// Total natural population.
38    pub total: f64,
39}
40
41/// Natural Bond Orbital result.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct NboResult {
44    /// NPA charges.
45    pub npa: NpaResult,
46    /// Lewis-structure bond orbitals.
47    pub bond_orbitals: Vec<NboBond>,
48    /// Lone pair orbitals.
49    pub lone_pairs: Vec<NboLonePair>,
50    /// Percentage of electrons in the natural Lewis structure.
51    pub lewis_population_pct: f64,
52}
53
54/// An NBO bonding orbital.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct NboBond {
57    /// Atom A index.
58    pub atom_a: usize,
59    /// Atom B index.
60    pub atom_b: usize,
61    /// Occupancy (ideally ~2.0).
62    pub occupancy: f64,
63    /// Polarization coefficient on A.
64    pub coeff_a: f64,
65    /// Polarization coefficient on B.
66    pub coeff_b: f64,
67    /// Bond type: "sigma", "pi", "delta".
68    pub bond_type: String,
69}
70
71/// An NBO lone pair orbital.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct NboLonePair {
74    /// Atom index.
75    pub atom_index: usize,
76    /// Occupancy (ideally ~2.0).
77    pub occupancy: f64,
78    /// Orbital type: "s", "p", "sp", "sp2", "sp3".
79    pub orbital_type: String,
80}
81
82/// Compute Natural Population Analysis from density and overlap matrices.
83///
84/// The NPA algorithm:
85/// 1. Build density matrix: D = C·n·C^T
86/// 2. For each atom A, extract the density sub-block D_AA
87/// 3. Diagonalize D_AA to get pre-NAOs with natural occupancies
88/// 4. Orthogonalize inter-atom overlaps (occupancy-weighted symmetric)
89/// 5. Sum resulting occupancies per atom → natural charges
90pub 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    // Step 1: Compute S^{-1/2} for Löwdin orthogonalization
107    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    // Step 2: Transform density to orthogonal basis
118    // D_orth = S^{-1/2} · D · S^{-1/2}
119    let d_orth = &s_half_inv * density * &s_half_inv;
120
121    // Step 3: Compute per-atom natural populations
122    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        // Find basis functions on this atom
128        let atom_basis: Vec<usize> = (0..n_basis)
129            .filter(|&mu| basis_atom_map[mu] == atom)
130            .collect();
131
132        // Extract atomic density sub-block
133        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        // Diagonalize atomic block → pre-NAO occupancies
154        let eigen_aa = nalgebra::SymmetricEigen::new(d_aa);
155        let total_pop: f64 = eigen_aa.eigenvalues.iter().sum();
156
157        // Classify occupancies by angular momentum (simplified)
158        let z = elements[atom];
159        let (s_pop, p_pop, d_pop) = classify_shell_populations(&eigen_aa.eigenvalues, z);
160
161        // Determine "core" + "valence" vs "Rydberg"
162        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
187/// Compute NBO analysis (NPA + bond/lone-pair identification).
188///
189/// Starting from NPA results, identifies Lewis-structure bonding patterns
190/// by analyzing the inter-atomic density blocks.
191pub 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    // Identify bond orbitals from inter-atomic density blocks
202    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        // Extract AB density block
214        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        // Diagonalize AB block
225        let eigen_ab = nalgebra::SymmetricEigen::new(d_ab);
226
227        // Bond orbitals: eigenvalues near 2.0
228        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    // Identify lone pairs from single-atom density blocks
248    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        // Lone pairs: high occupancy NAOs not involved in bonding
269        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    // Lewis population percentage
293    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
310/// Classify eigenvalues by angular momentum shell.
311fn 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
337/// Expected number of valence electrons for an element.
338fn 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, // TMs
348        31..=36 => (z - 28) as usize,
349        _ => z.min(8) as usize,
350    }
351}
352
353/// Expected shell counts (s, p, d) for the minimal basis of element z.
354fn 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        // 2-basis system: one function on each atom
376        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        // H2: symmetric → charges should be near zero
386        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); // Fe
395    }
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}