Skip to main content

sci_form/pm3/
solver.rs

1//! PM3 NDDO solver: builds Fock matrix, runs SCF, returns energies.
2//!
3//! Implements the core PM3/NDDO workflow:
4//! 1. Build minimal basis (s for H, s+p for heavy atoms)
5//! 2. Compute overlap integrals using Slater-type approximation
6//! 3. Build core Hamiltonian from one-center and resonance integrals
7//! 4. Run restricted Hartree-Fock SCF with NDDO two-electron integrals
8//! 5. Return orbital energies, total energy, heat of formation
9
10use super::params::{count_pm3_electrons, get_pm3_params, num_pm3_basis_functions};
11use nalgebra::DMatrix;
12use serde::{Deserialize, Serialize};
13
14/// PM3 calculation result.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Pm3Result {
17    /// Orbital energies (eV), sorted ascending.
18    pub orbital_energies: Vec<f64>,
19    /// Electronic energy (eV).
20    pub electronic_energy: f64,
21    /// Nuclear repulsion energy (eV).
22    pub nuclear_repulsion: f64,
23    /// Total energy (eV) = electronic + nuclear.
24    pub total_energy: f64,
25    /// Heat of formation estimate (kcal/mol).
26    pub heat_of_formation: f64,
27    /// Number of basis functions.
28    pub n_basis: usize,
29    /// Number of electrons.
30    pub n_electrons: usize,
31    /// HOMO energy (eV).
32    pub homo_energy: f64,
33    /// LUMO energy (eV).
34    pub lumo_energy: f64,
35    /// HOMO-LUMO gap (eV).
36    pub gap: f64,
37    /// Mulliken charges from PM3 density.
38    pub mulliken_charges: Vec<f64>,
39    /// Number of SCF iterations to convergence.
40    pub scf_iterations: usize,
41    /// Whether SCF converged.
42    pub converged: bool,
43}
44
45const EV_TO_KCAL: f64 = 23.0605;
46const BOHR_TO_ANGSTROM: f64 = 0.529177;
47const ANGSTROM_TO_BOHR: f64 = 1.0 / BOHR_TO_ANGSTROM;
48
49/// Compute the distance between two atoms in bohr.
50fn distance_bohr(pos_a: &[f64; 3], pos_b: &[f64; 3]) -> f64 {
51    let dx = (pos_a[0] - pos_b[0]) * ANGSTROM_TO_BOHR;
52    let dy = (pos_a[1] - pos_b[1]) * ANGSTROM_TO_BOHR;
53    let dz = (pos_a[2] - pos_b[2]) * ANGSTROM_TO_BOHR;
54    (dx * dx + dy * dy + dz * dz).sqrt()
55}
56
57/// STO overlap integral S(n,zeta_a,n,zeta_b,R) for s-s overlap.
58fn sto_ss_overlap(zeta_a: f64, zeta_b: f64, r_bohr: f64) -> f64 {
59    if r_bohr < 1e-10 {
60        return if (zeta_a - zeta_b).abs() < 1e-10 {
61            1.0
62        } else {
63            0.0
64        };
65    }
66    let p = 0.5 * (zeta_a + zeta_b) * r_bohr;
67    let t = 0.5 * (zeta_a - zeta_b) * r_bohr;
68
69    if p.abs() < 1e-10 {
70        return 0.0;
71    }
72
73    // Mulliken approximation for general STO overlap
74    let a_func = |x: f64| -> f64 {
75        if x.abs() < 1e-8 {
76            1.0
77        } else {
78            (-x).exp() * (1.0 + x + x * x / 3.0)
79        }
80    };
81    let b_func = |x: f64| -> f64 {
82        if x.abs() < 1e-8 {
83            1.0
84        } else {
85            x.exp() * (1.0 - x + x * x / 3.0) - (-x).exp() * (1.0 + x + x * x / 3.0)
86        }
87    };
88
89    let s = a_func(p) * b_func(t.abs());
90    s.clamp(-1.0, 1.0)
91}
92
93/// Build the basis function mapping: for each basis function, which atom and which orbital type.
94fn build_basis_map(elements: &[u8]) -> Vec<(usize, u8, u8)> {
95    // Returns (atom_index, l, m_offset)
96    let mut basis = Vec::new();
97    for (i, &z) in elements.iter().enumerate() {
98        let n_bf = num_pm3_basis_functions(z);
99        if n_bf >= 1 {
100            basis.push((i, 0, 0)); // s orbital
101        }
102        if n_bf >= 4 {
103            basis.push((i, 1, 0)); // px
104            basis.push((i, 1, 1)); // py
105            basis.push((i, 1, 2)); // pz
106        }
107    }
108    basis
109}
110
111/// Run PM3 calculation on a molecule.
112///
113/// `elements`: atomic numbers.
114/// `positions`: Cartesian coordinates in Å (one [x,y,z] per atom).
115///
116/// Returns `Pm3Result` with orbital energies, total energy, and heat of formation.
117pub fn solve_pm3(elements: &[u8], positions: &[[f64; 3]]) -> Result<Pm3Result, String> {
118    if elements.len() != positions.len() {
119        return Err(format!(
120            "elements ({}) and positions ({}) length mismatch",
121            elements.len(),
122            positions.len()
123        ));
124    }
125
126    // Check all elements are supported
127    for &z in elements {
128        if get_pm3_params(z).is_none() {
129            return Err(format!("PM3 parameters not available for Z={}", z));
130        }
131    }
132
133    let n_atoms = elements.len();
134    let basis_map = build_basis_map(elements);
135    let n_basis = basis_map.len();
136    let n_electrons = count_pm3_electrons(elements);
137    let n_occ = n_electrons / 2;
138
139    if n_basis == 0 {
140        return Err("No basis functions".to_string());
141    }
142
143    // Build overlap matrix
144    let mut s_mat = DMatrix::zeros(n_basis, n_basis);
145    for i in 0..n_basis {
146        s_mat[(i, i)] = 1.0;
147        let (atom_a, la, _) = basis_map[i];
148        for j in (i + 1)..n_basis {
149            let (atom_b, lb, _) = basis_map[j];
150            if atom_a == atom_b {
151                // Same atom: orthogonal
152                continue;
153            }
154            let r = distance_bohr(&positions[atom_a], &positions[atom_b]);
155            let pa = get_pm3_params(elements[atom_a]).unwrap();
156            let pb = get_pm3_params(elements[atom_b]).unwrap();
157            // Only compute s-s overlap; s-p and p-p use simplified Mulliken approx
158            if la == 0 && lb == 0 {
159                let sij = sto_ss_overlap(pa.zeta_s, pb.zeta_s, r);
160                s_mat[(i, j)] = sij;
161                s_mat[(j, i)] = sij;
162            } else {
163                // Simplified: for σ-type overlaps use directional cosines
164                let za = if la == 0 { pa.zeta_s } else { pa.zeta_p };
165                let zb = if lb == 0 { pb.zeta_s } else { pb.zeta_p };
166                let sij = sto_ss_overlap(za, zb, r) * 0.5; // approximate
167                s_mat[(i, j)] = sij;
168                s_mat[(j, i)] = sij;
169            }
170        }
171    }
172
173    // Build core Hamiltonian
174    let mut h_core = DMatrix::zeros(n_basis, n_basis);
175
176    // Diagonal: one-center integrals
177    for i in 0..n_basis {
178        let (atom_a, la, _) = basis_map[i];
179        let pa = get_pm3_params(elements[atom_a]).unwrap();
180        h_core[(i, i)] = if la == 0 { pa.uss } else { pa.upp };
181    }
182
183    // Off-diagonal: resonance integrals (Wolfsberg-Helmholtz-like)
184    for i in 0..n_basis {
185        let (atom_a, la, _) = basis_map[i];
186        for j in (i + 1)..n_basis {
187            let (atom_b, lb, _) = basis_map[j];
188            if atom_a == atom_b {
189                continue;
190            }
191            let pa = get_pm3_params(elements[atom_a]).unwrap();
192            let pb = get_pm3_params(elements[atom_b]).unwrap();
193            let beta_a = if la == 0 { pa.beta_s } else { pa.beta_p };
194            let beta_b = if lb == 0 { pb.beta_s } else { pb.beta_p };
195            let hij = 0.5 * (beta_a + beta_b) * s_mat[(i, j)];
196            h_core[(i, j)] = hij;
197            h_core[(j, i)] = hij;
198        }
199    }
200
201    // Nuclear repulsion energy (core-core)
202    let mut e_nuc = 0.0;
203    for a in 0..n_atoms {
204        let pa = get_pm3_params(elements[a]).unwrap();
205        for b in (a + 1)..n_atoms {
206            let pb = get_pm3_params(elements[b]).unwrap();
207            let r_bohr = distance_bohr(&positions[a], &positions[b]);
208            let r_angstrom = r_bohr * BOHR_TO_ANGSTROM;
209            if r_angstrom < 0.1 {
210                continue;
211            }
212
213            // PM3 core-core repulsion: Z_a * Z_b * gamma_ss * f(R)
214            let _gamma_ss = 1.0 / r_bohr; // Coulomb term in eV
215            let ev_per_hartree = 27.2114;
216            let gamma = ev_per_hartree / r_bohr.max(0.1);
217
218            let alpha_term = (-pa.alpha * r_angstrom).exp() + (-pb.alpha * r_angstrom).exp();
219            e_nuc += pa.core_charge * pb.core_charge * gamma * (1.0 + alpha_term);
220        }
221    }
222
223    // SCF procedure
224    let max_iter = 200;
225    let convergence_threshold = 1e-5;
226
227    // Initial density matrix from core Hamiltonian eigenvectors
228    let mut density = DMatrix::zeros(n_basis, n_basis);
229    let mut fock = h_core.clone();
230    let mut orbital_energies = vec![0.0; n_basis];
231    let mut coefficients = DMatrix::zeros(n_basis, n_basis);
232    let mut converged = false;
233    let mut scf_iter = 0;
234    let mut prev_energy = 0.0;
235
236    for iter in 0..max_iter {
237        scf_iter = iter + 1;
238
239        // Solve generalized eigenvalue problem FC = SCε
240        // Use Löwdin orthogonalization: S^{-1/2} F S^{-1/2}
241        let s_eigen = s_mat.clone().symmetric_eigen();
242        let mut s_half_inv = DMatrix::zeros(n_basis, n_basis);
243        for k in 0..n_basis {
244            let val = s_eigen.eigenvalues[k];
245            if val > 1e-8 {
246                let inv_sqrt = 1.0 / val.sqrt();
247                let col = s_eigen.eigenvectors.column(k);
248                for i in 0..n_basis {
249                    for j in 0..n_basis {
250                        s_half_inv[(i, j)] += inv_sqrt * col[i] * col[j];
251                    }
252                }
253            }
254        }
255
256        let f_prime = &s_half_inv * &fock * &s_half_inv;
257        let eigen = f_prime.symmetric_eigen();
258
259        // Sort eigenvalues
260        let mut indices: Vec<usize> = (0..n_basis).collect();
261        indices.sort_by(|&a, &b| {
262            eigen.eigenvalues[a]
263                .partial_cmp(&eigen.eigenvalues[b])
264                .unwrap_or(std::cmp::Ordering::Equal)
265        });
266
267        for (new_idx, &old_idx) in indices.iter().enumerate() {
268            orbital_energies[new_idx] = eigen.eigenvalues[old_idx];
269        }
270
271        // Back-transform coefficients
272        let c_prime = &eigen.eigenvectors;
273        let c_full = &s_half_inv * c_prime;
274
275        // Rebuild with sorted order
276        for new_idx in 0..n_basis {
277            let old_idx = indices[new_idx];
278            for i in 0..n_basis {
279                coefficients[(i, new_idx)] = c_full[(i, old_idx)];
280            }
281        }
282
283        // Build density matrix: P_ij = 2 * Σ_occ C_ia * C_ja
284        let mut new_density = DMatrix::zeros(n_basis, n_basis);
285        for i in 0..n_basis {
286            for j in 0..n_basis {
287                let mut val = 0.0;
288                for k in 0..n_occ.min(n_basis) {
289                    val += coefficients[(i, k)] * coefficients[(j, k)];
290                }
291                new_density[(i, j)] = 2.0 * val;
292            }
293        }
294
295        // Electronic energy
296        let mut e_elec = 0.0;
297        for i in 0..n_basis {
298            for j in 0..n_basis {
299                e_elec += 0.5 * new_density[(i, j)] * (h_core[(i, j)] + fock[(i, j)]);
300            }
301        }
302
303        // Check convergence
304        if (e_elec - prev_energy).abs() < convergence_threshold && iter > 0 {
305            converged = true;
306            density = new_density;
307            break;
308        }
309
310        prev_energy = e_elec;
311
312        // Build new Fock matrix: F = H_core + G(P)
313        // G_ij = Σ_kl P_kl * [(ij|kl) - 0.5*(ik|jl)]
314        // In NDDO approximation, many integrals vanish
315        let mut g_mat = DMatrix::zeros(n_basis, n_basis);
316
317        // One-center two-electron integrals
318        for i in 0..n_basis {
319            let (atom_a, la, _ma) = basis_map[i];
320            let pa = get_pm3_params(elements[atom_a]).unwrap();
321            for j in 0..n_basis {
322                let (atom_b, lb, _mb) = basis_map[j];
323                if atom_a == atom_b {
324                    // Same atom: use one-center integrals
325                    let gij = if la == 0 && lb == 0 {
326                        pa.gss
327                    } else if (la == 0 && lb == 1) || (la == 1 && lb == 0) {
328                        pa.gsp
329                    } else if la == 1 && lb == 1 {
330                        pa.gpp
331                    } else {
332                        0.0
333                    };
334
335                    // Coulomb contribution
336                    g_mat[(i, i)] += new_density[(j, j)] * gij;
337                    if i != j {
338                        // Exchange contribution (approximate)
339                        g_mat[(i, j)] -= 0.5 * new_density[(i, j)] * gij;
340                    }
341                }
342            }
343
344            // Two-center Coulomb: interaction with other atoms
345            for b in 0..n_atoms {
346                if b == atom_a {
347                    continue;
348                }
349                let r_bohr = distance_bohr(&positions[atom_a], &positions[b]);
350                let ev_per_hartree = 27.2114;
351                let gamma_ab = ev_per_hartree / r_bohr.max(0.5);
352
353                // Sum density on atom b
354                let mut p_b = 0.0;
355                for k in 0..n_basis {
356                    if basis_map[k].0 == b {
357                        p_b += new_density[(k, k)];
358                    }
359                }
360                // Two-center electron-electron Coulomb repulsion
361                // (electron-nuclear attraction is already in h_core)
362                g_mat[(i, i)] += p_b * gamma_ab;
363            }
364        }
365
366        // Damped density mixing for SCF stability
367        // Use more conservative mixing (higher damp) at later iterations
368        let damp = if iter < 5 {
369            0.3
370        } else if iter < 30 {
371            0.5
372        } else {
373            0.7
374        };
375        density = &density * damp + &new_density * (1.0 - damp);
376
377        // New Fock matrix
378        fock = &h_core + &g_mat;
379    }
380
381    // Final electronic energy
382    let mut e_elec = 0.0;
383    for i in 0..n_basis {
384        for j in 0..n_basis {
385            e_elec += 0.5 * density[(i, j)] * (h_core[(i, j)] + fock[(i, j)]);
386        }
387    }
388
389    let total_energy = e_elec + e_nuc;
390
391    // Heat of formation: ΔHf = total_energy - Σ E_atom + Σ ΔHf_atom
392    let mut e_atom_sum = 0.0;
393    let mut dhf_atom_sum = 0.0;
394    for &z in elements {
395        let p = get_pm3_params(z).unwrap();
396        // Isolated atom energy approximation
397        e_atom_sum += if z == 1 {
398            p.uss * p.core_charge * 0.5
399        } else {
400            (p.uss + 3.0 * p.upp) * 0.25 * p.core_charge
401        };
402        dhf_atom_sum += p.heat_of_atomization;
403    }
404    let heat_of_formation = (total_energy - e_atom_sum) * EV_TO_KCAL + dhf_atom_sum;
405
406    // Mulliken charges
407    let sp = &density * &s_mat;
408    let mut mulliken_charges = Vec::with_capacity(n_atoms);
409    for a in 0..n_atoms {
410        let pa = get_pm3_params(elements[a]).unwrap();
411        let mut pop = 0.0;
412        for i in 0..n_basis {
413            if basis_map[i].0 == a {
414                pop += sp[(i, i)];
415            }
416        }
417        mulliken_charges.push(pa.core_charge - pop);
418    }
419
420    let homo_idx = if n_occ > 0 { n_occ - 1 } else { 0 };
421    let lumo_idx = n_occ.min(n_basis - 1);
422    let homo_energy = orbital_energies[homo_idx];
423    let lumo_energy = if n_occ < n_basis {
424        orbital_energies[lumo_idx]
425    } else {
426        homo_energy
427    };
428    let gap = if n_occ < n_basis {
429        lumo_energy - homo_energy
430    } else {
431        0.0
432    };
433
434    Ok(Pm3Result {
435        orbital_energies,
436        electronic_energy: e_elec,
437        nuclear_repulsion: e_nuc,
438        total_energy,
439        heat_of_formation,
440        n_basis,
441        n_electrons,
442        homo_energy,
443        lumo_energy,
444        gap,
445        mulliken_charges,
446        scf_iterations: scf_iter,
447        converged,
448    })
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn test_pm3_h2() {
457        let elements = [1u8, 1];
458        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
459        let result = solve_pm3(&elements, &positions).unwrap();
460        assert_eq!(result.n_basis, 2);
461        assert_eq!(result.n_electrons, 2);
462        assert!(result.total_energy.is_finite());
463        assert!(result.gap >= 0.0);
464        // H2 charges should be zero by symmetry
465        assert!((result.mulliken_charges[0] - result.mulliken_charges[1]).abs() < 0.01);
466    }
467
468    #[test]
469    fn test_pm3_water() {
470        let elements = [8u8, 1, 1];
471        let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
472        let result = solve_pm3(&elements, &positions).unwrap();
473        assert_eq!(result.n_basis, 6); // O: s+3p, 2H: s each
474        assert_eq!(result.n_electrons, 8);
475        assert!(result.total_energy.is_finite());
476        assert!(
477            result.gap > 0.0,
478            "Water should have a positive HOMO-LUMO gap"
479        );
480        // Check charge separation exists (O more negative than H, or at least different)
481        assert!(
482            (result.mulliken_charges[0] - result.mulliken_charges[1]).abs() > 0.001,
483            "O and H charges should differ"
484        );
485    }
486
487    #[test]
488    fn test_pm3_methane() {
489        let elements = [6u8, 1, 1, 1, 1];
490        let positions = [
491            [0.0, 0.0, 0.0],
492            [0.629, 0.629, 0.629],
493            [-0.629, -0.629, 0.629],
494            [0.629, -0.629, -0.629],
495            [-0.629, 0.629, -0.629],
496        ];
497        let result = solve_pm3(&elements, &positions).unwrap();
498        assert_eq!(result.n_basis, 8); // C: s+3p, 4H: s each
499        assert_eq!(result.n_electrons, 8);
500        assert!(result.total_energy.is_finite());
501    }
502
503    #[test]
504    fn test_pm3_unsupported_element() {
505        let elements = [26u8, 17]; // Fe-Cl
506        let positions = [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
507        assert!(solve_pm3(&elements, &positions).is_err());
508    }
509}