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    /// ¹⁹F NMR
19    F19,
20    /// ³¹P NMR
21    P31,
22    /// ¹⁵N NMR
23    N15,
24    /// ¹¹B NMR
25    B11,
26    /// ²⁹Si NMR
27    Si29,
28    /// ⁷⁷Se NMR
29    Se77,
30    /// ¹⁷O NMR
31    O17,
32    /// ³³S NMR
33    S33,
34}
35
36/// A single peak in the NMR spectrum.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct NmrPeak {
39    /// Chemical shift in ppm.
40    pub shift_ppm: f64,
41    /// Relative intensity (integration).
42    pub intensity: f64,
43    /// Atom index.
44    pub atom_index: usize,
45    /// Multiplicity label (s, d, t, q, m).
46    pub multiplicity: String,
47    /// Environment description.
48    pub environment: String,
49}
50
51/// Complete NMR spectrum.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct NmrSpectrum {
54    /// Chemical shift axis (ppm), typically decreasing.
55    pub ppm_axis: Vec<f64>,
56    /// Intensity axis.
57    pub intensities: Vec<f64>,
58    /// Identified peaks.
59    pub peaks: Vec<NmrPeak>,
60    /// Nucleus type.
61    pub nucleus: NmrNucleus,
62    /// Line width parameter (ppm).
63    pub gamma: f64,
64    /// Peak group integrations (relative areas, normalized to largest = 1.0).
65    pub integrations: Vec<PeakIntegration>,
66    /// Notes.
67    pub notes: Vec<String>,
68}
69
70/// Integration result for a group of equivalent peaks.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PeakIntegration {
73    /// Center of the peak group (ppm).
74    pub center_ppm: f64,
75    /// Integration bounds (ppm_low, ppm_high).
76    pub bounds: (f64, f64),
77    /// Raw area (sum of intensities × step).
78    pub raw_area: f64,
79    /// Relative area (normalized so largest group = 1.0).
80    pub relative_area: f64,
81    /// Number of equivalent atoms contributing.
82    pub n_atoms: usize,
83}
84
85/// Default FWHM (full-width at half-maximum) for each nucleus type, in Hz.
86fn default_fwhm_hz(nucleus: NmrNucleus) -> f64 {
87    match nucleus {
88        NmrNucleus::H1 => 1.0,  // Narrow lines for ¹H
89        NmrNucleus::C13 => 5.0, // Broader for ¹³C
90        NmrNucleus::F19 => 5.0,
91        NmrNucleus::P31 => 10.0,
92        NmrNucleus::N15 => 10.0,
93        NmrNucleus::B11 => 50.0, // Quadrupolar broadening
94        NmrNucleus::Si29 => 5.0,
95        NmrNucleus::Se77 => 20.0,
96        NmrNucleus::O17 => 100.0, // Quadrupolar
97        NmrNucleus::S33 => 100.0, // Quadrupolar
98    }
99}
100
101/// Default spectrometer frequency for each nucleus (MHz).
102fn default_frequency_mhz(nucleus: NmrNucleus) -> f64 {
103    match nucleus {
104        NmrNucleus::H1 => 400.0,
105        NmrNucleus::C13 => 100.6,
106        NmrNucleus::F19 => 376.5,
107        NmrNucleus::P31 => 162.0,
108        NmrNucleus::N15 => 40.6,
109        NmrNucleus::B11 => 128.4,
110        NmrNucleus::Si29 => 79.5,
111        NmrNucleus::Se77 => 76.3,
112        NmrNucleus::O17 => 54.2,
113        NmrNucleus::S33 => 30.7,
114    }
115}
116
117/// Lorentzian line shape: L(x) = (1/π) · γ / [(x - x₀)² + γ²]
118fn lorentzian(x: f64, x0: f64, gamma: f64) -> f64 {
119    let g = gamma.max(1e-8);
120    let dx = x - x0;
121    g / (std::f64::consts::PI * (dx * dx + g * g))
122}
123
124/// Determine multiplicity from J-coupling count.
125fn multiplicity_label(n_couplings: usize) -> &'static str {
126    match n_couplings {
127        0 => "s", // singlet
128        1 => "d", // doublet
129        2 => "t", // triplet
130        3 => "q", // quartet
131        _ => "m", // multiplet
132    }
133}
134
135/// Generate an NMR spectrum from shift predictions and J-couplings.
136///
137/// `shifts`: predicted chemical shifts
138/// `couplings`: predicted J-coupling constants
139/// `nucleus`: which nucleus to display
140/// `gamma`: Lorentzian line width in ppm (if 0.0, uses nucleus-specific FWHM default)
141/// `ppm_min`, `ppm_max`: spectral window
142/// `n_points`: grid resolution
143pub fn compute_nmr_spectrum(
144    shifts: &NmrShiftResult,
145    couplings: &[JCoupling],
146    nucleus: NmrNucleus,
147    gamma: f64,
148    ppm_min: f64,
149    ppm_max: f64,
150    n_points: usize,
151) -> NmrSpectrum {
152    let n_points = n_points.max(2);
153    let step = (ppm_max - ppm_min) / (n_points as f64 - 1.0);
154    // NMR convention: ppm axis runs from high to low
155    let ppm_axis: Vec<f64> = (0..n_points).map(|i| ppm_max - step * i as f64).collect();
156    let mut intensities = vec![0.0; n_points];
157
158    // Use nucleus-specific FWHM if gamma is 0 or very small
159    let effective_gamma = if gamma > 1e-6 {
160        gamma
161    } else {
162        default_fwhm_hz(nucleus) / default_frequency_mhz(nucleus)
163    };
164
165    let active_shifts: &[ChemicalShift] = match nucleus {
166        NmrNucleus::H1 => &shifts.h_shifts,
167        NmrNucleus::C13 => &shifts.c_shifts,
168        NmrNucleus::F19 => &shifts.f_shifts,
169        NmrNucleus::P31 => &shifts.p_shifts,
170        NmrNucleus::N15 => &shifts.n_shifts,
171        NmrNucleus::B11 => &shifts.b_shifts,
172        NmrNucleus::Si29 => &shifts.si_shifts,
173        NmrNucleus::Se77 => &shifts.se_shifts,
174        NmrNucleus::O17 => &shifts.o_shifts,
175        NmrNucleus::S33 => &shifts.s_shifts,
176    };
177
178    let mut peaks = Vec::with_capacity(active_shifts.len());
179
180    for shift in active_shifts {
181        // Count J-couplings involving this atom
182        let n_j = if matches!(nucleus, NmrNucleus::H1) {
183            couplings
184                .iter()
185                .filter(|c| c.h1_index == shift.atom_index || c.h2_index == shift.atom_index)
186                .filter(|c| c.n_bonds == 3) // only vicinal for splitting
187                .count()
188        } else {
189            0 // Non-¹H nuclei typically shown as singlets in decoupled spectra
190        };
191
192        let mult = multiplicity_label(n_j);
193        let intensity = 1.0; // Each H contributes equally
194
195        peaks.push(NmrPeak {
196            shift_ppm: shift.shift_ppm,
197            intensity,
198            atom_index: shift.atom_index,
199            multiplicity: mult.to_string(),
200            environment: shift.environment.clone(),
201        });
202
203        // Simple splitting pattern (first-order only)
204        if n_j == 0 || !matches!(nucleus, NmrNucleus::H1) {
205            // Singlet or non-¹H: single Lorentzian
206            for (idx, &ppm) in ppm_axis.iter().enumerate() {
207                intensities[idx] += intensity * lorentzian(ppm, shift.shift_ppm, effective_gamma);
208            }
209        } else {
210            // For ¹H with coupling: generate split pattern
211            // Average J-coupling for this atom
212            let avg_j: f64 = couplings
213                .iter()
214                .filter(|c| c.h1_index == shift.atom_index || c.h2_index == shift.atom_index)
215                .filter(|c| c.n_bonds == 3)
216                .map(|c| c.j_hz)
217                .sum::<f64>()
218                / n_j.max(1) as f64;
219
220            // Convert J from Hz to ppm (assume 400 MHz spectrometer)
221            let j_ppm = avg_j / 400.0;
222
223            // Generate Pascal's triangle splitting
224            let n_lines = n_j + 1;
225            let coeffs = pascal_row(n_j);
226            let total: f64 = coeffs.iter().sum::<f64>();
227
228            for (k, &coeff) in coeffs.iter().enumerate() {
229                let offset = (k as f64 - n_j as f64 / 2.0) * j_ppm;
230                let line_ppm = shift.shift_ppm + offset;
231                let line_intensity = intensity * coeff / total;
232
233                for (idx, &ppm) in ppm_axis.iter().enumerate() {
234                    intensities[idx] += line_intensity * lorentzian(ppm, line_ppm, effective_gamma);
235                }
236            }
237
238            // Update peak multiplicity
239            if let Some(p) = peaks.last_mut() {
240                p.multiplicity = format!("{} (n+1={}, J≈{:.1} Hz)", mult, n_lines, avg_j);
241            }
242        }
243    }
244
245    let nucleus_label = match nucleus {
246        NmrNucleus::H1 => "¹H",
247        NmrNucleus::C13 => "¹³C",
248        NmrNucleus::F19 => "¹⁹F",
249        NmrNucleus::P31 => "³¹P",
250        NmrNucleus::N15 => "¹⁵N",
251        NmrNucleus::B11 => "¹¹B",
252        NmrNucleus::Si29 => "²⁹Si",
253        NmrNucleus::Se77 => "⁷⁷Se",
254        NmrNucleus::O17 => "¹⁷O",
255        NmrNucleus::S33 => "³³S",
256    };
257
258    // Compute integrations for each peak group
259    let integration_width = effective_gamma * 10.0; // integrate ±10γ around each peak
260    let integrations =
261        compute_integrations(&peaks, &ppm_axis, &intensities, step, integration_width);
262
263    NmrSpectrum {
264        ppm_axis,
265        intensities,
266        peaks,
267        nucleus,
268        gamma: effective_gamma,
269        integrations,
270        notes: vec![
271            format!(
272                "{} NMR spectrum with Lorentzian broadening (γ = {:.4} ppm, FWHM = {:.1} Hz).",
273                nucleus_label, effective_gamma,
274                effective_gamma * default_frequency_mhz(nucleus)
275            ),
276            "Chemical shifts from empirical additivity rules; J-couplings from Karplus equation.".to_string(),
277            "First-order splitting only; higher-order effects (roofing, strong coupling) not modeled.".to_string(),
278        ],
279    }
280}
281
282/// Compute peak integrations for groups of peaks in the spectrum.
283fn compute_integrations(
284    peaks: &[NmrPeak],
285    ppm_axis: &[f64],
286    intensities: &[f64],
287    step: f64,
288    integration_width: f64,
289) -> Vec<PeakIntegration> {
290    if peaks.is_empty() || ppm_axis.is_empty() {
291        return Vec::new();
292    }
293
294    let mut integrations: Vec<PeakIntegration> = peaks
295        .iter()
296        .map(|peak| {
297            let low = peak.shift_ppm - integration_width;
298            let high = peak.shift_ppm + integration_width;
299
300            let raw_area: f64 = ppm_axis
301                .iter()
302                .zip(intensities.iter())
303                .filter(|(&ppm, _)| ppm >= low && ppm <= high)
304                .map(|(_, &intensity)| intensity * step)
305                .sum();
306
307            PeakIntegration {
308                center_ppm: peak.shift_ppm,
309                bounds: (low, high),
310                raw_area,
311                relative_area: 0.0,
312                n_atoms: 1,
313            }
314        })
315        .collect();
316
317    // Normalize relative areas
318    let max_area = integrations
319        .iter()
320        .map(|i| i.raw_area)
321        .fold(0.0f64, f64::max);
322
323    if max_area > 1e-30 {
324        for int in &mut integrations {
325            int.relative_area = int.raw_area / max_area;
326        }
327    }
328
329    integrations
330}
331
332/// Pascal's triangle row n: [C(n,0), C(n,1), ..., C(n,n)].
333fn pascal_row(n: usize) -> Vec<f64> {
334    let mut row = vec![1.0];
335    for k in 1..=n {
336        let prev = row[k - 1];
337        row.push(prev * (n - k + 1) as f64 / k as f64);
338    }
339    row
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_lorentzian_normalization() {
348        // Integral of Lorentzian should be 1 (approximately)
349        let gamma = 0.02;
350        let n = 10000;
351        let dx = 20.0 / n as f64;
352        let integral: f64 = (0..n)
353            .map(|i| {
354                let x = -10.0 + i as f64 * dx;
355                lorentzian(x, 0.0, gamma) * dx
356            })
357            .sum();
358        assert!(
359            (integral - 1.0).abs() < 0.01,
360            "Lorentzian integral = {}, expected ~1.0",
361            integral
362        );
363    }
364
365    #[test]
366    fn test_pascal_row() {
367        assert_eq!(pascal_row(0), vec![1.0]);
368        assert_eq!(pascal_row(1), vec![1.0, 1.0]);
369        assert_eq!(pascal_row(2), vec![1.0, 2.0, 1.0]);
370        assert_eq!(pascal_row(3), vec![1.0, 3.0, 3.0, 1.0]);
371    }
372
373    #[test]
374    fn test_nmr_spectrum_h1_ethanol() {
375        let mol = crate::graph::Molecule::from_smiles("CCO").unwrap();
376        let shifts = super::super::shifts::predict_chemical_shifts(&mol);
377        let couplings = super::super::coupling::predict_j_couplings(&mol, &[]);
378
379        let spectrum =
380            compute_nmr_spectrum(&shifts, &couplings, NmrNucleus::H1, 0.02, 0.0, 12.0, 1000);
381
382        assert_eq!(spectrum.ppm_axis.len(), 1000);
383        assert_eq!(spectrum.intensities.len(), 1000);
384        assert!(!spectrum.peaks.is_empty());
385
386        // Verify ppm axis goes from high to low (NMR convention)
387        assert!(spectrum.ppm_axis[0] > spectrum.ppm_axis[999]);
388    }
389
390    #[test]
391    fn test_nmr_spectrum_c13_benzene() {
392        let mol = crate::graph::Molecule::from_smiles("c1ccccc1").unwrap();
393        let shifts = super::super::shifts::predict_chemical_shifts(&mol);
394        let couplings = super::super::coupling::predict_j_couplings(&mol, &[]);
395
396        let spectrum =
397            compute_nmr_spectrum(&shifts, &couplings, NmrNucleus::C13, 1.0, 0.0, 220.0, 1000);
398
399        assert!(!spectrum.peaks.is_empty(), "Benzene should have ¹³C peaks");
400
401        // All aromatic C should cluster near 128 ppm
402        for peak in &spectrum.peaks {
403            assert!(
404                (peak.shift_ppm - 128.0).abs() < 20.0,
405                "Benzene ¹³C peak at {} ppm should be near 128",
406                peak.shift_ppm
407            );
408        }
409    }
410
411    #[test]
412    fn test_multiplicity_labels() {
413        assert_eq!(multiplicity_label(0), "s");
414        assert_eq!(multiplicity_label(1), "d");
415        assert_eq!(multiplicity_label(2), "t");
416        assert_eq!(multiplicity_label(3), "q");
417        assert_eq!(multiplicity_label(4), "m");
418    }
419}