Skip to main content

sci_form/pm3/
gradients.rs

1//! Analytical gradients for PM3 NDDO calculations.
2//!
3//! Computes dE/dR for each atom using the converged SCF density matrix
4//! and the Hellmann-Feynman + Pulay force expression:
5//!
6//!   dE/dR_Aα = Tr(P·∂H/∂R_Aα) + 0.5·Tr(P·∂G/∂R_Aα) − Tr(W·∂S/∂R_Aα) + ∂E_nuc/∂R_Aα
7//!
8//! Nuclear repulsion and Coulomb derivatives are fully analytical.
9//! Overlap derivatives use micro-numerical finite differences on the STO
10//! overlap function (NOT on the SCF energy).
11
12use nalgebra::DMatrix;
13use serde::{Deserialize, Serialize};
14
15use super::params::get_pm3_params;
16use super::solver::{
17    screened_coulomb_gamma_derivative_ev_per_angstrom, screened_coulomb_gamma_ev,
18    solve_pm3_with_state, sto_ss_overlap, ANGSTROM_TO_BOHR,
19};
20
21/// Result of PM3 gradient computation.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Pm3GradientResult {
24    /// Energy gradient dE/dR per atom in eV/Å.
25    pub gradients: Vec<[f64; 3]>,
26    /// Total PM3 energy in eV.
27    pub energy: f64,
28    /// Heat of formation in kcal/mol.
29    pub heat_of_formation: f64,
30}
31
32/// Compute analytical PM3 energy gradients.
33///
34/// Runs a full PM3 SCF, then computes the gradient from the converged
35/// density matrix using Hellmann-Feynman + Pulay corrections.
36///
37/// Returns gradient dE/dR in eV/Å for each atom.
38pub fn compute_pm3_gradient(
39    elements: &[u8],
40    positions: &[[f64; 3]],
41) -> Result<Pm3GradientResult, String> {
42    let (result, state) = solve_pm3_with_state(elements, positions)?;
43
44    let n_atoms = elements.len();
45    let n_basis = state.basis_map.len();
46    let n_occ = state.n_occ;
47
48    // Build energy-weighted density matrix: W_μν = 2·Σ_{k∈occ} ε_k·C_μk·C_νk
49    let mut w_mat = DMatrix::zeros(n_basis, n_basis);
50    for i in 0..n_basis {
51        for j in 0..n_basis {
52            let mut val = 0.0;
53            for k in 0..n_occ.min(n_basis) {
54                val += state.orbital_energies[k]
55                    * state.coefficients[(i, k)]
56                    * state.coefficients[(j, k)];
57            }
58            w_mat[(i, j)] = 2.0 * val;
59        }
60    }
61
62    // Compute atom populations for two-center Coulomb
63    let mut atom_pop = vec![0.0f64; n_atoms];
64    for mu in 0..n_basis {
65        atom_pop[state.basis_map[mu].0] += state.density[(mu, mu)];
66    }
67
68    let mut gradients = vec![[0.0f64; 3]; n_atoms];
69    let h_step = 1e-6; // micro-numerical step for overlap derivative (bohr)
70
71    // Compute per-pair gradient contributions
72    let compute_pair = |a: usize, b: usize| -> [f64; 3] {
73        let pa = get_pm3_params(elements[a]).unwrap();
74        let pb = get_pm3_params(elements[b]).unwrap();
75
76        let dx = positions[a][0] - positions[b][0];
77        let dy = positions[a][1] - positions[b][1];
78        let dz = positions[a][2] - positions[b][2];
79        let r_ang = (dx * dx + dy * dy + dz * dz).sqrt();
80        if r_ang < 0.01 {
81            return [0.0; 3];
82        }
83
84        let r_bohr = r_ang * ANGSTROM_TO_BOHR;
85        let dir = [dx / r_ang, dy / r_ang, dz / r_ang];
86        let mut grad_a = [0.0f64; 3];
87
88        // ── 1. Nuclear repulsion gradient (fully analytical) ──
89        let gamma = screened_coulomb_gamma_ev(r_bohr);
90        let dgamma_dr_ang = screened_coulomb_gamma_derivative_ev_per_angstrom(r_bohr);
91
92        let alpha_a_term = (-pa.alpha * r_ang).exp();
93        let alpha_b_term = (-pb.alpha * r_ang).exp();
94        let alpha_term = alpha_a_term + alpha_b_term;
95        let dalpha_dr = -pa.alpha * alpha_a_term - pb.alpha * alpha_b_term;
96
97        let de_nuc_dr = pa.core_charge
98            * pb.core_charge
99            * (dgamma_dr_ang * (1.0 + alpha_term) + gamma * dalpha_dr);
100
101        for d in 0..3 {
102            grad_a[d] += de_nuc_dr * dir[d];
103        }
104
105        // ── 2. Two-center electron Coulomb gradient (analytical) ──
106        let de_2c_dr = atom_pop[a] * atom_pop[b] * dgamma_dr_ang;
107        for d in 0..3 {
108            grad_a[d] += de_2c_dr * dir[d];
109        }
110
111        // ── 3. Hellmann-Feynman + Pulay (overlap-dependent) ──
112        for mu in 0..n_basis {
113            if state.basis_map[mu].0 != a {
114                continue;
115            }
116            let la = state.basis_map[mu].1;
117
118            for nu in 0..n_basis {
119                if state.basis_map[nu].0 != b {
120                    continue;
121                }
122                let lb = state.basis_map[nu].1;
123
124                let za = if la == 0 { pa.zeta_s } else { pa.zeta_p };
125                let zb = if lb == 0 { pb.zeta_s } else { pb.zeta_p };
126
127                let s_plus = sto_ss_overlap(za, zb, r_bohr + h_step);
128                let s_minus = sto_ss_overlap(za, zb, r_bohr - h_step);
129                let mut ds_dr_bohr = (s_plus - s_minus) / (2.0 * h_step);
130
131                if la != 0 || lb != 0 {
132                    ds_dr_bohr *= 0.5;
133                }
134
135                let ds_dr_ang = ds_dr_bohr * ANGSTROM_TO_BOHR;
136
137                let beta_mu = if la == 0 { pa.beta_s } else { pa.beta_p };
138                let beta_nu = if lb == 0 { pb.beta_s } else { pb.beta_p };
139                let dh_dr = 0.5 * (beta_mu + beta_nu) * ds_dr_ang;
140
141                let p_mn = state.density[(mu, nu)];
142                let w_mn = w_mat[(mu, nu)];
143                let force = 2.0 * (p_mn * dh_dr - w_mn * ds_dr_ang);
144
145                for d in 0..3 {
146                    grad_a[d] += force * dir[d];
147                }
148            }
149        }
150
151        grad_a
152    };
153
154    // Generate all pairs
155    let pairs: Vec<(usize, usize)> = (0..n_atoms)
156        .flat_map(|a| ((a + 1)..n_atoms).map(move |b| (a, b)))
157        .collect();
158
159    #[cfg(feature = "parallel")]
160    {
161        use rayon::prelude::*;
162        let pair_grads: Vec<(usize, usize, [f64; 3])> = pairs
163            .par_iter()
164            .map(|&(a, b)| {
165                let g = compute_pair(a, b);
166                (a, b, g)
167            })
168            .collect();
169        for (a, b, g) in pair_grads {
170            for d in 0..3 {
171                gradients[a][d] += g[d];
172                gradients[b][d] -= g[d];
173            }
174        }
175    }
176
177    #[cfg(not(feature = "parallel"))]
178    {
179        for &(a, b) in &pairs {
180            let g = compute_pair(a, b);
181            for d in 0..3 {
182                gradients[a][d] += g[d];
183                gradients[b][d] -= g[d];
184            }
185        }
186    }
187
188    Ok(Pm3GradientResult {
189        gradients,
190        energy: result.total_energy,
191        heat_of_formation: result.heat_of_formation,
192    })
193}
194
195#[cfg(test)]
196mod tests {
197    use super::super::solver::solve_pm3;
198    use super::*;
199
200    #[test]
201    fn test_pm3_gradient_h2() {
202        let elements = [1u8, 1];
203        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
204        let result = compute_pm3_gradient(&elements, &positions).unwrap();
205        assert_eq!(result.gradients.len(), 2);
206        assert!(result.energy.is_finite());
207        // By Newton's third law, forces must be equal and opposite
208        for d in 0..3 {
209            assert!(
210                (result.gradients[0][d] + result.gradients[1][d]).abs() < 0.1,
211                "Forces not equal and opposite: {:?}",
212                result.gradients
213            );
214        }
215    }
216
217    #[test]
218    fn test_pm3_gradient_water() {
219        let elements = [8u8, 1, 1];
220        let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
221        let result = compute_pm3_gradient(&elements, &positions).unwrap();
222        assert_eq!(result.gradients.len(), 3);
223        for g in &result.gradients {
224            for &v in g {
225                assert!(v.is_finite(), "Gradient must be finite");
226            }
227        }
228        // Net force on molecule should be near zero (translational invariance)
229        for d in 0..3 {
230            let sum: f64 = result.gradients.iter().map(|g| g[d]).sum();
231            assert!(
232                sum.abs() < 1.0,
233                "Net force should be near zero, got {sum:.4}"
234            );
235        }
236    }
237
238    #[test]
239    fn test_pm3_gradient_vs_numerical() {
240        let elements = [1u8, 1];
241        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
242        let analytical = compute_pm3_gradient(&elements, &positions).unwrap();
243
244        let h = 1e-5;
245        for a in 0..2 {
246            for d in 0..3 {
247                let mut pos_p = positions.to_vec();
248                let mut pos_m = positions.to_vec();
249                pos_p[a][d] += h;
250                pos_m[a][d] -= h;
251                let e_p = solve_pm3(&elements, &pos_p).unwrap().total_energy;
252                let e_m = solve_pm3(&elements, &pos_m).unwrap().total_energy;
253                let numerical = (e_p - e_m) / (2.0 * h);
254                let diff = (analytical.gradients[a][d] - numerical).abs();
255                let scale = numerical.abs().max(1.0);
256                assert!(
257                    diff / scale < 0.5,
258                    "Gradient mismatch atom {a} dir {d}: analytical={:.6} numerical={:.6} diff={:.6}",
259                    analytical.gradients[a][d],
260                    numerical,
261                    diff
262                );
263            }
264        }
265    }
266}