Skip to main content

sci_form/nmr/
spectrum.rs

1//! NMR spectrum generation with Lorentzian broadening.
2//!
3//! Combines predicted chemical shifts and J-couplings to produce
4//! a synthetic 1D NMR spectrum with Lorentzian line shapes.
5
6use serde::{Deserialize, Serialize};
7
8use super::coupling::JCoupling;
9use super::shifts::{ChemicalShift, NmrShiftResult};
10
11/// Target NMR nucleus.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum NmrNucleus {
14    /// ¹H NMR
15    H1,
16    /// ¹³C NMR
17    C13,
18}
19
20/// A single peak in the NMR spectrum.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct NmrPeak {
23    /// Chemical shift in ppm.
24    pub shift_ppm: f64,
25    /// Relative intensity (integration).
26    pub intensity: f64,
27    /// Atom index.
28    pub atom_index: usize,
29    /// Multiplicity label (s, d, t, q, m).
30    pub multiplicity: String,
31    /// Environment description.
32    pub environment: String,
33}
34
35/// Complete NMR spectrum.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct NmrSpectrum {
38    /// Chemical shift axis (ppm), typically decreasing.
39    pub ppm_axis: Vec<f64>,
40    /// Intensity axis.
41    pub intensities: Vec<f64>,
42    /// Identified peaks.
43    pub peaks: Vec<NmrPeak>,
44    /// Nucleus type.
45    pub nucleus: NmrNucleus,
46    /// Line width parameter (ppm).
47    pub gamma: f64,
48    /// Notes.
49    pub notes: Vec<String>,
50}
51
52/// Lorentzian line shape: L(x) = (1/π) · γ / [(x - x₀)² + γ²]
53fn lorentzian(x: f64, x0: f64, gamma: f64) -> f64 {
54    let g = gamma.max(1e-8);
55    let dx = x - x0;
56    g / (std::f64::consts::PI * (dx * dx + g * g))
57}
58
59/// Determine multiplicity from J-coupling count.
60fn multiplicity_label(n_couplings: usize) -> &'static str {
61    match n_couplings {
62        0 => "s", // singlet
63        1 => "d", // doublet
64        2 => "t", // triplet
65        3 => "q", // quartet
66        _ => "m", // multiplet
67    }
68}
69
70/// Generate an NMR spectrum from shift predictions and J-couplings.
71///
72/// `shifts`: predicted chemical shifts
73/// `couplings`: predicted J-coupling constants
74/// `nucleus`: which nucleus to display
75/// `gamma`: Lorentzian line width in ppm (typically 0.01–0.05 for ¹H, 0.5–2.0 for ¹³C)
76/// `ppm_min`, `ppm_max`: spectral window
77/// `n_points`: grid resolution
78pub fn compute_nmr_spectrum(
79    shifts: &NmrShiftResult,
80    couplings: &[JCoupling],
81    nucleus: NmrNucleus,
82    gamma: f64,
83    ppm_min: f64,
84    ppm_max: f64,
85    n_points: usize,
86) -> NmrSpectrum {
87    let n_points = n_points.max(2);
88    let step = (ppm_max - ppm_min) / (n_points as f64 - 1.0);
89    // NMR convention: ppm axis runs from high to low
90    let ppm_axis: Vec<f64> = (0..n_points).map(|i| ppm_max - step * i as f64).collect();
91    let mut intensities = vec![0.0; n_points];
92
93    let active_shifts: &[ChemicalShift] = match nucleus {
94        NmrNucleus::H1 => &shifts.h_shifts,
95        NmrNucleus::C13 => &shifts.c_shifts,
96    };
97
98    let mut peaks = Vec::with_capacity(active_shifts.len());
99
100    for shift in active_shifts {
101        // Count J-couplings involving this atom
102        let n_j = if matches!(nucleus, NmrNucleus::H1) {
103            couplings
104                .iter()
105                .filter(|c| c.h1_index == shift.atom_index || c.h2_index == shift.atom_index)
106                .filter(|c| c.n_bonds == 3) // only vicinal for splitting
107                .count()
108        } else {
109            0 // ¹³C typically shows as singlets in proton-decoupled spectra
110        };
111
112        let mult = multiplicity_label(n_j);
113        let intensity = 1.0; // Each H contributes equally
114
115        peaks.push(NmrPeak {
116            shift_ppm: shift.shift_ppm,
117            intensity,
118            atom_index: shift.atom_index,
119            multiplicity: mult.to_string(),
120            environment: shift.environment.clone(),
121        });
122
123        // Simple splitting pattern (first-order only)
124        if n_j == 0 || matches!(nucleus, NmrNucleus::C13) {
125            // Singlet or ¹³C: single Lorentzian
126            for (idx, &ppm) in ppm_axis.iter().enumerate() {
127                intensities[idx] += intensity * lorentzian(ppm, shift.shift_ppm, gamma);
128            }
129        } else {
130            // For ¹H with coupling: generate split pattern
131            // Average J-coupling for this atom
132            let avg_j: f64 = couplings
133                .iter()
134                .filter(|c| c.h1_index == shift.atom_index || c.h2_index == shift.atom_index)
135                .filter(|c| c.n_bonds == 3)
136                .map(|c| c.j_hz)
137                .sum::<f64>()
138                / n_j.max(1) as f64;
139
140            // Convert J from Hz to ppm (assume 400 MHz spectrometer)
141            let j_ppm = avg_j / 400.0;
142
143            // Generate Pascal's triangle splitting
144            let n_lines = n_j + 1;
145            let coeffs = pascal_row(n_j);
146            let total: f64 = coeffs.iter().sum::<f64>();
147
148            for (k, &coeff) in coeffs.iter().enumerate() {
149                let offset = (k as f64 - n_j as f64 / 2.0) * j_ppm;
150                let line_ppm = shift.shift_ppm + offset;
151                let line_intensity = intensity * coeff / total;
152
153                for (idx, &ppm) in ppm_axis.iter().enumerate() {
154                    intensities[idx] += line_intensity * lorentzian(ppm, line_ppm, gamma);
155                }
156            }
157
158            // Update peak multiplicity
159            if let Some(p) = peaks.last_mut() {
160                p.multiplicity = format!("{} (n+1={}, J≈{:.1} Hz)", mult, n_lines, avg_j);
161            }
162        }
163    }
164
165    let nucleus_label = match nucleus {
166        NmrNucleus::H1 => "¹H",
167        NmrNucleus::C13 => "¹³C",
168    };
169
170    NmrSpectrum {
171        ppm_axis,
172        intensities,
173        peaks,
174        nucleus,
175        gamma,
176        notes: vec![
177            format!(
178                "{} NMR spectrum with Lorentzian broadening (γ = {} ppm).",
179                nucleus_label, gamma
180            ),
181            "Chemical shifts from empirical additivity rules; J-couplings from Karplus equation.".to_string(),
182            "First-order splitting only; higher-order effects (roofing, strong coupling) not modeled.".to_string(),
183        ],
184    }
185}
186
187/// Pascal's triangle row n: [C(n,0), C(n,1), ..., C(n,n)].
188fn pascal_row(n: usize) -> Vec<f64> {
189    let mut row = vec![1.0];
190    for k in 1..=n {
191        let prev = row[k - 1];
192        row.push(prev * (n - k + 1) as f64 / k as f64);
193    }
194    row
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_lorentzian_normalization() {
203        // Integral of Lorentzian should be 1 (approximately)
204        let gamma = 0.02;
205        let n = 10000;
206        let dx = 20.0 / n as f64;
207        let integral: f64 = (0..n)
208            .map(|i| {
209                let x = -10.0 + i as f64 * dx;
210                lorentzian(x, 0.0, gamma) * dx
211            })
212            .sum();
213        assert!(
214            (integral - 1.0).abs() < 0.01,
215            "Lorentzian integral = {}, expected ~1.0",
216            integral
217        );
218    }
219
220    #[test]
221    fn test_pascal_row() {
222        assert_eq!(pascal_row(0), vec![1.0]);
223        assert_eq!(pascal_row(1), vec![1.0, 1.0]);
224        assert_eq!(pascal_row(2), vec![1.0, 2.0, 1.0]);
225        assert_eq!(pascal_row(3), vec![1.0, 3.0, 3.0, 1.0]);
226    }
227
228    #[test]
229    fn test_nmr_spectrum_h1_ethanol() {
230        let mol = crate::graph::Molecule::from_smiles("CCO").unwrap();
231        let shifts = super::super::shifts::predict_chemical_shifts(&mol);
232        let couplings = super::super::coupling::predict_j_couplings(&mol, &[]);
233
234        let spectrum =
235            compute_nmr_spectrum(&shifts, &couplings, NmrNucleus::H1, 0.02, 0.0, 12.0, 1000);
236
237        assert_eq!(spectrum.ppm_axis.len(), 1000);
238        assert_eq!(spectrum.intensities.len(), 1000);
239        assert!(!spectrum.peaks.is_empty());
240
241        // Verify ppm axis goes from high to low (NMR convention)
242        assert!(spectrum.ppm_axis[0] > spectrum.ppm_axis[999]);
243    }
244
245    #[test]
246    fn test_nmr_spectrum_c13_benzene() {
247        let mol = crate::graph::Molecule::from_smiles("c1ccccc1").unwrap();
248        let shifts = super::super::shifts::predict_chemical_shifts(&mol);
249        let couplings = super::super::coupling::predict_j_couplings(&mol, &[]);
250
251        let spectrum =
252            compute_nmr_spectrum(&shifts, &couplings, NmrNucleus::C13, 1.0, 0.0, 220.0, 1000);
253
254        assert!(!spectrum.peaks.is_empty(), "Benzene should have ¹³C peaks");
255
256        // All aromatic C should cluster near 128 ppm
257        for peak in &spectrum.peaks {
258            assert!(
259                (peak.shift_ppm - 128.0).abs() < 20.0,
260                "Benzene ¹³C peak at {} ppm should be near 128",
261                peak.shift_ppm
262            );
263        }
264    }
265
266    #[test]
267    fn test_multiplicity_labels() {
268        assert_eq!(multiplicity_label(0), "s");
269        assert_eq!(multiplicity_label(1), "d");
270        assert_eq!(multiplicity_label(2), "t");
271        assert_eq!(multiplicity_label(3), "q");
272        assert_eq!(multiplicity_label(4), "m");
273    }
274}