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}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct NmrPeak {
23 pub shift_ppm: f64,
25 pub intensity: f64,
27 pub atom_index: usize,
29 pub multiplicity: String,
31 pub environment: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct NmrSpectrum {
38 pub ppm_axis: Vec<f64>,
40 pub intensities: Vec<f64>,
42 pub peaks: Vec<NmrPeak>,
44 pub nucleus: NmrNucleus,
46 pub gamma: f64,
48 pub notes: Vec<String>,
50}
51
52fn 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
59fn multiplicity_label(n_couplings: usize) -> &'static str {
61 match n_couplings {
62 0 => "s", 1 => "d", 2 => "t", 3 => "q", _ => "m", }
68}
69
70pub 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 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 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) .count()
108 } else {
109 0 };
111
112 let mult = multiplicity_label(n_j);
113 let intensity = 1.0; 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 if n_j == 0 || matches!(nucleus, NmrNucleus::C13) {
125 for (idx, &ppm) in ppm_axis.iter().enumerate() {
127 intensities[idx] += intensity * lorentzian(ppm, shift.shift_ppm, gamma);
128 }
129 } else {
130 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 let j_ppm = avg_j / 400.0;
142
143 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 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
187fn 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 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 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 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}