1use serde::{Deserialize, Serialize};
7
8use super::coupling::JCoupling;
9use super::shifts::{ChemicalShift, NmrShiftResult};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum NmrNucleus {
14 H1,
16 C13,
18 F19,
20 P31,
22 N15,
24 B11,
26 Si29,
28 Se77,
30 O17,
32 S33,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct NmrPeak {
39 pub shift_ppm: f64,
41 pub intensity: f64,
43 pub atom_index: usize,
45 pub multiplicity: String,
47 pub environment: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct NmrSpectrum {
54 pub ppm_axis: Vec<f64>,
56 pub intensities: Vec<f64>,
58 pub peaks: Vec<NmrPeak>,
60 pub nucleus: NmrNucleus,
62 pub gamma: f64,
64 pub integrations: Vec<PeakIntegration>,
66 pub notes: Vec<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PeakIntegration {
73 pub center_ppm: f64,
75 pub bounds: (f64, f64),
77 pub raw_area: f64,
79 pub relative_area: f64,
81 pub n_atoms: usize,
83}
84
85fn default_fwhm_hz(nucleus: NmrNucleus) -> f64 {
87 match nucleus {
88 NmrNucleus::H1 => 1.0, NmrNucleus::C13 => 5.0, NmrNucleus::F19 => 5.0,
91 NmrNucleus::P31 => 10.0,
92 NmrNucleus::N15 => 10.0,
93 NmrNucleus::B11 => 50.0, NmrNucleus::Si29 => 5.0,
95 NmrNucleus::Se77 => 20.0,
96 NmrNucleus::O17 => 100.0, NmrNucleus::S33 => 100.0, }
99}
100
101fn 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
117fn 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
124fn multiplicity_label(n_couplings: usize) -> &'static str {
126 match n_couplings {
127 0 => "s", 1 => "d", 2 => "t", 3 => "q", _ => "m", }
133}
134
135pub 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 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 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 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) .count()
188 } else {
189 0 };
191
192 let mult = multiplicity_label(n_j);
193 let intensity = 1.0; 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 if n_j == 0 || !matches!(nucleus, NmrNucleus::H1) {
205 for (idx, &ppm) in ppm_axis.iter().enumerate() {
207 intensities[idx] += intensity * lorentzian(ppm, shift.shift_ppm, effective_gamma);
208 }
209 } else {
210 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 let j_ppm = avg_j / 400.0;
222
223 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 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 let integration_width = effective_gamma * 10.0; 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
282fn 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 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
332fn 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 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 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 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}