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        }
253    }
254
255    couplings
256}
257
258fn element_symbol(z: u8) -> &'static str {
259    match z {
260        6 => "C",
261        7 => "N",
262        8 => "O",
263        16 => "S",
264        _ => "X",
265    }
266}
267
268/// Compute Boltzmann-weighted ensemble-averaged J-couplings from multiple conformers.
269///
270/// Given conformer coordinates and their energies, compute the Boltzmann-weighted
271/// average of ³J coupling constants:
272///
273/// $\langle {}^3J \rangle = \frac{\sum_i J_i \cdot e^{-E_i / k_B T}}{\sum_i e^{-E_i / k_B T}}$
274///
275/// # Arguments
276/// - `mol`: parsed molecular graph
277/// - `conformer_positions`: list of 3D coordinate sets (one per conformer)
278/// - `energies_kcal`: relative energies in kcal/mol for each conformer
279/// - `temperature_k`: temperature in Kelvin (default 298.15)
280pub fn ensemble_averaged_j_couplings(
281    mol: &crate::graph::Molecule,
282    conformer_positions: &[Vec<[f64; 3]>],
283    energies_kcal: &[f64],
284    temperature_k: f64,
285) -> Vec<JCoupling> {
286    if conformer_positions.is_empty() {
287        return Vec::new();
288    }
289    if conformer_positions.len() != energies_kcal.len() {
290        return predict_j_couplings(mol, &conformer_positions[0]);
291    }
292
293    // k_B in kcal/(mol·K)
294    const KB_KCAL: f64 = 0.001987204;
295    let beta = 1.0 / (KB_KCAL * temperature_k);
296
297    // Find minimum energy for numerical stability
298    let e_min = energies_kcal.iter().cloned().fold(f64::INFINITY, f64::min);
299
300    // Compute Boltzmann weights
301    let weights: Vec<f64> = energies_kcal
302        .iter()
303        .map(|&e| (-(e - e_min) * beta).exp())
304        .collect();
305    let weight_sum: f64 = weights.iter().sum();
306
307    if weight_sum < 1e-30 {
308        return predict_j_couplings(mol, &conformer_positions[0]);
309    }
310
311    // Compute J-couplings for each conformer
312    let all_couplings: Vec<Vec<JCoupling>> = conformer_positions
313        .iter()
314        .map(|pos| predict_j_couplings(mol, pos))
315        .collect();
316
317    // Average over conformers
318    if all_couplings.is_empty() {
319        return Vec::new();
320    }
321
322    let n_couplings = all_couplings[0].len();
323    let mut averaged = all_couplings[0].clone();
324
325    for k in 0..n_couplings {
326        let mut weighted_j = 0.0;
327        for (conf_idx, couplings) in all_couplings.iter().enumerate() {
328            if k < couplings.len() {
329                weighted_j += couplings[k].j_hz * weights[conf_idx];
330            }
331        }
332        averaged[k].j_hz = weighted_j / weight_sum;
333    }
334
335    averaged
336}
337
338/// Parallel ensemble-averaged J-couplings via rayon.
339///
340/// Each conformer's J-coupling prediction is independent, making
341/// this embarrassingly parallel over conformers.
342#[cfg(feature = "parallel")]
343pub fn ensemble_averaged_j_couplings_parallel(
344    mol: &crate::graph::Molecule,
345    conformer_positions: &[Vec<[f64; 3]>],
346    energies_kcal: &[f64],
347    temperature_k: f64,
348) -> Vec<JCoupling> {
349    use rayon::prelude::*;
350
351    if conformer_positions.is_empty() {
352        return Vec::new();
353    }
354    if conformer_positions.len() != energies_kcal.len() {
355        return predict_j_couplings(mol, &conformer_positions[0]);
356    }
357
358    const KB_KCAL: f64 = 0.001987204;
359    let beta = 1.0 / (KB_KCAL * temperature_k);
360    let e_min = energies_kcal.iter().cloned().fold(f64::INFINITY, f64::min);
361
362    let weights: Vec<f64> = energies_kcal
363        .iter()
364        .map(|&e| (-(e - e_min) * beta).exp())
365        .collect();
366    let weight_sum: f64 = weights.iter().sum();
367
368    if weight_sum < 1e-30 {
369        return predict_j_couplings(mol, &conformer_positions[0]);
370    }
371
372    // Parallel J-coupling evaluation per conformer
373    let all_couplings: Vec<Vec<JCoupling>> = conformer_positions
374        .par_iter()
375        .map(|pos| predict_j_couplings(mol, pos))
376        .collect();
377
378    if all_couplings.is_empty() {
379        return Vec::new();
380    }
381
382    let n_couplings = all_couplings[0].len();
383    let mut averaged = all_couplings[0].clone();
384
385    for k in 0..n_couplings {
386        let mut weighted_j = 0.0;
387        for (conf_idx, couplings) in all_couplings.iter().enumerate() {
388            if k < couplings.len() {
389                weighted_j += couplings[k].j_hz * weights[conf_idx];
390            }
391        }
392        averaged[k].j_hz = weighted_j / weight_sum;
393    }
394
395    averaged
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    /// Karplus equation: ³J = A cos²φ + B cosφ + C
403    fn karplus_3j(phi_rad: f64) -> f64 {
404        let cos_phi = phi_rad.cos();
405        KARPLUS_A * cos_phi * cos_phi + KARPLUS_B * cos_phi + KARPLUS_C
406    }
407
408    #[test]
409    fn test_karplus_equation_values() {
410        // At φ = 0° (eclipsed): ³J should be large (~9.06 Hz)
411        let j_0 = karplus_3j(0.0);
412        assert!(
413            j_0 > 8.0 && j_0 < 10.0,
414            "³J(0°) = {} Hz, expected ~9 Hz",
415            j_0
416        );
417
418        // At φ = 90°: ³J should be small (~1.4 Hz)
419        let j_90 = karplus_3j(std::f64::consts::FRAC_PI_2);
420        assert!(
421            j_90 > 0.0 && j_90 < 3.0,
422            "³J(90°) = {} Hz, expected ~1.4 Hz",
423            j_90
424        );
425
426        // At φ = 180° (antiperiplanar): ³J should be large (~10.26 Hz with A-S params)
427        let j_180 = karplus_3j(std::f64::consts::PI);
428        assert!(
429            j_180 > 6.0 && j_180 < 12.0,
430            "³J(180°) = {} Hz, expected ~10 Hz",
431            j_180
432        );
433    }
434
435    #[test]
436    fn test_dihedral_angle_basic() {
437        // Simple planar dihedral
438        let p1 = [1.0, 0.0, 0.0];
439        let p2 = [0.0, 0.0, 0.0];
440        let p3 = [0.0, 1.0, 0.0];
441        let p4 = [-1.0, 1.0, 0.0];
442        let angle = dihedral_angle(&p1, &p2, &p3, &p4);
443        // Should be 0° (all in same plane)
444        assert!(angle.abs() < 0.1 || (angle.abs() - std::f64::consts::PI).abs() < 0.1);
445    }
446
447    #[test]
448    fn test_ethane_j_couplings() {
449        let mol = crate::graph::Molecule::from_smiles("CC").unwrap();
450        let couplings = predict_j_couplings(&mol, &[]);
451
452        // Ethane should have geminal ²J and vicinal ³J couplings
453        assert!(
454            !couplings.is_empty(),
455            "Ethane should have J-coupling predictions"
456        );
457
458        // Check that we find vicinal couplings
459        let vicinal: Vec<&JCoupling> = couplings.iter().filter(|c| c.n_bonds == 3).collect();
460        assert!(
461            !vicinal.is_empty(),
462            "Ethane should have ³J vicinal couplings"
463        );
464
465        // Vicinal coupling type should include pathway info
466        for c in &vicinal {
467            assert!(
468                c.coupling_type.contains("vicinal_3J"),
469                "Coupling type should be vicinal_3J, got {}",
470                c.coupling_type
471            );
472        }
473    }
474
475    #[test]
476    fn test_karplus_pathway_specific() {
477        // H-C-C-H vs H-C-N-H should have different parameters
478        let cc_params = get_karplus_params(6, 6);
479        let cn_params = get_karplus_params(6, 7);
480
481        // At φ = 0, different pathways should give different J values
482        let j_cc = cc_params.evaluate(0.0);
483        let j_cn = cn_params.evaluate(0.0);
484        assert!(
485            (j_cc - j_cn).abs() > 0.1,
486            "H-C-C-H and H-C-N-H should have different J at φ=0: {} vs {}",
487            j_cc,
488            j_cn
489        );
490    }
491
492    #[test]
493    fn test_ensemble_averaging() {
494        let mol = crate::graph::Molecule::from_smiles("CC").unwrap();
495        // Single conformer: result should be same as regular prediction
496        let n = mol.graph.node_count();
497        let positions = vec![[0.0, 0.0, 0.0]; n];
498        let result = ensemble_averaged_j_couplings(&mol, &[positions], &[0.0], 298.15);
499        assert!(!result.is_empty());
500    }
501
502    #[test]
503    fn test_methane_j_couplings() {
504        let mol = crate::graph::Molecule::from_smiles("C").unwrap();
505        let couplings = predict_j_couplings(&mol, &[]);
506
507        // Methane: all H on same C → geminal ²J only
508        for c in &couplings {
509            assert_eq!(c.n_bonds, 2, "Methane H-H should be 2-bond (geminal)");
510        }
511    }
512}