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/// Compute dihedral angle (in radians) from four 3D positions.
32fn dihedral_angle(p1: &[f64; 3], p2: &[f64; 3], p3: &[f64; 3], p4: &[f64; 3]) -> f64 {
33    let b1 = [p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]];
34    let b2 = [p3[0] - p2[0], p3[1] - p2[1], p3[2] - p2[2]];
35    let b3 = [p4[0] - p3[0], p4[1] - p3[1], p4[2] - p3[2]];
36
37    // n1 = b1 × b2
38    let n1 = cross(&b1, &b2);
39    // n2 = b2 × b3
40    let n2 = cross(&b2, &b3);
41
42    let m1 = cross(&n1, &normalize(&b2));
43
44    let x = dot(&n1, &n2);
45    let y = dot(&m1, &n2);
46
47    (-y).atan2(x)
48}
49
50fn cross(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
51    [
52        a[1] * b[2] - a[2] * b[1],
53        a[2] * b[0] - a[0] * b[2],
54        a[0] * b[1] - a[1] * b[0],
55    ]
56}
57
58fn dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
59    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
60}
61
62fn normalize(v: &[f64; 3]) -> [f64; 3] {
63    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
64    if len < 1e-10 {
65        return [0.0, 0.0, 0.0];
66    }
67    [v[0] / len, v[1] / len, v[2] / len]
68}
69
70/// Karplus equation: ³J = A cos²φ + B cosφ + C
71fn karplus_3j(phi_rad: f64) -> f64 {
72    let cos_phi = phi_rad.cos();
73    KARPLUS_A * cos_phi * cos_phi + KARPLUS_B * cos_phi + KARPLUS_C
74}
75
76/// Predict all J-coupling constants for a molecule.
77///
78/// `mol`: parsed molecular graph
79/// `positions`: 3D coordinates (one per atom, or empty for topology-only estimate)
80///
81/// Currently supports:
82/// - ²J (geminal): topological estimate (typically 2–12 Hz)
83/// - ³J (vicinal): Karplus equation if 3D coords available, else topological estimate (6–8 Hz)
84pub fn predict_j_couplings(mol: &crate::graph::Molecule, positions: &[[f64; 3]]) -> Vec<JCoupling> {
85    let n = mol.graph.node_count();
86    let has_3d = positions.len() == n;
87    let mut couplings = Vec::new();
88
89    // Find all hydrogen atoms
90    let h_atoms: Vec<usize> = (0..n)
91        .filter(|&i| mol.graph[petgraph::graph::NodeIndex::new(i)].element == 1)
92        .collect();
93
94    // For each pair of H atoms, determine coupling pathway
95    for i in 0..h_atoms.len() {
96        for j in (i + 1)..h_atoms.len() {
97            let h1 = h_atoms[i];
98            let h2 = h_atoms[j];
99            let h1_idx = petgraph::graph::NodeIndex::new(h1);
100            let h2_idx = petgraph::graph::NodeIndex::new(h2);
101
102            // Find parent heavy atom for each H
103            let parent1: Vec<petgraph::graph::NodeIndex> = mol
104                .graph
105                .neighbors(h1_idx)
106                .filter(|n| mol.graph[*n].element != 1)
107                .collect();
108            let parent2: Vec<petgraph::graph::NodeIndex> = mol
109                .graph
110                .neighbors(h2_idx)
111                .filter(|n| mol.graph[*n].element != 1)
112                .collect();
113
114            if parent1.is_empty() || parent2.is_empty() {
115                continue;
116            }
117
118            let p1 = parent1[0];
119            let p2 = parent2[0];
120
121            if p1 == p2 {
122                // ²J geminal coupling (both H on same carbon)
123                let j_hz: f64 = -12.0; // typical geminal coupling (negative)
124                couplings.push(JCoupling {
125                    h1_index: h1,
126                    h2_index: h2,
127                    j_hz: j_hz.abs(), // report magnitude
128                    n_bonds: 2,
129                    coupling_type: "geminal_2J".to_string(),
130                });
131            } else if mol.graph.find_edge(p1, p2).is_some() {
132                // ³J vicinal coupling (H-C-C-H pathway)
133                let j_hz = if has_3d {
134                    let phi = dihedral_angle(
135                        &positions[h1],
136                        &positions[p1.index()],
137                        &positions[p2.index()],
138                        &positions[h2],
139                    );
140                    karplus_3j(phi)
141                } else {
142                    // Fallback: average vicinal value
143                    7.0
144                };
145
146                couplings.push(JCoupling {
147                    h1_index: h1,
148                    h2_index: h2,
149                    j_hz,
150                    n_bonds: 3,
151                    coupling_type: "vicinal_3J".to_string(),
152                });
153            }
154            // We skip longer-range couplings (⁴J, ⁵J) as they are typically < 3 Hz
155        }
156    }
157
158    couplings
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_karplus_equation_values() {
167        // At φ = 0° (eclipsed): ³J should be large (~9.06 Hz)
168        let j_0 = karplus_3j(0.0);
169        assert!(
170            j_0 > 8.0 && j_0 < 10.0,
171            "³J(0°) = {} Hz, expected ~9 Hz",
172            j_0
173        );
174
175        // At φ = 90°: ³J should be small (~1.4 Hz)
176        let j_90 = karplus_3j(std::f64::consts::FRAC_PI_2);
177        assert!(
178            j_90 > 0.0 && j_90 < 3.0,
179            "³J(90°) = {} Hz, expected ~1.4 Hz",
180            j_90
181        );
182
183        // At φ = 180° (antiperiplanar): ³J should be large (~10.26 Hz with A-S params)
184        let j_180 = karplus_3j(std::f64::consts::PI);
185        assert!(
186            j_180 > 6.0 && j_180 < 12.0,
187            "³J(180°) = {} Hz, expected ~10 Hz",
188            j_180
189        );
190    }
191
192    #[test]
193    fn test_dihedral_angle_basic() {
194        // Simple planar dihedral
195        let p1 = [1.0, 0.0, 0.0];
196        let p2 = [0.0, 0.0, 0.0];
197        let p3 = [0.0, 1.0, 0.0];
198        let p4 = [-1.0, 1.0, 0.0];
199        let angle = dihedral_angle(&p1, &p2, &p3, &p4);
200        // Should be 0° (all in same plane)
201        assert!(angle.abs() < 0.1 || (angle.abs() - std::f64::consts::PI).abs() < 0.1);
202    }
203
204    #[test]
205    fn test_ethane_j_couplings() {
206        let mol = crate::graph::Molecule::from_smiles("CC").unwrap();
207        let couplings = predict_j_couplings(&mol, &[]);
208
209        // Ethane should have geminal ²J and vicinal ³J couplings
210        assert!(
211            !couplings.is_empty(),
212            "Ethane should have J-coupling predictions"
213        );
214
215        // Check that we find vicinal couplings
216        let vicinal: Vec<&JCoupling> = couplings.iter().filter(|c| c.n_bonds == 3).collect();
217        assert!(
218            !vicinal.is_empty(),
219            "Ethane should have ³J vicinal couplings"
220        );
221    }
222
223    #[test]
224    fn test_methane_j_couplings() {
225        let mol = crate::graph::Molecule::from_smiles("C").unwrap();
226        let couplings = predict_j_couplings(&mol, &[]);
227
228        // Methane: all H on same C → geminal ²J only
229        for c in &couplings {
230            assert_eq!(c.n_bonds, 2, "Methane H-H should be 2-bond (geminal)");
231        }
232    }
233}