Skip to main content

sci_form/ir/
vibrations.rs

1//! Vibrational analysis: normal modes, frequencies, IR intensities, and spectrum.
2//!
3//! Given a numerical Hessian, computes mass-weighted normal modes,
4//! vibrational frequencies (cm⁻¹), IR intensities from dipole derivatives,
5//! and generates a Lorentzian-broadened IR spectrum.
6
7use nalgebra::DMatrix;
8use serde::{Deserialize, Serialize};
9
10use super::hessian::{compute_numerical_hessian, HessianMethod};
11
12/// Atomic masses in amu for elements 1–86 (H through Rn).
13fn 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, // rough fallback
42    }
43}
44
45/// A single vibrational normal mode.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct VibrationalMode {
48    /// Frequency in cm⁻¹ (negative = imaginary).
49    pub frequency_cm1: f64,
50    /// IR intensity in km/mol.
51    pub ir_intensity: f64,
52    /// Displacement vector (3N elements, mass-weighted normal coordinate).
53    pub displacement: Vec<f64>,
54    /// Whether this is a real mode (true) or translation/rotation (false).
55    pub is_real: bool,
56}
57
58/// Complete vibrational analysis result.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct VibrationalAnalysis {
61    /// Number of atoms.
62    pub n_atoms: usize,
63    /// All vibrational modes sorted by frequency.
64    pub modes: Vec<VibrationalMode>,
65    /// Number of real vibrational modes (excluding translation/rotation).
66    pub n_real_modes: usize,
67    /// Zero-point vibrational energy in eV.
68    pub zpve_ev: f64,
69    /// Thermochemistry at 298.15 K (if computed).
70    pub thermochemistry: Option<Thermochemistry>,
71    /// Semiempirical method used for Hessian.
72    pub method: String,
73    /// Notes and caveats.
74    pub notes: Vec<String>,
75}
76
77/// Thermochemical properties from RRHO approximation.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct Thermochemistry {
80    /// Temperature in K.
81    pub temperature_k: f64,
82    /// Zero-point energy in kcal/mol.
83    pub zpve_kcal: f64,
84    /// Thermal energy correction in kcal/mol.
85    pub thermal_energy_kcal: f64,
86    /// Thermal enthalpy correction in kcal/mol (E_thermal + RT).
87    pub enthalpy_correction_kcal: f64,
88    /// Vibrational entropy in cal/(mol·K).
89    pub entropy_vib_cal: f64,
90    /// Gibbs free energy correction in kcal/mol (H - TS).
91    pub gibbs_correction_kcal: f64,
92}
93
94/// A peak in the IR spectrum.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct IrPeak {
97    /// Frequency in cm⁻¹.
98    pub frequency_cm1: f64,
99    /// IR intensity in km/mol.
100    pub ir_intensity: f64,
101    /// Mode index.
102    pub mode_index: usize,
103    /// Functional group assignment (e.g., "O-H stretch", "C=O stretch").
104    pub assignment: String,
105}
106
107/// Broadened IR spectrum.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct IrSpectrum {
110    /// Wavenumber axis (cm⁻¹).
111    pub wavenumbers: Vec<f64>,
112    /// Transmittance/absorbance axis.
113    pub intensities: Vec<f64>,
114    /// Discrete peaks from vibrational analysis.
115    pub peaks: Vec<IrPeak>,
116    /// Broadening width in cm⁻¹.
117    pub gamma: f64,
118    /// Notes.
119    pub notes: Vec<String>,
120}
121
122// Constants for unit conversion
123// 1 eV = 8065.544 cm⁻¹
124const EV_TO_CM1: f64 = 8065.544;
125// 1 amu = 1.66054e-27 kg, 1 eV = 1.60218e-19 J, 1 Å = 1e-10 m
126// Hessian in eV/Ų, mass in amu → frequency in cm⁻¹:
127// ω² = H/(m) in eV/(amu·Å²) → convert to s⁻²:
128// factor = eV_to_J / (amu_to_kg * Å_to_m²) = 1.60218e-19 / (1.66054e-27 * 1e-20) = 9.6485e27
129// ν_cm1 = sqrt(factor * eigenvalue) / (2π * c) where c = 2.998e10 cm/s
130const HESSIAN_TO_FREQUENCY_FACTOR: f64 = 9.6485e27; // eV/(amu·Å²) → s⁻²
131const INV_2PI_C: f64 = 1.0 / (2.0 * std::f64::consts::PI * 2.99792458e10); // 1/(2πc) in s/cm
132
133/// Compute the molecular dipole for use in IR intensity calculation.
134fn 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            // Use Mulliken charges for dipole
153            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
164/// Compute IR intensities from numerical dipole derivatives along normal modes.
165///
166/// I_k ∝ |dμ/dQ_k|² where Q_k is the k-th normal coordinate.
167fn 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        // Displace along normal mode k
182        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        // dμ/dQ ≈ (μ+ - μ-) / (2δ)
198        let dmu_dq: Vec<f64> = (0..3)
199            .map(|c| (mu_plus[c] - mu_minus[c]) / (2.0 * delta))
200            .collect();
201
202        // I ∝ |dμ/dQ|² — convert to km/mol with empirical scaling
203        let intensity = dmu_dq.iter().map(|x| x * x).sum::<f64>();
204        // Scale: Debye²/Ų·amu → km/mol (empirical: ~42.256)
205        intensities.push(intensity * 42.256);
206    }
207
208    Ok(intensities)
209}
210
211/// Lorentzian line shape: L(x) = (1/π) · γ / [(x - x₀)² + γ²]
212fn 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
218/// Gaussian line shape: G(x) = (1/(σ√(2π))) · exp(-(x-x₀)²/(2σ²))
219fn 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
225/// Assign functional group label to an IR frequency based on standard ranges.
226fn 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
254/// Compute RRHO thermochemistry from vibrational frequencies.
255///
256/// Uses the rigid-rotor harmonic-oscillator approximation at the given temperature.
257fn compute_thermochemistry(frequencies_cm1: &[f64], temperature_k: f64) -> Thermochemistry {
258    // Constants
259    const CM1_TO_EV: f64 = 1.0 / 8065.544;
260    const EV_TO_KCAL: f64 = 23.0605;
261    const R_KCAL: f64 = 0.001987204; // kcal/(mol·K)
262    const R_CAL: f64 = 1.987204; // cal/(mol·K)
263    const KB_EV: f64 = 8.617333e-5; // eV/K
264
265    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    // ZPVE = Σ (1/2)hν with 0.9 anharmonic scaling
274    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    // Thermal energy (vibrational contribution): Σ hν / (exp(hν/kT) - 1)
278    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    // Enthalpy correction: E_thermal + RT
293    let enthalpy_correction_kcal = thermal_energy_kcal + R_KCAL * temperature_k;
294
295    // Vibrational entropy: Σ [x/(exp(x)-1) - ln(1-exp(-x))]·R
296    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    // Gibbs correction: H - TS
311    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
323/// Perform a complete vibrational analysis from elements and positions.
324///
325/// Computes the numerical Hessian, mass-weighted eigenvalue problem,
326/// vibrational frequencies, and IR intensities.
327pub 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    // 1. Compute numerical Hessian
342    let hessian = compute_numerical_hessian(elements, positions, method, Some(delta))?;
343
344    // 2. Build mass-weighted Hessian: H'_{ij} = H_{ij} / sqrt(m_i * m_j)
345    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    // 3. Diagonalize mass-weighted Hessian
356    let eigen = mw_hessian.symmetric_eigen();
357
358    // 4. Sort eigenvalues and eigenvectors by eigenvalue
359    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    // 5. Convert eigenvalues to frequencies (cm⁻¹)
367    // For linear molecules: 5 zero modes, for nonlinear: 6
368    let is_linear = n_atoms == 2; // simplified check
369    let _n_tr = if is_linear { 5 } else { 6 };
370    let freq_threshold = 50.0; // cm⁻¹ threshold for "real" modes
371
372    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    // Convert eigenvalues to frequencies
382    let frequencies: Vec<f64> = sorted_eigenvalues
383        .iter()
384        .map(|&ev| {
385            if ev >= 0.0 {
386                // ν = (1/2πc) * sqrt(eigenvalue * factor)
387                (ev * HESSIAN_TO_FREQUENCY_FACTOR).sqrt() * INV_2PI_C
388            } else {
389                // Imaginary frequency (negative eigenvalue)
390                -((-ev) * HESSIAN_TO_FREQUENCY_FACTOR).sqrt() * INV_2PI_C
391            }
392        })
393        .collect();
394
395    // 6. Compute IR intensities from dipole derivatives
396    let ir_intensities = compute_ir_intensities(
397        elements,
398        positions,
399        &sorted_modes,
400        method,
401        delta * 2.0, // slightly larger step for dipole derivatives
402    )?;
403
404    // 7. Build mode list
405    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 contribution: (1/2)hν in eV
415            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    // Compute thermochemistry at 298.15 K
435    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/// Broadening function type for IR spectrum generation.
457#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
458pub enum BroadeningType {
459    /// Lorentzian line shape (default).
460    Lorentzian,
461    /// Gaussian line shape.
462    Gaussian,
463}
464
465/// Generate a broadened IR spectrum from vibrational analysis.
466///
467/// `gamma`: broadening width in cm⁻¹ (typically 10–30).
468/// `wn_min`, `wn_max`: wavenumber range in cm⁻¹.
469/// `n_points`: number of grid points.
470pub 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
487/// Generate a broadened IR spectrum with selectable broadening function.
488pub 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        // At center: L = 1/(π·γ)
553        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); // 3N = 6
581
582        // H₂ is linear: 5 zero + 1 stretch
583        // Should have at least 1 real mode (H-H stretch)
584        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        // The stretch frequency should be positive
591        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        // Check that ZPVE is positive for real modes
596        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); // 3N = 9
611
612        // Water is nonlinear: 6 zero + 3 real modes (sym stretch, asym stretch, bend)
613        // Due to numerical noise some translational modes may appear as real
614        // so we just check that we have at least 3 real modes
615        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}