1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct JCoupling {
13 pub h1_index: usize,
15 pub h2_index: usize,
17 pub j_hz: f64,
19 pub n_bonds: usize,
21 pub coupling_type: String,
23}
24
25const KARPLUS_A: f64 = 7.76;
28const KARPLUS_B: f64 = -1.10;
29const KARPLUS_C: f64 = 1.40;
30
31#[derive(Debug, Clone, Copy)]
33pub struct KarplusParams {
34 pub a: f64,
35 pub b: f64,
36 pub c: f64,
37}
38
39impl KarplusParams {
40 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
47fn get_karplus_params(x_elem: u8, y_elem: u8) -> KarplusParams {
50 match (x_elem, y_elem) {
51 (6, 6) => KarplusParams {
53 a: 7.76,
54 b: -1.10,
55 c: 1.40,
56 },
57 (6, 7) | (7, 6) => KarplusParams {
59 a: 6.40,
60 b: -1.40,
61 c: 1.90,
62 },
63 (6, 8) | (8, 6) => KarplusParams {
65 a: 5.80,
66 b: -1.20,
67 c: 1.50,
68 },
69 (6, 16) | (16, 6) => KarplusParams {
71 a: 6.00,
72 b: -1.00,
73 c: 1.30,
74 },
75 _ => KarplusParams {
77 a: KARPLUS_A,
78 b: KARPLUS_B,
79 c: KARPLUS_C,
80 },
81 }
82}
83
84fn 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 let n1 = cross(&b1, &b2);
92 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
123pub 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 let h_atoms: Vec<usize> = (0..n)
139 .filter(|&i| mol.graph[petgraph::graph::NodeIndex::new(i)].element == 1)
140 .collect();
141
142 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 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 let j_hz: f64 = -12.0; couplings.push(JCoupling {
173 h1_index: h1,
174 h2_index: h2,
175 j_hz: j_hz.abs(), n_bonds: 2,
177 coupling_type: "geminal_2J".to_string(),
178 });
179 } else if mol.graph.find_edge(p1, p2).is_some() {
180 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 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 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 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 let j_hz = if is_sp2_mid && (is_sp2_p1 || is_sp2_p2) {
231 2.0 } else {
233 1.0 };
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; }
250 }
251
252 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 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 } else {
274 0.3 };
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
312pub 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 const KB_KCAL: f64 = 0.001987204;
339 let beta = 1.0 / (KB_KCAL * temperature_k);
340
341 let e_min = energies_kcal.iter().cloned().fold(f64::INFINITY, f64::min);
343
344 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 let all_couplings: Vec<Vec<JCoupling>> = conformer_positions
357 .iter()
358 .map(|pos| predict_j_couplings(mol, pos))
359 .collect();
360
361 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#[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 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 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 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 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 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 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 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 assert!(
498 !couplings.is_empty(),
499 "Ethane should have J-coupling predictions"
500 );
501
502 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 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 let cc_params = get_karplus_params(6, 6);
523 let cn_params = get_karplus_params(6, 7);
524
525 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 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 for c in &couplings {
553 assert_eq!(c.n_bonds, 2, "Methane H-H should be 2-bond (geminal)");
554 }
555 }
556}