1use nalgebra::DMatrix;
8use serde::{Deserialize, Serialize};
9
10use super::hessian::{compute_numerical_hessian, HessianMethod};
11
12fn atomic_mass(z: u8) -> f64 {
14 match z {
15 1 => 1.00794,
16 2 => 4.00260,
17 5 => 10.811,
18 6 => 12.0107,
19 7 => 14.0067,
20 8 => 15.9994,
21 9 => 18.9984,
22 14 => 28.0855,
23 15 => 30.9738,
24 16 => 32.065,
25 17 => 35.453,
26 22 => 47.867,
27 24 => 51.9961,
28 25 => 54.9380,
29 26 => 55.845,
30 27 => 58.9332,
31 28 => 58.6934,
32 29 => 63.546,
33 30 => 65.38,
34 35 => 79.904,
35 44 => 101.07,
36 46 => 106.42,
37 47 => 107.868,
38 53 => 126.904,
39 78 => 195.084,
40 79 => 196.967,
41 _ => z as f64 * 1.5, }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct VibrationalMode {
48 pub frequency_cm1: f64,
50 pub ir_intensity: f64,
52 pub displacement: Vec<f64>,
54 pub is_real: bool,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct VibrationalAnalysis {
61 pub n_atoms: usize,
63 pub modes: Vec<VibrationalMode>,
65 pub n_real_modes: usize,
67 pub zpve_ev: f64,
69 pub thermochemistry: Option<Thermochemistry>,
71 pub method: String,
73 pub notes: Vec<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Thermochemistry {
80 pub temperature_k: f64,
82 pub zpve_kcal: f64,
84 pub thermal_energy_kcal: f64,
86 pub enthalpy_correction_kcal: f64,
88 pub entropy_vib_cal: f64,
90 pub gibbs_correction_kcal: f64,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct IrPeak {
97 pub frequency_cm1: f64,
99 pub ir_intensity: f64,
101 pub mode_index: usize,
103 pub assignment: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct IrSpectrum {
110 pub wavenumbers: Vec<f64>,
112 pub intensities: Vec<f64>,
114 pub peaks: Vec<IrPeak>,
116 pub gamma: f64,
118 pub notes: Vec<String>,
120}
121
122const EV_TO_CM1: f64 = 8065.544;
125const HESSIAN_TO_FREQUENCY_FACTOR: f64 = 9.6485e27; const INV_2PI_C: f64 = 1.0 / (2.0 * std::f64::consts::PI * 2.99792458e10); fn compute_dipole_vector(
135 elements: &[u8],
136 positions: &[[f64; 3]],
137 method: HessianMethod,
138) -> Result<[f64; 3], String> {
139 match method {
140 HessianMethod::Eht => {
141 let eht = crate::eht::solve_eht(elements, positions, None)?;
142 let dipole = crate::dipole::compute_dipole_from_eht(
143 elements,
144 positions,
145 &eht.coefficients,
146 eht.n_electrons,
147 );
148 Ok(dipole.vector)
149 }
150 HessianMethod::Pm3 => {
151 let pm3 = crate::pm3::solve_pm3(elements, positions)?;
152 let dipole = crate::dipole::compute_dipole(&pm3.mulliken_charges, positions);
154 Ok(dipole.vector)
155 }
156 HessianMethod::Xtb => {
157 let xtb = crate::xtb::solve_xtb(elements, positions)?;
158 let dipole = crate::dipole::compute_dipole(&xtb.mulliken_charges, positions);
159 Ok(dipole.vector)
160 }
161 }
162}
163
164fn compute_ir_intensities(
168 elements: &[u8],
169 positions: &[[f64; 3]],
170 normal_modes: &DMatrix<f64>,
171 method: HessianMethod,
172 delta: f64,
173) -> Result<Vec<f64>, String> {
174 let n_atoms = elements.len();
175 let n_modes = normal_modes.ncols();
176 let masses: Vec<f64> = elements.iter().map(|&z| atomic_mass(z)).collect();
177
178 let mut intensities = Vec::with_capacity(n_modes);
179
180 for k in 0..n_modes {
181 let mut pos_plus: Vec<[f64; 3]> = positions.to_vec();
183 let mut pos_minus: Vec<[f64; 3]> = positions.to_vec();
184
185 for i in 0..n_atoms {
186 let sqrt_m = masses[i].sqrt();
187 for c in 0..3 {
188 let disp = normal_modes[(3 * i + c, k)] * delta / sqrt_m;
189 pos_plus[i][c] += disp;
190 pos_minus[i][c] -= disp;
191 }
192 }
193
194 let mu_plus = compute_dipole_vector(elements, &pos_plus, method)?;
195 let mu_minus = compute_dipole_vector(elements, &pos_minus, method)?;
196
197 let dmu_dq: Vec<f64> = (0..3)
199 .map(|c| (mu_plus[c] - mu_minus[c]) / (2.0 * delta))
200 .collect();
201
202 let intensity = dmu_dq.iter().map(|x| x * x).sum::<f64>();
204 intensities.push(intensity * 42.256);
206 }
207
208 Ok(intensities)
209}
210
211fn lorentzian(x: f64, x0: f64, gamma: f64) -> f64 {
213 let g = gamma.max(1e-6);
214 let dx = x - x0;
215 g / (std::f64::consts::PI * (dx * dx + g * g))
216}
217
218fn gaussian(x: f64, x0: f64, sigma: f64) -> f64 {
220 let s = sigma.max(1e-6);
221 let dx = x - x0;
222 (-(dx * dx) / (2.0 * s * s)).exp() / (s * (2.0 * std::f64::consts::PI).sqrt())
223}
224
225fn assign_ir_peak(frequency_cm1: f64) -> String {
227 if frequency_cm1 > 3500.0 {
228 "O-H stretch (broad)".to_string()
229 } else if frequency_cm1 > 3300.0 {
230 "N-H stretch".to_string()
231 } else if frequency_cm1 > 3000.0 {
232 "sp2 C-H stretch".to_string()
233 } else if frequency_cm1 > 2800.0 {
234 "sp3 C-H stretch".to_string()
235 } else if frequency_cm1 > 2100.0 && frequency_cm1 < 2300.0 {
236 "C≡N or C≡C stretch".to_string()
237 } else if frequency_cm1 > 1680.0 && frequency_cm1 < 1800.0 {
238 "C=O stretch".to_string()
239 } else if frequency_cm1 > 1600.0 && frequency_cm1 < 1680.0 {
240 "C=C stretch".to_string()
241 } else if frequency_cm1 > 1400.0 && frequency_cm1 < 1600.0 {
242 "aromatic C=C stretch".to_string()
243 } else if frequency_cm1 > 1300.0 && frequency_cm1 < 1400.0 {
244 "C-H bend".to_string()
245 } else if frequency_cm1 > 1000.0 && frequency_cm1 < 1300.0 {
246 "C-O stretch".to_string()
247 } else if frequency_cm1 > 600.0 && frequency_cm1 < 900.0 {
248 "C-H out-of-plane bend".to_string()
249 } else {
250 "skeletal mode".to_string()
251 }
252}
253
254fn compute_thermochemistry(frequencies_cm1: &[f64], temperature_k: f64) -> Thermochemistry {
258 const CM1_TO_EV: f64 = 1.0 / 8065.544;
260 const EV_TO_KCAL: f64 = 23.0605;
261 const R_KCAL: f64 = 0.001987204; const R_CAL: f64 = 1.987204; const KB_EV: f64 = 8.617333e-5; let kbt_ev = KB_EV * temperature_k;
266
267 let real_freqs: Vec<f64> = frequencies_cm1
268 .iter()
269 .filter(|&&f| f > 50.0)
270 .copied()
271 .collect();
272
273 let zpve_ev: f64 = real_freqs.iter().map(|&f| 0.5 * f * CM1_TO_EV * 0.9).sum();
275 let zpve_kcal = zpve_ev * EV_TO_KCAL;
276
277 let thermal_e_ev: f64 = real_freqs
279 .iter()
280 .map(|&f| {
281 let hnu = f * CM1_TO_EV;
282 let x = hnu / kbt_ev;
283 if x > 100.0 {
284 0.0
285 } else {
286 hnu / (x.exp() - 1.0)
287 }
288 })
289 .sum();
290 let thermal_energy_kcal = thermal_e_ev * EV_TO_KCAL;
291
292 let enthalpy_correction_kcal = thermal_energy_kcal + R_KCAL * temperature_k;
294
295 let entropy_vib_cal: f64 = real_freqs
297 .iter()
298 .map(|&f| {
299 let hnu = f * CM1_TO_EV;
300 let x = hnu / kbt_ev;
301 if x > 100.0 {
302 0.0
303 } else {
304 let ex = x.exp();
305 R_CAL * (x / (ex - 1.0) - (1.0 - (-x).exp()).ln())
306 }
307 })
308 .sum();
309
310 let gibbs_correction_kcal = enthalpy_correction_kcal - temperature_k * entropy_vib_cal / 1000.0;
312
313 Thermochemistry {
314 temperature_k,
315 zpve_kcal,
316 thermal_energy_kcal,
317 enthalpy_correction_kcal,
318 entropy_vib_cal,
319 gibbs_correction_kcal,
320 }
321}
322
323pub fn compute_vibrational_analysis(
328 elements: &[u8],
329 positions: &[[f64; 3]],
330 method: HessianMethod,
331 step_size: Option<f64>,
332) -> Result<VibrationalAnalysis, String> {
333 let n_atoms = elements.len();
334 let n3 = 3 * n_atoms;
335 let delta = step_size.unwrap_or(0.005);
336
337 if n_atoms < 2 {
338 return Err("Need at least 2 atoms for vibrational analysis".to_string());
339 }
340
341 let hessian = compute_numerical_hessian(elements, positions, method, Some(delta))?;
343
344 let masses: Vec<f64> = elements.iter().map(|&z| atomic_mass(z)).collect();
346 let mut mw_hessian = DMatrix::zeros(n3, n3);
347 for i in 0..n3 {
348 let mi = masses[i / 3];
349 for j in 0..n3 {
350 let mj = masses[j / 3];
351 mw_hessian[(i, j)] = hessian[(i, j)] / (mi * mj).sqrt();
352 }
353 }
354
355 let eigen = mw_hessian.symmetric_eigen();
357
358 let mut indices: Vec<usize> = (0..n3).collect();
360 indices.sort_by(|&a, &b| {
361 eigen.eigenvalues[a]
362 .partial_cmp(&eigen.eigenvalues[b])
363 .unwrap_or(std::cmp::Ordering::Equal)
364 });
365
366 let is_linear = n_atoms == 2; let _n_tr = if is_linear { 5 } else { 6 };
370 let freq_threshold = 50.0; let mut sorted_eigenvalues = Vec::with_capacity(n3);
373 let mut sorted_modes = DMatrix::zeros(n3, n3);
374 for (new_idx, &old_idx) in indices.iter().enumerate() {
375 sorted_eigenvalues.push(eigen.eigenvalues[old_idx]);
376 for i in 0..n3 {
377 sorted_modes[(i, new_idx)] = eigen.eigenvectors[(i, old_idx)];
378 }
379 }
380
381 let frequencies: Vec<f64> = sorted_eigenvalues
383 .iter()
384 .map(|&ev| {
385 if ev >= 0.0 {
386 (ev * HESSIAN_TO_FREQUENCY_FACTOR).sqrt() * INV_2PI_C
388 } else {
389 -((-ev) * HESSIAN_TO_FREQUENCY_FACTOR).sqrt() * INV_2PI_C
391 }
392 })
393 .collect();
394
395 let ir_intensities = compute_ir_intensities(
397 elements,
398 positions,
399 &sorted_modes,
400 method,
401 delta * 2.0, )?;
403
404 let mut modes = Vec::with_capacity(n3);
406 let mut n_real = 0;
407 let mut zpve = 0.0;
408
409 for k in 0..n3 {
410 let freq = frequencies[k];
411 let is_real = freq.abs() > freq_threshold;
412 if is_real && freq > 0.0 {
413 n_real += 1;
414 zpve += 0.5 * freq / EV_TO_CM1;
416 }
417
418 let displacement: Vec<f64> = (0..n3).map(|i| sorted_modes[(i, k)]).collect();
419
420 modes.push(VibrationalMode {
421 frequency_cm1: freq,
422 ir_intensity: ir_intensities.get(k).copied().unwrap_or(0.0),
423 displacement,
424 is_real,
425 });
426 }
427
428 let method_name = match method {
429 HessianMethod::Eht => "EHT",
430 HessianMethod::Pm3 => "PM3",
431 HessianMethod::Xtb => "xTB",
432 };
433
434 let thermochemistry = Some(compute_thermochemistry(&frequencies, 298.15));
436
437 Ok(VibrationalAnalysis {
438 n_atoms,
439 modes,
440 n_real_modes: n_real,
441 zpve_ev: zpve,
442 thermochemistry,
443 method: method_name.to_string(),
444 notes: vec![
445 format!(
446 "Numerical Hessian computed with {} using central finite differences (δ = {} Å).",
447 method_name, delta
448 ),
449 "IR intensities derived from numerical dipole derivatives along normal coordinates."
450 .to_string(),
451 "Frequencies below 50 cm⁻¹ are classified as translation/rotation modes.".to_string(),
452 ],
453 })
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
458pub enum BroadeningType {
459 Lorentzian,
461 Gaussian,
463}
464
465pub fn compute_ir_spectrum(
471 analysis: &VibrationalAnalysis,
472 gamma: f64,
473 wn_min: f64,
474 wn_max: f64,
475 n_points: usize,
476) -> IrSpectrum {
477 compute_ir_spectrum_with_broadening(
478 analysis,
479 gamma,
480 wn_min,
481 wn_max,
482 n_points,
483 BroadeningType::Lorentzian,
484 )
485}
486
487pub fn compute_ir_spectrum_with_broadening(
489 analysis: &VibrationalAnalysis,
490 gamma: f64,
491 wn_min: f64,
492 wn_max: f64,
493 n_points: usize,
494 broadening: BroadeningType,
495) -> IrSpectrum {
496 let n_points = n_points.max(2);
497 let step = (wn_max - wn_min) / (n_points as f64 - 1.0);
498 let wavenumbers: Vec<f64> = (0..n_points).map(|i| wn_min + step * i as f64).collect();
499 let mut intensities = vec![0.0; n_points];
500
501 let mut peaks = Vec::new();
502
503 for (mode_idx, mode) in analysis.modes.iter().enumerate() {
504 if !mode.is_real || mode.frequency_cm1 <= 0.0 {
505 continue;
506 }
507
508 peaks.push(IrPeak {
509 frequency_cm1: mode.frequency_cm1,
510 ir_intensity: mode.ir_intensity,
511 mode_index: mode_idx,
512 assignment: assign_ir_peak(mode.frequency_cm1),
513 });
514
515 for (idx, &wn) in wavenumbers.iter().enumerate() {
516 intensities[idx] += mode.ir_intensity
517 * match broadening {
518 BroadeningType::Lorentzian => lorentzian(wn, mode.frequency_cm1, gamma),
519 BroadeningType::Gaussian => gaussian(wn, mode.frequency_cm1, gamma),
520 };
521 }
522 }
523
524 peaks.sort_by(|a, b| {
525 b.ir_intensity
526 .partial_cmp(&a.ir_intensity)
527 .unwrap_or(std::cmp::Ordering::Equal)
528 });
529
530 IrSpectrum {
531 wavenumbers,
532 intensities,
533 peaks,
534 gamma,
535 notes: vec![
536 format!(
537 "IR spectrum generated with Lorentzian broadening (γ = {} cm⁻¹).",
538 gamma
539 ),
540 format!("Vibrational analysis method: {}.", analysis.method),
541 ],
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn test_lorentzian_peak_at_center() {
551 let val = lorentzian(100.0, 100.0, 10.0);
552 let expected = 1.0 / (std::f64::consts::PI * 10.0);
554 assert!((val - expected).abs() < 1e-10);
555 }
556
557 #[test]
558 fn test_lorentzian_symmetry() {
559 let left = lorentzian(90.0, 100.0, 10.0);
560 let right = lorentzian(110.0, 100.0, 10.0);
561 assert!((left - right).abs() < 1e-10);
562 }
563
564 #[test]
565 fn test_atomic_masses_known_elements() {
566 assert!((atomic_mass(1) - 1.00794).abs() < 0.001);
567 assert!((atomic_mass(6) - 12.011).abs() < 0.01);
568 assert!((atomic_mass(8) - 15.999).abs() < 0.01);
569 }
570
571 #[test]
572 fn test_h2_vibrational_analysis() {
573 let elements = [1u8, 1];
574 let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
575 let analysis =
576 compute_vibrational_analysis(&elements, &positions, HessianMethod::Xtb, Some(0.005))
577 .unwrap();
578
579 assert_eq!(analysis.n_atoms, 2);
580 assert_eq!(analysis.modes.len(), 6); assert!(
585 analysis.n_real_modes >= 1,
586 "H₂ should have at least 1 real vibrational mode, got {}",
587 analysis.n_real_modes
588 );
589
590 let real_modes: Vec<&VibrationalMode> =
592 analysis.modes.iter().filter(|m| m.is_real).collect();
593 assert!(!real_modes.is_empty(), "Should have at least one real mode");
594
595 if analysis.n_real_modes > 0 {
597 assert!(analysis.zpve_ev > 0.0, "ZPVE should be positive");
598 }
599 }
600
601 #[test]
602 fn test_water_vibrational_analysis() {
603 let elements = [8u8, 1, 1];
604 let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
605 let analysis =
606 compute_vibrational_analysis(&elements, &positions, HessianMethod::Xtb, Some(0.005))
607 .unwrap();
608
609 assert_eq!(analysis.n_atoms, 3);
610 assert_eq!(analysis.modes.len(), 9); assert!(
616 analysis.n_real_modes >= 2,
617 "Water should have at least 2-3 real modes, got {}",
618 analysis.n_real_modes
619 );
620 }
621
622 #[test]
623 fn test_ir_spectrum_generation() {
624 let elements = [8u8, 1, 1];
625 let positions = [[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]];
626 let analysis =
627 compute_vibrational_analysis(&elements, &positions, HessianMethod::Xtb, Some(0.005))
628 .unwrap();
629
630 let spectrum = compute_ir_spectrum(&analysis, 20.0, 400.0, 4000.0, 500);
631
632 assert_eq!(spectrum.wavenumbers.len(), 500);
633 assert_eq!(spectrum.intensities.len(), 500);
634 assert!(!spectrum.peaks.is_empty(), "Should have IR peaks");
635 assert!(
636 spectrum.intensities.iter().any(|&i| i > 0.0),
637 "Spectrum should have non-zero intensity"
638 );
639 }
640}