Skip to main content

sci_form/ir/
hessian.rs

1//! Numerical Hessian computation via central finite differences.
2//!
3//! Computes the 3N×3N Hessian matrix by displacing each coordinate
4//! by ±δ and evaluating the energy at each displaced geometry.
5
6use nalgebra::DMatrix;
7
8/// Which semiempirical method to use for energy evaluations.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum HessianMethod {
11    /// Extended Hückel Theory (fastest, qualitative)
12    Eht,
13    /// PM3 semi-empirical SCF
14    Pm3,
15    /// GFN0-xTB tight-binding
16    Xtb,
17    /// UFF force field with analytical Hessian for bond-stretch and angle-bend
18    Uff,
19}
20
21/// Evaluate total energy for a given method and geometry.
22fn evaluate_energy(
23    method: HessianMethod,
24    elements: &[u8],
25    positions: &[[f64; 3]],
26) -> Result<f64, String> {
27    match method {
28        HessianMethod::Eht => {
29            let result = crate::eht::solve_eht(elements, positions, None)?;
30            // EHT total electronic energy: sum of occupied orbital energies × 2
31            let n_occ = result.n_electrons / 2;
32            let total: f64 = result.energies.iter().take(n_occ).sum::<f64>() * 2.0;
33            Ok(total)
34        }
35        HessianMethod::Pm3 => {
36            let result = crate::pm3::solve_pm3(elements, positions)?;
37            Ok(result.total_energy)
38        }
39        HessianMethod::Xtb => {
40            let result = crate::xtb::solve_xtb(elements, positions)?;
41            Ok(result.total_energy)
42        }
43        HessianMethod::Uff => {
44            Err("UFF uses analytical Hessian path; use compute_uff_analytical_hessian".to_string())
45        }
46    }
47}
48
49/// Compute the 3N×3N Hessian matrix via central finite differences.
50///
51/// Uses the formula: H_{ij} ≈ [E(+δ_i,+δ_j) - E(+δ_i,-δ_j) - E(-δ_i,+δ_j) + E(-δ_i,-δ_j)] / (4δ²)
52///
53/// For diagonal elements, uses the more efficient:
54/// H_{ii} ≈ [E(+δ_i) - 2E₀ + E(-δ_i)] / δ²
55///
56/// `elements`: atomic numbers
57/// `positions`: Cartesian coordinates in Å
58/// `method`: which energy method to use
59/// `step_size`: finite difference step in Å (if None, auto-selects based on atomic mass)
60pub fn compute_numerical_hessian(
61    elements: &[u8],
62    positions: &[[f64; 3]],
63    method: HessianMethod,
64    step_size: Option<f64>,
65) -> Result<DMatrix<f64>, String> {
66    let n_atoms = elements.len();
67    if n_atoms != positions.len() {
68        return Err("elements and positions length mismatch".to_string());
69    }
70
71    let n3 = 3 * n_atoms;
72
73    // Auto step-size: heavier atoms need larger steps for stable numerics
74    let delta = step_size.unwrap_or_else(|| auto_step_size(elements));
75    let delta_sq = delta * delta;
76
77    // Reference energy
78    let e0 = evaluate_energy(method, elements, positions)?;
79
80    // Flatten positions for easier manipulation
81    let mut coords: Vec<f64> = Vec::with_capacity(n3);
82    for pos in positions {
83        coords.extend_from_slice(pos);
84    }
85
86    let mut hessian = DMatrix::zeros(n3, n3);
87
88    // Diagonal elements: H_{ii} = [E(x+δ_i) - 2E₀ + E(x-δ_i)] / δ²
89    for i in 0..n3 {
90        let mut coords_plus = coords.clone();
91        let mut coords_minus = coords.clone();
92        coords_plus[i] += delta;
93        coords_minus[i] -= delta;
94
95        let pos_plus = flat_to_positions(&coords_plus);
96        let pos_minus = flat_to_positions(&coords_minus);
97
98        let e_plus = evaluate_energy(method, elements, &pos_plus)?;
99        let e_minus = evaluate_energy(method, elements, &pos_minus)?;
100
101        hessian[(i, i)] = (e_plus - 2.0 * e0 + e_minus) / delta_sq;
102    }
103
104    // Off-diagonal elements: H_{ij} = [E(+i,+j) - E(+i,-j) - E(-i,+j) + E(-i,-j)] / (4δ²)
105    for i in 0..n3 {
106        for j in (i + 1)..n3 {
107            let mut coords_pp = coords.clone();
108            let mut coords_pm = coords.clone();
109            let mut coords_mp = coords.clone();
110            let mut coords_mm = coords.clone();
111
112            coords_pp[i] += delta;
113            coords_pp[j] += delta;
114            coords_pm[i] += delta;
115            coords_pm[j] -= delta;
116            coords_mp[i] -= delta;
117            coords_mp[j] += delta;
118            coords_mm[i] -= delta;
119            coords_mm[j] -= delta;
120
121            let e_pp = evaluate_energy(method, elements, &flat_to_positions(&coords_pp))?;
122            let e_pm = evaluate_energy(method, elements, &flat_to_positions(&coords_pm))?;
123            let e_mp = evaluate_energy(method, elements, &flat_to_positions(&coords_mp))?;
124            let e_mm = evaluate_energy(method, elements, &flat_to_positions(&coords_mm))?;
125
126            let hij = (e_pp - e_pm - e_mp + e_mm) / (4.0 * delta_sq);
127            hessian[(i, j)] = hij;
128            hessian[(j, i)] = hij;
129        }
130    }
131
132    // Symmetry enforcement: average H and H^T to eliminate numerical noise
133    enforce_symmetry(&mut hessian);
134
135    Ok(hessian)
136}
137
138/// Enforce exact symmetry by averaging H[i,j] and H[j,i].
139///
140/// Reduces numerical noise from finite difference evaluation:
141/// $H_{\text{sym}} = \frac{H + H^T}{2}$
142fn enforce_symmetry(hessian: &mut DMatrix<f64>) {
143    let n = hessian.nrows();
144    for i in 0..n {
145        for j in (i + 1)..n {
146            let avg = (hessian[(i, j)] + hessian[(j, i)]) * 0.5;
147            hessian[(i, j)] = avg;
148            hessian[(j, i)] = avg;
149        }
150    }
151}
152
153/// Automatic step-size selection based on the lightest element.
154///
155/// Lighter atoms (H) need smaller steps; heavier atoms allow larger steps.
156/// Uses δ = 0.005 × sqrt(min_mass / 1.008) with a floor of 0.005 Å.
157fn auto_step_size(elements: &[u8]) -> f64 {
158    let min_mass = elements
159        .iter()
160        .map(|&z| match z {
161            1 => 1.008,
162            6 => 12.011,
163            7 => 14.007,
164            8 => 15.999,
165            9 => 18.998,
166            _ => z as f64 * 1.5,
167        })
168        .fold(f64::INFINITY, f64::min);
169
170    // Base step of 0.005 Å scaled by sqrt(mass_ratio), floor at 0.005 for H
171    (0.005 * (min_mass / 1.008).sqrt()).clamp(0.005, 0.01)
172}
173
174fn flat_to_positions(coords: &[f64]) -> Vec<[f64; 3]> {
175    coords.chunks(3).map(|c| [c[0], c[1], c[2]]).collect()
176}
177
178/// Compute the analytical Hessian for UFF force field using gradient differences.
179///
180/// Instead of computing H_{ij} from 4-point energy stencils (O(9N²) energy evaluations),
181/// this computes H_{ij} = ∂g_i/∂x_j using central differences on the analytical gradients:
182///   H_{ij} ≈ [g_i(x + δe_j) - g_i(x - δe_j)] / (2δ)
183///
184/// This requires only 6N gradient evaluations (one ± displacement per coordinate),
185/// which is much cheaper since UFF gradients are computed analytically.
186pub fn compute_uff_analytical_hessian(
187    smiles: &str,
188    coords_flat: &[f64],
189    step_size: Option<f64>,
190) -> Result<DMatrix<f64>, String> {
191    let mol = crate::graph::Molecule::from_smiles(smiles)?;
192    let n_atoms = mol.graph.node_count();
193    let n3 = 3 * n_atoms;
194
195    if coords_flat.len() != n3 {
196        return Err(format!(
197            "coords length {} != 3 * atoms {}",
198            coords_flat.len(),
199            n_atoms
200        ));
201    }
202
203    let ff = crate::forcefield::builder::build_uff_force_field(&mol);
204    let elements: Vec<u8> = (0..n_atoms)
205        .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
206        .collect();
207    let delta = step_size.unwrap_or_else(|| auto_step_size(&elements));
208
209    let mut hessian = DMatrix::zeros(n3, n3);
210
211    // For each coordinate j, displace ±δ and compute the full gradient vector.
212    // H_{ij} = [g_i(x + δe_j) - g_i(x - δe_j)] / (2δ)
213    for j in 0..n3 {
214        let mut coords_plus = coords_flat.to_vec();
215        let mut coords_minus = coords_flat.to_vec();
216        coords_plus[j] += delta;
217        coords_minus[j] -= delta;
218
219        let mut grad_plus = vec![0.0; n3];
220        let mut grad_minus = vec![0.0; n3];
221
222        ff.compute_system_energy_and_gradients(&coords_plus, &mut grad_plus);
223        ff.compute_system_energy_and_gradients(&coords_minus, &mut grad_minus);
224
225        for i in 0..n3 {
226            hessian[(i, j)] = (grad_plus[i] - grad_minus[i]) / (2.0 * delta);
227        }
228    }
229
230    // Symmetry enforcement
231    enforce_symmetry(&mut hessian);
232
233    Ok(hessian)
234}
235
236/// Parallel numerical Hessian via rayon.
237///
238/// Same algorithm as `compute_numerical_hessian` but evaluates all
239/// diagonal elements in parallel, then all off-diagonal (i,j) pairs
240/// in parallel. Each energy evaluation is independent.
241#[cfg(feature = "parallel")]
242pub fn compute_numerical_hessian_parallel(
243    elements: &[u8],
244    positions: &[[f64; 3]],
245    method: HessianMethod,
246    step_size: Option<f64>,
247) -> Result<DMatrix<f64>, String> {
248    use rayon::prelude::*;
249    use std::sync::Mutex;
250
251    let n_atoms = elements.len();
252    if n_atoms != positions.len() {
253        return Err("elements and positions length mismatch".to_string());
254    }
255
256    let n3 = 3 * n_atoms;
257    let delta = step_size.unwrap_or_else(|| auto_step_size(elements));
258    let delta_sq = delta * delta;
259
260    let e0 = evaluate_energy(method, elements, positions)?;
261
262    let mut coords: Vec<f64> = Vec::with_capacity(n3);
263    for pos in positions {
264        coords.extend_from_slice(pos);
265    }
266
267    let hessian = Mutex::new(DMatrix::zeros(n3, n3));
268
269    // Diagonal elements in parallel
270    let diag_results: Vec<Result<(usize, f64), String>> = (0..n3)
271        .into_par_iter()
272        .map(|i| {
273            let mut cp = coords.clone();
274            let mut cm = coords.clone();
275            cp[i] += delta;
276            cm[i] -= delta;
277            let e_plus = evaluate_energy(method, elements, &flat_to_positions(&cp))?;
278            let e_minus = evaluate_energy(method, elements, &flat_to_positions(&cm))?;
279            Ok((i, (e_plus - 2.0 * e0 + e_minus) / delta_sq))
280        })
281        .collect();
282
283    for res in diag_results {
284        let (i, val) = res?;
285        hessian.lock().unwrap()[(i, i)] = val;
286    }
287
288    // Off-diagonal pairs in parallel
289    let pairs: Vec<(usize, usize)> = (0..n3)
290        .flat_map(|i| ((i + 1)..n3).map(move |j| (i, j)))
291        .collect();
292
293    let offdiag_results: Vec<Result<(usize, usize, f64), String>> = pairs
294        .into_par_iter()
295        .map(|(i, j)| {
296            let mut cpp = coords.clone();
297            let mut cpm = coords.clone();
298            let mut cmp = coords.clone();
299            let mut cmm = coords.clone();
300            cpp[i] += delta;
301            cpp[j] += delta;
302            cpm[i] += delta;
303            cpm[j] -= delta;
304            cmp[i] -= delta;
305            cmp[j] += delta;
306            cmm[i] -= delta;
307            cmm[j] -= delta;
308
309            let e_pp = evaluate_energy(method, elements, &flat_to_positions(&cpp))?;
310            let e_pm = evaluate_energy(method, elements, &flat_to_positions(&cpm))?;
311            let e_mp = evaluate_energy(method, elements, &flat_to_positions(&cmp))?;
312            let e_mm = evaluate_energy(method, elements, &flat_to_positions(&cmm))?;
313            Ok((i, j, (e_pp - e_pm - e_mp + e_mm) / (4.0 * delta_sq)))
314        })
315        .collect();
316
317    {
318        let mut h = hessian.lock().unwrap();
319        for res in offdiag_results {
320            let (i, j, val) = res?;
321            h[(i, j)] = val;
322            h[(j, i)] = val;
323        }
324        enforce_symmetry(&mut h);
325    }
326
327    Ok(hessian.into_inner().unwrap())
328}
329
330/// Parallel UFF analytical Hessian via rayon.
331///
332/// Evaluates gradient displacements for each coordinate in parallel.
333#[cfg(feature = "parallel")]
334pub fn compute_uff_analytical_hessian_parallel(
335    smiles: &str,
336    coords_flat: &[f64],
337    step_size: Option<f64>,
338) -> Result<DMatrix<f64>, String> {
339    use rayon::prelude::*;
340    use std::sync::Mutex;
341
342    let mol = crate::graph::Molecule::from_smiles(smiles)?;
343    let n_atoms = mol.graph.node_count();
344    let n3 = 3 * n_atoms;
345
346    if coords_flat.len() != n3 {
347        return Err(format!(
348            "coords length {} != 3 * atoms {}",
349            coords_flat.len(),
350            n_atoms
351        ));
352    }
353
354    let ff = crate::forcefield::builder::build_uff_force_field(&mol);
355    let elements: Vec<u8> = (0..n_atoms)
356        .map(|i| mol.graph[petgraph::graph::NodeIndex::new(i)].element)
357        .collect();
358    let delta = step_size.unwrap_or_else(|| auto_step_size(&elements));
359
360    let hessian = Mutex::new(DMatrix::zeros(n3, n3));
361
362    let results: Vec<(usize, Vec<f64>)> = (0..n3)
363        .into_par_iter()
364        .map(|j| {
365            let mut cp = coords_flat.to_vec();
366            let mut cm = coords_flat.to_vec();
367            cp[j] += delta;
368            cm[j] -= delta;
369
370            let mut gp = vec![0.0; n3];
371            let mut gm = vec![0.0; n3];
372            ff.compute_system_energy_and_gradients(&cp, &mut gp);
373            ff.compute_system_energy_and_gradients(&cm, &mut gm);
374
375            let col: Vec<f64> = (0..n3).map(|i| (gp[i] - gm[i]) / (2.0 * delta)).collect();
376            (j, col)
377        })
378        .collect();
379
380    {
381        let mut h = hessian.lock().unwrap();
382        for (j, col) in results {
383            for i in 0..n3 {
384                h[(i, j)] = col[i];
385            }
386        }
387        enforce_symmetry(&mut h);
388    }
389
390    Ok(hessian.into_inner().unwrap())
391}
392
393/// Evaluate analytical gradient for PM3 or xTB at a given geometry.
394/// Returns gradient as flat Vec<f64> of length 3N.
395fn evaluate_gradient(
396    method: HessianMethod,
397    elements: &[u8],
398    positions: &[[f64; 3]],
399) -> Result<Vec<f64>, String> {
400    match method {
401        HessianMethod::Pm3 => {
402            let grad_result = crate::pm3::gradients::compute_pm3_gradient(elements, positions)?;
403            let mut flat = Vec::with_capacity(positions.len() * 3);
404            for g in &grad_result.gradients {
405                flat.push(g[0]);
406                flat.push(g[1]);
407                flat.push(g[2]);
408            }
409            Ok(flat)
410        }
411        HessianMethod::Xtb => {
412            let grad_result = crate::xtb::gradients::compute_xtb_gradient(elements, positions)?;
413            let mut flat = Vec::with_capacity(positions.len() * 3);
414            for g in &grad_result.gradients {
415                flat.push(g[0]);
416                flat.push(g[1]);
417                flat.push(g[2]);
418            }
419            Ok(flat)
420        }
421        _ => Err(format!(
422            "Semi-analytical Hessian requires PM3 or xTB, got {:?}",
423            method
424        )),
425    }
426}
427
428/// Compute the Hessian using semi-analytical gradient differences (PM3/xTB).
429///
430/// Uses the formula: H_{ij} = [g_i(x + δe_j) - g_i(x - δe_j)] / (2δ)
431///
432/// This needs only 6N gradient evaluations instead of O(9N²) energy evaluations,
433/// and is more accurate since analytical gradients have less numerical noise.
434pub fn compute_semianalytical_hessian(
435    elements: &[u8],
436    positions: &[[f64; 3]],
437    method: HessianMethod,
438    step_size: Option<f64>,
439) -> Result<DMatrix<f64>, String> {
440    let n_atoms = elements.len();
441    if n_atoms != positions.len() {
442        return Err("elements and positions length mismatch".to_string());
443    }
444
445    let n3 = 3 * n_atoms;
446    let delta = step_size.unwrap_or_else(|| auto_step_size(elements));
447
448    let mut coords: Vec<f64> = Vec::with_capacity(n3);
449    for pos in positions {
450        coords.extend_from_slice(pos);
451    }
452
453    let mut hessian = DMatrix::zeros(n3, n3);
454
455    for j in 0..n3 {
456        let mut coords_plus = coords.clone();
457        let mut coords_minus = coords.clone();
458        coords_plus[j] += delta;
459        coords_minus[j] -= delta;
460
461        let pos_plus = flat_to_positions(&coords_plus);
462        let pos_minus = flat_to_positions(&coords_minus);
463
464        let grad_plus = evaluate_gradient(method, elements, &pos_plus)?;
465        let grad_minus = evaluate_gradient(method, elements, &pos_minus)?;
466
467        for i in 0..n3 {
468            hessian[(i, j)] = (grad_plus[i] - grad_minus[i]) / (2.0 * delta);
469        }
470    }
471
472    enforce_symmetry(&mut hessian);
473    Ok(hessian)
474}
475
476/// Parallel semi-analytical Hessian via rayon.
477#[cfg(feature = "parallel")]
478pub fn compute_semianalytical_hessian_parallel(
479    elements: &[u8],
480    positions: &[[f64; 3]],
481    method: HessianMethod,
482    step_size: Option<f64>,
483) -> Result<DMatrix<f64>, String> {
484    use rayon::prelude::*;
485
486    let n_atoms = elements.len();
487    if n_atoms != positions.len() {
488        return Err("elements and positions length mismatch".to_string());
489    }
490
491    let n3 = 3 * n_atoms;
492    let delta = step_size.unwrap_or_else(|| auto_step_size(elements));
493
494    let mut coords: Vec<f64> = Vec::with_capacity(n3);
495    for pos in positions {
496        coords.extend_from_slice(pos);
497    }
498
499    let results: Vec<Result<(usize, Vec<f64>), String>> = (0..n3)
500        .into_par_iter()
501        .map(|j| {
502            let mut cp = coords.clone();
503            let mut cm = coords.clone();
504            cp[j] += delta;
505            cm[j] -= delta;
506
507            let pp = flat_to_positions(&cp);
508            let pm = flat_to_positions(&cm);
509
510            let gp = evaluate_gradient(method, elements, &pp)?;
511            let gm = evaluate_gradient(method, elements, &pm)?;
512
513            let col: Vec<f64> = (0..n3).map(|i| (gp[i] - gm[i]) / (2.0 * delta)).collect();
514            Ok((j, col))
515        })
516        .collect();
517
518    let mut hessian = DMatrix::zeros(n3, n3);
519    for res in results {
520        let (j, col) = res?;
521        for i in 0..n3 {
522            hessian[(i, j)] = col[i];
523        }
524    }
525
526    enforce_symmetry(&mut hessian);
527    Ok(hessian)
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn test_hessian_h2_is_symmetric() {
536        let elements = [1u8, 1];
537        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
538        let hessian =
539            compute_numerical_hessian(&elements, &positions, HessianMethod::Xtb, Some(0.005))
540                .unwrap();
541
542        assert_eq!(hessian.nrows(), 6);
543        assert_eq!(hessian.ncols(), 6);
544
545        // Check symmetry
546        for i in 0..6 {
547            for j in 0..6 {
548                assert!(
549                    (hessian[(i, j)] - hessian[(j, i)]).abs() < 1e-4,
550                    "Hessian not symmetric at ({}, {}): {} vs {}",
551                    i,
552                    j,
553                    hessian[(i, j)],
554                    hessian[(j, i)]
555                );
556            }
557        }
558    }
559
560    #[test]
561    fn test_hessian_water_shape() {
562        let elements = [8u8, 1, 1];
563        let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
564        let hessian =
565            compute_numerical_hessian(&elements, &positions, HessianMethod::Xtb, Some(0.005))
566                .unwrap();
567
568        assert_eq!(hessian.nrows(), 9);
569        assert_eq!(hessian.ncols(), 9);
570
571        // Check at least some non-zero off-diagonal elements
572        let mut has_nonzero_offdiag = false;
573        for i in 0..9 {
574            for j in (i + 1)..9 {
575                if hessian[(i, j)].abs() > 1e-6 {
576                    has_nonzero_offdiag = true;
577                    break;
578                }
579            }
580        }
581        assert!(
582            has_nonzero_offdiag,
583            "Hessian should have non-zero off-diagonal elements"
584        );
585    }
586}