Skip to main content

sci_form/nmr/
coupling.rs

1//! J-coupling constant estimation via Karplus equation and topological rules.
2//!
3//! Implements ³J(H,H) prediction using the Karplus equation:
4//! ³J = A cos²φ + B cosφ + C
5//!
6//! where φ is the H-C-C-H dihedral angle.
7
8use serde::{Deserialize, Serialize};
9
10/// A predicted spin-spin coupling constant.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct JCoupling {
13    /// First hydrogen atom index.
14    pub h1_index: usize,
15    /// Second hydrogen atom index.
16    pub h2_index: usize,
17    /// Coupling constant in Hz.
18    pub j_hz: f64,
19    /// Number of bonds separating the two H atoms.
20    pub n_bonds: usize,
21    /// Coupling type description.
22    pub coupling_type: String,
23}
24
25// Karplus equation parameters for ³J(H,H)
26// Standard Karplus: A=7.76, B=-1.10, C=1.40 (Altona & Sundaralingam, JACS 1972)
27const KARPLUS_A: f64 = 7.76;
28const KARPLUS_B: f64 = -1.10;
29const KARPLUS_C: f64 = 1.40;
30
31/// Karplus parameters for a specific coupling pathway.
32#[derive(Debug, Clone, Copy)]
33pub struct KarplusParams {
34    pub a: f64,
35    pub b: f64,
36    pub c: f64,
37}
38
39impl KarplusParams {
40    /// Evaluate Karplus equation: ³J = A cos²φ + B cosφ + C
41    pub fn evaluate(&self, phi_rad: f64) -> f64 {
42        let cos_phi = phi_rad.cos();
43        self.a * cos_phi * cos_phi + self.b * cos_phi + self.c
44    }
45}
46
47/// Get Karplus parameters based on the coupled pathway atoms.
48/// Returns pathway-specific parameters for H-X-Y-H coupling.
49fn get_karplus_params(x_elem: u8, y_elem: u8) -> KarplusParams {
50    match (x_elem, y_elem) {
51        // H-C-C-H: Standard Altona & Sundaralingam (1972)
52        (6, 6) => KarplusParams {
53            a: 7.76,
54            b: -1.10,
55            c: 1.40,
56        },
57        // H-C-N-H: Bystrov parameters for peptides
58        (6, 7) | (7, 6) => KarplusParams {
59            a: 6.40,
60            b: -1.40,
61            c: 1.90,
62        },
63        // H-C-O-H: Parameters for glycosidic/hydroxyl couplings
64        (6, 8) | (8, 6) => KarplusParams {
65            a: 5.80,
66            b: -1.20,
67            c: 1.50,
68        },
69        // H-C-S-H: Thiol coupling
70        (6, 16) | (16, 6) => KarplusParams {
71            a: 6.00,
72            b: -1.00,
73            c: 1.30,
74        },
75        // Default: use standard H-C-C-H parameters
76        _ => KarplusParams {
77            a: KARPLUS_A,
78            b: KARPLUS_B,
79            c: KARPLUS_C,
80        },
81    }
82}
83
84/// Compute dihedral angle (in radians) from four 3D positions.
85fn dihedral_angle(p1: &[f64; 3], p2: &[f64; 3], p3: &[f64; 3], p4: &[f64; 3]) -> f64 {
86    let b1 = [p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]];
87    let b2 = [p3[0] - p2[0], p3[1] - p2[1], p3[2] - p2[2]];
88    let b3 = [p4[0] - p3[0], p4[1] - p3[1], p4[2] - p3[2]];
89
90    // n1 = b1 × b2
91    let n1 = cross(&b1, &b2);
92    // n2 = b2 × b3
93    let n2 = cross(&b2, &b3);
94
95    let m1 = cross(&n1, &normalize(&b2));
96
97    let x = dot(&n1, &n2);
98    let y = dot(&m1, &n2);
99
100    (-y).atan2(x)
101}
102
103fn cross(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
104    [
105        a[1] * b[2] - a[2] * b[1],
106        a[2] * b[0] - a[0] * b[2],
107        a[0] * b[1] - a[1] * b[0],
108    ]
109}
110
111fn dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
112    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
113}
114
115fn normalize(v: &[f64; 3]) -> [f64; 3] {
116    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
117    if len < 1e-10 {
118        return [0.0, 0.0, 0.0];
119    }
120    [v[0] / len, v[1] / len, v[2] / len]
121}
122
123/// Predict all J-coupling constants for a molecule.
124///
125/// `mol`: parsed molecular graph
126/// `positions`: 3D coordinates (one per atom, or empty for topology-only estimate)
127///
128/// Currently supports:
129/// - ²J (geminal): topological estimate (typically 2–12 Hz)
130/// - ³J (vicinal): Karplus equation if 3D coords available, else topological estimate (6–8 Hz)
131///   Uses pathway-specific parameters (H-C-C-H, H-C-N-H, H-C-O-H, etc.)
132pub fn predict_j_couplings(mol: &crate::graph::Molecule, positions: &[[f64; 3]]) -> Vec<JCoupling> {
133    let n = mol.graph.node_count();
134    let has_3d = positions.len() == n;
135    let mut couplings = Vec::new();
136
137    // Find all hydrogen atoms
138    let h_atoms: Vec<usize> = (0..n)
139        .filter(|&i| mol.graph[petgraph::graph::NodeIndex::new(i)].element == 1)
140        .collect();
141
142    // For each pair of H atoms, determine coupling pathway
143    for i in 0..h_atoms.len() {
144        for j in (i + 1)..h_atoms.len() {
145            let h1 = h_atoms[i];
146            let h2 = h_atoms[j];
147            let h1_idx = petgraph::graph::NodeIndex::new(h1);
148            let h2_idx = petgraph::graph::NodeIndex::new(h2);
149
150            // Find parent heavy atom for each H
151            let parent1: Vec<petgraph::graph::NodeIndex> = mol
152                .graph
153                .neighbors(h1_idx)
154                .filter(|n| mol.graph[*n].element != 1)
155                .collect();
156            let parent2: Vec<petgraph::graph::NodeIndex> = mol
157                .graph
158                .neighbors(h2_idx)
159                .filter(|n| mol.graph[*n].element != 1)
160                .collect();
161
162            if parent1.is_empty() || parent2.is_empty() {
163                continue;
164            }
165
166            let p1 = parent1[0];
167            let p2 = parent2[0];
168
169            if p1 == p2 {
170                // ²J geminal coupling (both H on same carbon)
171                let j_hz: f64 = -12.0; // typical geminal coupling (negative)
172                couplings.push(JCoupling {
173                    h1_index: h1,
174                    h2_index: h2,
175                    j_hz: j_hz.abs(), // report magnitude
176                    n_bonds: 2,
177                    coupling_type: "geminal_2J".to_string(),
178                });
179            } else if mol.graph.find_edge(p1, p2).is_some() {
180                // ³J vicinal coupling (H-X-Y-H pathway)
181                let x_elem = mol.graph[p1].element;
182                let y_elem = mol.graph[p2].element;
183                let params = get_karplus_params(x_elem, y_elem);
184
185                let j_hz = if has_3d {
186                    let phi = dihedral_angle(
187                        &positions[h1],
188                        &positions[p1.index()],
189                        &positions[p2.index()],
190                        &positions[h2],
191                    );
192                    params.evaluate(phi)
193                } else {
194                    // Fallback: average vicinal value
195                    7.0
196                };
197
198                couplings.push(JCoupling {
199                    h1_index: h1,
200                    h2_index: h2,
201                    j_hz,
202                    n_bonds: 3,
203                    coupling_type: format!(
204                        "vicinal_3J_H-{}-{}-H",
205                        element_symbol(x_elem),
206                        element_symbol(y_elem)
207                    ),
208                });
209            } else {
210                // ⁴J long-range coupling: check for H-X-Y-Z-H (4-bond path)
211                // Includes W-coupling and allylic coupling patterns
212                let p1_neighbors: Vec<petgraph::graph::NodeIndex> = mol
213                    .graph
214                    .neighbors(p1)
215                    .filter(|&nb| nb != h1_idx && mol.graph[nb].element != 1)
216                    .collect();
217                for &mid in &p1_neighbors {
218                    if mol.graph.find_edge(mid, p2).is_some() {
219                        // Found 4-bond path: H1-p1-mid-p2-H2
220                        let mid_elem = mol.graph[mid].element;
221                        let is_sp2_mid =
222                            mol.graph[mid].hybridization == crate::graph::Hybridization::SP2;
223                        let is_sp2_p1 =
224                            mol.graph[p1].hybridization == crate::graph::Hybridization::SP2;
225                        let is_sp2_p2 =
226                            mol.graph[p2].hybridization == crate::graph::Hybridization::SP2;
227
228                        // Allylic ⁴J: sp3-sp2=sp2-sp3 pattern, typically 1-3 Hz
229                        // W-coupling: rigid W-shaped path, typically 1-3 Hz
230                        let j_hz = if is_sp2_mid && (is_sp2_p1 || is_sp2_p2) {
231                            2.0 // allylic coupling
232                        } else {
233                            1.0 // generic long-range
234                        };
235
236                        couplings.push(JCoupling {
237                            h1_index: h1,
238                            h2_index: h2,
239                            j_hz,
240                            n_bonds: 4,
241                            coupling_type: format!(
242                                "long_range_4J_H-{}-{}-{}-H",
243                                element_symbol(mol.graph[p1].element),
244                                element_symbol(mid_elem),
245                                element_symbol(mol.graph[p2].element)
246                            ),
247                        });
248                        break; // only report one 4-bond path per pair
249                    }
250                }
251
252                // ⁵J long-range coupling: H-X-Y-Z-W-H (5-bond path)
253                // Observed in aromatic systems and extended conjugation, typically 0.2–1.0 Hz
254                if couplings
255                    .last()
256                    .is_none_or(|c| c.n_bonds != 4 || c.h1_index != h1 || c.h2_index != h2)
257                {
258                    'five_bond: for &mid1 in &p1_neighbors {
259                        let mid1_neighbors: Vec<petgraph::graph::NodeIndex> = mol
260                            .graph
261                            .neighbors(mid1)
262                            .filter(|&nb| nb != p1 && nb != h1_idx && mol.graph[nb].element != 1)
263                            .collect();
264                        for &mid2 in &mid1_neighbors {
265                            if mol.graph.find_edge(mid2, p2).is_some() {
266                                // Found 5-bond path: H1-p1-mid1-mid2-p2-H2
267                                let is_aromatic_path = [p1, mid1, mid2, p2].iter().all(|&n| {
268                                    mol.graph[n].hybridization == crate::graph::Hybridization::SP2
269                                });
270
271                                let j_hz = if is_aromatic_path {
272                                    0.7 // aromatic ⁵J
273                                } else {
274                                    0.3 // generic five-bond
275                                };
276
277                                couplings.push(JCoupling {
278                                    h1_index: h1,
279                                    h2_index: h2,
280                                    j_hz,
281                                    n_bonds: 5,
282                                    coupling_type: format!(
283                                        "long_range_5J_H-{}-{}-{}-{}-H",
284                                        element_symbol(mol.graph[p1].element),
285                                        element_symbol(mol.graph[mid1].element),
286                                        element_symbol(mol.graph[mid2].element),
287                                        element_symbol(mol.graph[p2].element)
288                                    ),
289                                });
290                                break 'five_bond;
291                            }
292                        }
293                    }
294                }
295            }
296        }
297    }
298
299    couplings
300}
301
302fn element_symbol(z: u8) -> &'static str {
303    match z {
304        6 => "C",
305        7 => "N",
306        8 => "O",
307        16 => "S",
308        _ => "X",
309    }
310}
311
312/// Compute Boltzmann-weighted ensemble-averaged J-couplings from multiple conformers.
313///
314/// Given conformer coordinates and their energies, compute the Boltzmann-weighted
315/// average of ³J coupling constants:
316///
317/// $\langle {}^3J \rangle = \frac{\sum_i J_i \cdot e^{-E_i / k_B T}}{\sum_i e^{-E_i / k_B T}}$
318///
319/// # Arguments
320/// - `mol`: parsed molecular graph
321/// - `conformer_positions`: list of 3D coordinate sets (one per conformer)
322/// - `energies_kcal`: relative energies in kcal/mol for each conformer
323/// - `temperature_k`: temperature in Kelvin (default 298.15)
324pub fn ensemble_averaged_j_couplings(
325    mol: &crate::graph::Molecule,
326    conformer_positions: &[Vec<[f64; 3]>],
327    energies_kcal: &[f64],
328    temperature_k: f64,
329) -> Vec<JCoupling> {
330    if conformer_positions.is_empty() {
331        return Vec::new();
332    }
333    if conformer_positions.len() != energies_kcal.len() {
334        return predict_j_couplings(mol, &conformer_positions[0]);
335    }
336
337    // k_B in kcal/(mol·K)
338    const KB_KCAL: f64 = 0.001987204;
339    let beta = 1.0 / (KB_KCAL * temperature_k);
340
341    // Find minimum energy for numerical stability
342    let e_min = energies_kcal.iter().cloned().fold(f64::INFINITY, f64::min);
343
344    // Compute Boltzmann weights
345    let weights: Vec<f64> = energies_kcal
346        .iter()
347        .map(|&e| (-(e - e_min) * beta).exp())
348        .collect();
349    let weight_sum: f64 = weights.iter().sum();
350
351    if weight_sum < 1e-30 {
352        return predict_j_couplings(mol, &conformer_positions[0]);
353    }
354
355    // Compute J-couplings for each conformer
356    let all_couplings: Vec<Vec<JCoupling>> = conformer_positions
357        .iter()
358        .map(|pos| predict_j_couplings(mol, pos))
359        .collect();
360
361    // Average over conformers
362    if all_couplings.is_empty() {
363        return Vec::new();
364    }
365
366    let n_couplings = all_couplings[0].len();
367    let mut averaged = all_couplings[0].clone();
368
369    for k in 0..n_couplings {
370        let mut weighted_j = 0.0;
371        for (conf_idx, couplings) in all_couplings.iter().enumerate() {
372            if k < couplings.len() {
373                weighted_j += couplings[k].j_hz * weights[conf_idx];
374            }
375        }
376        averaged[k].j_hz = weighted_j / weight_sum;
377    }
378
379    averaged
380}
381
382/// Parallel ensemble-averaged J-couplings via rayon.
383///
384/// Each conformer's J-coupling prediction is independent, making
385/// this embarrassingly parallel over conformers.
386#[cfg(feature = "parallel")]
387pub fn ensemble_averaged_j_couplings_parallel(
388    mol: &crate::graph::Molecule,
389    conformer_positions: &[Vec<[f64; 3]>],
390    energies_kcal: &[f64],
391    temperature_k: f64,
392) -> Vec<JCoupling> {
393    use rayon::prelude::*;
394
395    if conformer_positions.is_empty() {
396        return Vec::new();
397    }
398    if conformer_positions.len() != energies_kcal.len() {
399        return predict_j_couplings(mol, &conformer_positions[0]);
400    }
401
402    const KB_KCAL: f64 = 0.001987204;
403    let beta = 1.0 / (KB_KCAL * temperature_k);
404    let e_min = energies_kcal.iter().cloned().fold(f64::INFINITY, f64::min);
405
406    let weights: Vec<f64> = energies_kcal
407        .iter()
408        .map(|&e| (-(e - e_min) * beta).exp())
409        .collect();
410    let weight_sum: f64 = weights.iter().sum();
411
412    if weight_sum < 1e-30 {
413        return predict_j_couplings(mol, &conformer_positions[0]);
414    }
415
416    // Parallel J-coupling evaluation per conformer
417    let all_couplings: Vec<Vec<JCoupling>> = conformer_positions
418        .par_iter()
419        .map(|pos| predict_j_couplings(mol, pos))
420        .collect();
421
422    if all_couplings.is_empty() {
423        return Vec::new();
424    }
425
426    let n_couplings = all_couplings[0].len();
427    let mut averaged = all_couplings[0].clone();
428
429    for k in 0..n_couplings {
430        let mut weighted_j = 0.0;
431        for (conf_idx, couplings) in all_couplings.iter().enumerate() {
432            if k < couplings.len() {
433                weighted_j += couplings[k].j_hz * weights[conf_idx];
434            }
435        }
436        averaged[k].j_hz = weighted_j / weight_sum;
437    }
438
439    averaged
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    /// Karplus equation: ³J = A cos²φ + B cosφ + C
447    fn karplus_3j(phi_rad: f64) -> f64 {
448        let cos_phi = phi_rad.cos();
449        KARPLUS_A * cos_phi * cos_phi + KARPLUS_B * cos_phi + KARPLUS_C
450    }
451
452    #[test]
453    fn test_karplus_equation_values() {
454        // At φ = 0° (eclipsed): ³J should be large (~9.06 Hz)
455        let j_0 = karplus_3j(0.0);
456        assert!(
457            j_0 > 8.0 && j_0 < 10.0,
458            "³J(0°) = {} Hz, expected ~9 Hz",
459            j_0
460        );
461
462        // At φ = 90°: ³J should be small (~1.4 Hz)
463        let j_90 = karplus_3j(std::f64::consts::FRAC_PI_2);
464        assert!(
465            j_90 > 0.0 && j_90 < 3.0,
466            "³J(90°) = {} Hz, expected ~1.4 Hz",
467            j_90
468        );
469
470        // At φ = 180° (antiperiplanar): ³J should be large (~10.26 Hz with A-S params)
471        let j_180 = karplus_3j(std::f64::consts::PI);
472        assert!(
473            j_180 > 6.0 && j_180 < 12.0,
474            "³J(180°) = {} Hz, expected ~10 Hz",
475            j_180
476        );
477    }
478
479    #[test]
480    fn test_dihedral_angle_basic() {
481        // Simple planar dihedral
482        let p1 = [1.0, 0.0, 0.0];
483        let p2 = [0.0, 0.0, 0.0];
484        let p3 = [0.0, 1.0, 0.0];
485        let p4 = [-1.0, 1.0, 0.0];
486        let angle = dihedral_angle(&p1, &p2, &p3, &p4);
487        // Should be 0° (all in same plane)
488        assert!(angle.abs() < 0.1 || (angle.abs() - std::f64::consts::PI).abs() < 0.1);
489    }
490
491    #[test]
492    fn test_ethane_j_couplings() {
493        let mol = crate::graph::Molecule::from_smiles("CC").unwrap();
494        let couplings = predict_j_couplings(&mol, &[]);
495
496        // Ethane should have geminal ²J and vicinal ³J couplings
497        assert!(
498            !couplings.is_empty(),
499            "Ethane should have J-coupling predictions"
500        );
501
502        // Check that we find vicinal couplings
503        let vicinal: Vec<&JCoupling> = couplings.iter().filter(|c| c.n_bonds == 3).collect();
504        assert!(
505            !vicinal.is_empty(),
506            "Ethane should have ³J vicinal couplings"
507        );
508
509        // Vicinal coupling type should include pathway info
510        for c in &vicinal {
511            assert!(
512                c.coupling_type.contains("vicinal_3J"),
513                "Coupling type should be vicinal_3J, got {}",
514                c.coupling_type
515            );
516        }
517    }
518
519    #[test]
520    fn test_karplus_pathway_specific() {
521        // H-C-C-H vs H-C-N-H should have different parameters
522        let cc_params = get_karplus_params(6, 6);
523        let cn_params = get_karplus_params(6, 7);
524
525        // At φ = 0, different pathways should give different J values
526        let j_cc = cc_params.evaluate(0.0);
527        let j_cn = cn_params.evaluate(0.0);
528        assert!(
529            (j_cc - j_cn).abs() > 0.1,
530            "H-C-C-H and H-C-N-H should have different J at φ=0: {} vs {}",
531            j_cc,
532            j_cn
533        );
534    }
535
536    #[test]
537    fn test_ensemble_averaging() {
538        let mol = crate::graph::Molecule::from_smiles("CC").unwrap();
539        // Single conformer: result should be same as regular prediction
540        let n = mol.graph.node_count();
541        let positions = vec![[0.0, 0.0, 0.0]; n];
542        let result = ensemble_averaged_j_couplings(&mol, &[positions], &[0.0], 298.15);
543        assert!(!result.is_empty());
544    }
545
546    #[test]
547    fn test_methane_j_couplings() {
548        let mol = crate::graph::Molecule::from_smiles("C").unwrap();
549        let couplings = predict_j_couplings(&mol, &[]);
550
551        // Methane: all H on same C → geminal ²J only
552        for c in &couplings {
553            assert_eq!(c.n_bonds, 2, "Methane H-H should be 2-bond (geminal)");
554        }
555    }
556}