Skip to main content

oxiphysics_io/
quantum_chemistry_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Quantum chemistry file format I/O.
5//!
6//! Implements readers and writers for the most common quantum chemistry
7//! file formats: Gaussian (.com/.gjf/.log), ORCA (.inp/.out), along with
8//! data structures for molecular orbitals, basis sets, normal modes,
9//! electronic structure, excited states, and NBO analysis.
10
11#![allow(dead_code)]
12#![allow(clippy::too_many_arguments)]
13
14use std::collections::HashMap;
15use std::fmt;
16
17// ---------------------------------------------------------------------------
18// Shared geometry types
19// ---------------------------------------------------------------------------
20
21/// A single atom with element symbol and Cartesian coordinates (Å).
22#[derive(Debug, Clone, PartialEq)]
23pub struct Atom {
24    /// Element symbol (e.g. "C", "H", "N").
25    pub symbol: String,
26    /// Cartesian coordinates in Ångströms: `[x, y, z]`.
27    pub coords: [f64; 3],
28    /// Optional atomic number (0 if unset).
29    pub atomic_number: u8,
30}
31
32impl Atom {
33    /// Create a new atom from symbol and coordinates.
34    pub fn new(symbol: impl Into<String>, x: f64, y: f64, z: f64) -> Self {
35        Self {
36            symbol: symbol.into(),
37            coords: [x, y, z],
38            atomic_number: 0,
39        }
40    }
41
42    /// Set the atomic number.
43    pub fn with_atomic_number(mut self, z: u8) -> Self {
44        self.atomic_number = z;
45        self
46    }
47}
48
49impl fmt::Display for Atom {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(
52            f,
53            "{:<4} {:>12.6} {:>12.6} {:>12.6}",
54            self.symbol, self.coords[0], self.coords[1], self.coords[2]
55        )
56    }
57}
58
59// ---------------------------------------------------------------------------
60// GaussianInput — .com / .gjf format
61// ---------------------------------------------------------------------------
62
63/// Route section options for a Gaussian calculation.
64#[derive(Debug, Clone)]
65pub struct GaussianRouteSection {
66    /// Method keyword (e.g., "B3LYP", "MP2", "CCSD(T)").
67    pub method: String,
68    /// Basis set keyword (e.g., "6-31G*", "cc-pVDZ").
69    pub basis_set: String,
70    /// Calculation type (e.g., "SP", "Opt", "Freq", "NMR").
71    pub calc_type: String,
72    /// Additional keywords as key=value pairs.
73    pub keywords: HashMap<String, String>,
74    /// Raw extra route tokens (e.g., "Geom=CheckPoint").
75    pub extra_tokens: Vec<String>,
76}
77
78impl GaussianRouteSection {
79    /// Create a new route section.
80    pub fn new(
81        method: impl Into<String>,
82        basis_set: impl Into<String>,
83        calc_type: impl Into<String>,
84    ) -> Self {
85        Self {
86            method: method.into(),
87            basis_set: basis_set.into(),
88            calc_type: calc_type.into(),
89            keywords: HashMap::new(),
90            extra_tokens: Vec::new(),
91        }
92    }
93
94    /// Add a keyword.
95    pub fn add_keyword(&mut self, key: impl Into<String>, value: impl Into<String>) {
96        self.keywords.insert(key.into(), value.into());
97    }
98
99    /// Render the route section as a Gaussian `#` line.
100    pub fn render(&self) -> String {
101        let mut parts = vec![format!(
102            "# {}/{} {}",
103            self.method, self.basis_set, self.calc_type
104        )];
105        for (k, v) in &self.keywords {
106            if v.is_empty() {
107                parts.push(k.clone());
108            } else {
109                parts.push(format!("{}={}", k, v));
110            }
111        }
112        parts.extend(self.extra_tokens.iter().cloned());
113        parts.join(" ")
114    }
115}
116
117/// A complete Gaussian input file structure (.com / .gjf).
118#[derive(Debug, Clone)]
119pub struct GaussianInput {
120    /// Link0 commands (e.g., `%chk=job.chk`).
121    pub link0: Vec<String>,
122    /// Route section.
123    pub route: GaussianRouteSection,
124    /// Job title / comment line.
125    pub title: String,
126    /// Molecular charge.
127    pub charge: i32,
128    /// Spin multiplicity (2S+1).
129    pub multiplicity: u32,
130    /// Cartesian atom list.
131    pub atoms: Vec<Atom>,
132    /// Optional Z-matrix string (used instead of Cartesian coordinates).
133    pub z_matrix: Option<String>,
134}
135
136impl GaussianInput {
137    /// Create a new Gaussian input with Cartesian coordinates.
138    pub fn new(
139        route: GaussianRouteSection,
140        title: impl Into<String>,
141        charge: i32,
142        multiplicity: u32,
143    ) -> Self {
144        Self {
145            link0: Vec::new(),
146            route,
147            title: title.into(),
148            charge,
149            multiplicity,
150            atoms: Vec::new(),
151            z_matrix: None,
152        }
153    }
154
155    /// Add an atom to the geometry.
156    pub fn add_atom(&mut self, atom: Atom) {
157        self.atoms.push(atom);
158    }
159
160    /// Add a `%link0` directive (e.g., `%chk=job.chk`).
161    pub fn add_link0(&mut self, directive: impl Into<String>) {
162        self.link0.push(directive.into());
163    }
164
165    /// Render the full Gaussian input file as a string.
166    pub fn render(&self) -> String {
167        let mut out = String::new();
168        for l0 in &self.link0 {
169            out.push('%');
170            out.push_str(l0);
171            out.push('\n');
172        }
173        out.push_str(&self.route.render());
174        out.push_str("\n\n");
175        out.push_str(&self.title);
176        out.push_str("\n\n");
177        out.push_str(&format!("{} {}\n", self.charge, self.multiplicity));
178        if let Some(zmat) = &self.z_matrix {
179            out.push_str(zmat);
180        } else {
181            for atom in &self.atoms {
182                out.push_str(&atom.to_string());
183                out.push('\n');
184            }
185        }
186        out.push('\n');
187        out
188    }
189
190    /// Count the number of atoms.
191    pub fn num_atoms(&self) -> usize {
192        self.atoms.len()
193    }
194}
195
196// ---------------------------------------------------------------------------
197// GaussianOutput — parse .log files
198// ---------------------------------------------------------------------------
199
200/// A geometry optimization step extracted from a Gaussian log file.
201#[derive(Debug, Clone)]
202pub struct OptStep {
203    /// Step number (1-based).
204    pub step: usize,
205    /// SCF energy at this geometry (Hartree).
206    pub energy: f64,
207    /// Maximum force component.
208    pub max_force: f64,
209    /// RMS force.
210    pub rms_force: f64,
211    /// Maximum displacement.
212    pub max_displacement: f64,
213    /// RMS displacement.
214    pub rms_displacement: f64,
215    /// Converged?
216    pub converged: bool,
217    /// Atomic coordinates at this step.
218    pub atoms: Vec<Atom>,
219}
220
221impl OptStep {
222    /// Create an optimization step record.
223    pub fn new(step: usize, energy: f64) -> Self {
224        Self {
225            step,
226            energy,
227            max_force: 0.0,
228            rms_force: 0.0,
229            max_displacement: 0.0,
230            rms_displacement: 0.0,
231            converged: false,
232            atoms: Vec::new(),
233        }
234    }
235}
236
237/// Parsed frequency data for a single normal mode.
238#[derive(Debug, Clone)]
239pub struct GaussianFrequency {
240    /// Frequency in cm⁻¹.
241    pub freq_cm: f64,
242    /// IR intensity (km/mol).
243    pub ir_intensity: f64,
244    /// Raman activity (Å⁴/amu), if available.
245    pub raman_activity: Option<f64>,
246    /// Reduced mass (amu).
247    pub reduced_mass: f64,
248    /// Normal mode displacement vectors for each atom `[dx, dy, dz]`.
249    pub displacements: Vec<[f64; 3]>,
250}
251
252impl GaussianFrequency {
253    /// Create a frequency entry.
254    pub fn new(freq_cm: f64, ir_intensity: f64, reduced_mass: f64) -> Self {
255        Self {
256            freq_cm,
257            ir_intensity,
258            raman_activity: None,
259            reduced_mass,
260            displacements: Vec::new(),
261        }
262    }
263
264    /// Return `true` if this is an imaginary frequency (transition state).
265    pub fn is_imaginary(&self) -> bool {
266        self.freq_cm < 0.0
267    }
268}
269
270/// NMR shielding tensor data for a single nucleus.
271#[derive(Debug, Clone)]
272pub struct NmrShielding {
273    /// Atom index (0-based).
274    pub atom_idx: usize,
275    /// Isotropic chemical shielding (ppm).
276    pub isotropic: f64,
277    /// Anisotropy value (ppm).
278    pub anisotropy: f64,
279}
280
281/// Parsed Gaussian output (.log) file data.
282#[derive(Debug, Clone, Default)]
283pub struct GaussianOutput {
284    /// Final SCF energy (Hartree).
285    pub scf_energy: Option<f64>,
286    /// Whether the geometry optimization converged.
287    pub opt_converged: bool,
288    /// Optimization steps.
289    pub opt_steps: Vec<OptStep>,
290    /// Vibrational frequencies.
291    pub frequencies: Vec<GaussianFrequency>,
292    /// NMR shielding tensors.
293    pub nmr_shieldings: Vec<NmrShielding>,
294    /// Zero-point energy (Hartree).
295    pub zero_point_energy: Option<f64>,
296    /// Thermal correction to enthalpy (Hartree).
297    pub thermal_correction_h: Option<f64>,
298    /// Thermal correction to Gibbs free energy (Hartree).
299    pub thermal_correction_g: Option<f64>,
300    /// Final optimized geometry.
301    pub final_geometry: Vec<Atom>,
302    /// Number of imaginary frequencies (negative values).
303    pub n_imaginary: usize,
304}
305
306impl GaussianOutput {
307    /// Create an empty output record.
308    pub fn new() -> Self {
309        Self::default()
310    }
311
312    /// Parse a Gaussian log file content string and extract key data.
313    ///
314    /// This is a simplified parser that recognizes the most common output
315    /// patterns. Returns a populated `GaussianOutput`.
316    pub fn parse(content: &str) -> Self {
317        let mut out = GaussianOutput::new();
318        let mut current_opt_step: Option<OptStep> = None;
319
320        for line in content.lines() {
321            let trimmed = line.trim();
322
323            // SCF energy
324            if trimmed.starts_with("SCF Done:")
325                && let Some(e) = parse_after_eq(trimmed)
326            {
327                out.scf_energy = Some(e);
328            }
329
330            // Optimization convergence
331            if trimmed.contains("Optimization completed.") {
332                out.opt_converged = true;
333                if let Some(step) = current_opt_step.take() {
334                    out.opt_steps.push(step);
335                }
336            }
337
338            // Optimization step energy
339            if trimmed.starts_with("Energy=")
340                && let Some(e) = parse_value_after(trimmed, "Energy=")
341            {
342                let step_num = out.opt_steps.len() + 1;
343                current_opt_step = Some(OptStep::new(step_num, e));
344            }
345
346            // Frequency
347            if trimmed.starts_with("Frequencies --")
348                && let Some(rest) = trimmed.strip_prefix("Frequencies --")
349            {
350                for tok in rest.split_whitespace() {
351                    if let Ok(f) = tok.parse::<f64>() {
352                        out.frequencies.push(GaussianFrequency::new(f, 0.0, 1.0));
353                        if f < 0.0 {
354                            out.n_imaginary += 1;
355                        }
356                    }
357                }
358            }
359
360            // IR intensities
361            if trimmed.starts_with("IR Inten    --")
362                && let Some(rest) = trimmed.strip_prefix("IR Inten    --")
363            {
364                let intensities: Vec<f64> = rest
365                    .split_whitespace()
366                    .filter_map(|t| t.parse().ok())
367                    .collect();
368                let n_freqs = out.frequencies.len();
369                let start = n_freqs.saturating_sub(intensities.len());
370                for (i, &ir) in intensities.iter().enumerate() {
371                    if start + i < n_freqs {
372                        out.frequencies[start + i].ir_intensity = ir;
373                    }
374                }
375            }
376
377            // Zero-point energy
378            if trimmed.starts_with("Zero-point correction=")
379                && let Some(v) = parse_value_after(trimmed, "Zero-point correction=")
380            {
381                out.zero_point_energy = Some(v);
382            }
383
384            // Thermal correction to enthalpy
385            if trimmed.starts_with("Thermal correction to Enthalpy=")
386                && let Some(v) = parse_value_after(trimmed, "Thermal correction to Enthalpy=")
387            {
388                out.thermal_correction_h = Some(v);
389            }
390
391            // Thermal correction to Gibbs
392            if trimmed.starts_with("Thermal correction to Gibbs Free Energy=")
393                && let Some(v) =
394                    parse_value_after(trimmed, "Thermal correction to Gibbs Free Energy=")
395            {
396                out.thermal_correction_g = Some(v);
397            }
398        }
399
400        // Flush remaining opt step
401        if let Some(step) = current_opt_step {
402            out.opt_steps.push(step);
403        }
404
405        out
406    }
407
408    /// Return the lowest frequency (most negative if imaginary).
409    pub fn lowest_frequency(&self) -> Option<f64> {
410        self.frequencies.iter().map(|f| f.freq_cm).reduce(f64::min)
411    }
412
413    /// Return the number of frequencies stored.
414    pub fn n_frequencies(&self) -> usize {
415        self.frequencies.len()
416    }
417}
418
419/// Parse the floating-point value after `=` in a string like `SCF Done: E(RB3LYP) = -78.123`.
420fn parse_after_eq(s: &str) -> Option<f64> {
421    let idx = s.rfind('=')?;
422    s[idx + 1..].split_whitespace().next()?.parse().ok()
423}
424
425/// Parse a float value directly after a prefix.
426fn parse_value_after(s: &str, prefix: &str) -> Option<f64> {
427    s.strip_prefix(prefix)?
428        .split_whitespace()
429        .next()?
430        .parse()
431        .ok()
432}
433
434// ---------------------------------------------------------------------------
435// OrcaInput — ORCA input format
436// ---------------------------------------------------------------------------
437
438/// Calculation settings block for ORCA.
439#[derive(Debug, Clone)]
440pub struct OrcaSettings {
441    /// Maximum number of SCF iterations.
442    pub max_scf_iter: usize,
443    /// SCF energy convergence threshold (Eh).
444    pub scf_conv: f64,
445    /// Gradient convergence threshold.
446    pub grad_conv: f64,
447    /// Grid level (1–7 for DFT integration).
448    pub grid: u8,
449    /// Whether to use RIJCOSX (resolution-of-identity) approximation.
450    pub rijcosx: bool,
451    /// Auxiliary basis set for RI (e.g., "def2/J").
452    pub aux_basis: Option<String>,
453    /// Number of processors.
454    pub n_procs: usize,
455    /// Memory per process (MB).
456    pub mem_per_proc: usize,
457}
458
459impl OrcaSettings {
460    /// Create default ORCA settings.
461    pub fn default_settings() -> Self {
462        Self {
463            max_scf_iter: 125,
464            scf_conv: 1e-8,
465            grad_conv: 1e-5,
466            grid: 5,
467            rijcosx: false,
468            aux_basis: None,
469            n_procs: 1,
470            mem_per_proc: 2048,
471        }
472    }
473}
474
475/// A complete ORCA input file.
476#[derive(Debug, Clone)]
477pub struct OrcaInput {
478    /// Method/keywords line (e.g., "! B3LYP def2-TZVP TightSCF Opt").
479    pub keywords: Vec<String>,
480    /// Charge of the molecule.
481    pub charge: i32,
482    /// Spin multiplicity.
483    pub multiplicity: u32,
484    /// Atom list (Cartesian coordinates, Å).
485    pub atoms: Vec<Atom>,
486    /// Calculation settings block.
487    pub settings: OrcaSettings,
488    /// Extra `%block ... end` sections as raw strings.
489    pub extra_blocks: Vec<String>,
490}
491
492impl OrcaInput {
493    /// Create a new ORCA input.
494    pub fn new(charge: i32, multiplicity: u32) -> Self {
495        Self {
496            keywords: Vec::new(),
497            charge,
498            multiplicity,
499            atoms: Vec::new(),
500            settings: OrcaSettings::default_settings(),
501            extra_blocks: Vec::new(),
502        }
503    }
504
505    /// Add a keyword to the `!` line.
506    pub fn add_keyword(&mut self, kw: impl Into<String>) {
507        self.keywords.push(kw.into());
508    }
509
510    /// Add an atom.
511    pub fn add_atom(&mut self, atom: Atom) {
512        self.atoms.push(atom);
513    }
514
515    /// Render the full ORCA input file as a string.
516    pub fn render(&self) -> String {
517        let mut out = String::new();
518
519        // Keywords line
520        if !self.keywords.is_empty() {
521            out.push_str("! ");
522            out.push_str(&self.keywords.join(" "));
523            out.push('\n');
524        }
525
526        // %pal block for parallel
527        if self.settings.n_procs > 1 {
528            out.push_str(&format!("%pal nprocs {} end\n", self.settings.n_procs));
529        }
530
531        // %maxcore block
532        out.push_str(&format!("%maxcore {}\n", self.settings.mem_per_proc));
533
534        // %scf block
535        out.push_str(&format!(
536            "%scf\n  MaxIter {}\n  ConvTol {:.2e}\nend\n",
537            self.settings.max_scf_iter, self.settings.scf_conv
538        ));
539
540        // Extra blocks
541        for block in &self.extra_blocks {
542            out.push_str(block);
543            out.push('\n');
544        }
545
546        // Coordinate block
547        out.push_str(&format!("* xyz {} {}\n", self.charge, self.multiplicity));
548        for atom in &self.atoms {
549            out.push_str(&atom.to_string());
550            out.push('\n');
551        }
552        out.push_str("*\n");
553        out
554    }
555}
556
557// ---------------------------------------------------------------------------
558// OrcaOutput — parse ORCA output
559// ---------------------------------------------------------------------------
560
561/// Excited state from ORCA TD-DFT or EOM-CCSD output.
562#[derive(Debug, Clone)]
563pub struct OrcaExcitedState {
564    /// State number (1-based).
565    pub state: usize,
566    /// Excitation energy in eV.
567    pub energy_ev: f64,
568    /// Oscillator strength (dimensionless).
569    pub oscillator_strength: f64,
570    /// Transition type label (e.g., "SINGLET-A").
571    pub symmetry: String,
572}
573
574impl OrcaExcitedState {
575    /// Create a new excited state record.
576    pub fn new(
577        state: usize,
578        energy_ev: f64,
579        oscillator_strength: f64,
580        symmetry: impl Into<String>,
581    ) -> Self {
582        Self {
583            state,
584            energy_ev,
585            oscillator_strength,
586            symmetry: symmetry.into(),
587        }
588    }
589}
590
591/// Gradient vector for one atom.
592#[derive(Debug, Clone)]
593pub struct AtomGradient {
594    /// Atom index (0-based).
595    pub atom_idx: usize,
596    /// Gradient components in Eh/Bohr: `[gx, gy, gz]`.
597    pub gradient: [f64; 3],
598}
599
600/// Parsed ORCA output file.
601#[derive(Debug, Clone, Default)]
602pub struct OrcaOutput {
603    /// Final electronic energy (Hartree).
604    pub electronic_energy: Option<f64>,
605    /// Dispersion correction (Hartree), if present.
606    pub dispersion_correction: Option<f64>,
607    /// Cartesian gradient for each atom.
608    pub gradients: Vec<AtomGradient>,
609    /// Excited states (TDDFT or EOM-CCSD).
610    pub excited_states: Vec<OrcaExcitedState>,
611    /// Whether the geometry optimization converged.
612    pub opt_converged: bool,
613    /// Number of SCF iterations performed.
614    pub scf_iterations: usize,
615    /// ORCA version string.
616    pub version: Option<String>,
617    /// Final geometry after optimization.
618    pub final_geometry: Vec<Atom>,
619}
620
621impl OrcaOutput {
622    /// Create an empty output record.
623    pub fn new() -> Self {
624        Self::default()
625    }
626
627    /// Parse an ORCA output file string and extract key results.
628    pub fn parse(content: &str) -> Self {
629        let mut out = OrcaOutput::new();
630
631        for line in content.lines() {
632            let trimmed = line.trim();
633
634            // Version
635            if trimmed.starts_with("Program Version") {
636                out.version = Some(trimmed.to_string());
637            }
638
639            // Electronic energy
640            if trimmed.starts_with("FINAL SINGLE POINT ENERGY")
641                && let Some(val) = trimmed
642                    .split_whitespace()
643                    .last()
644                    .and_then(|s| s.parse().ok())
645            {
646                out.electronic_energy = Some(val);
647            }
648
649            // Dispersion
650            if trimmed.starts_with("Dispersion correction")
651                && let Some(val) = trimmed
652                    .split_whitespace()
653                    .last()
654                    .and_then(|s| s.parse().ok())
655            {
656                out.dispersion_correction = Some(val);
657            }
658
659            // Convergence
660            if trimmed.contains("OPTIMIZATION HAS CONVERGED") {
661                out.opt_converged = true;
662            }
663
664            // SCF iterations
665            if trimmed.starts_with("SCF CONVERGED AFTER")
666                && let Some(n) = trimmed
667                    .split_whitespace()
668                    .find(|t| t.parse::<usize>().is_ok())
669                    .and_then(|t| t.parse().ok())
670            {
671                out.scf_iterations = n;
672            }
673
674            // Excited states
675            if trimmed.starts_with("STATE") && trimmed.contains("EXCITATION ENERGY") {
676                // e.g. "STATE  1:  E=   0.1234 au    3.36 eV  f= 0.0012"
677                if let Some(ev) = parse_ev_from_state_line(trimmed) {
678                    let osc = parse_f_from_state_line(trimmed).unwrap_or(0.0);
679                    let state_num = out.excited_states.len() + 1;
680                    out.excited_states
681                        .push(OrcaExcitedState::new(state_num, ev, osc, "SINGLET"));
682                }
683            }
684        }
685        out
686    }
687
688    /// Return total energy including dispersion correction.
689    pub fn total_energy(&self) -> Option<f64> {
690        match (self.electronic_energy, self.dispersion_correction) {
691            (Some(e), Some(d)) => Some(e + d),
692            (Some(e), None) => Some(e),
693            _ => None,
694        }
695    }
696
697    /// Return the oscillator-strength-weighted centroid absorption wavelength (nm).
698    pub fn absorption_centroid_nm(&self) -> Option<f64> {
699        let total_osc: f64 = self
700            .excited_states
701            .iter()
702            .map(|s| s.oscillator_strength)
703            .sum();
704        if total_osc < 1e-15 {
705            return None;
706        }
707        let weighted: f64 = self
708            .excited_states
709            .iter()
710            .map(|s| (1239.84 / s.energy_ev) * s.oscillator_strength)
711            .sum();
712        Some(weighted / total_osc)
713    }
714}
715
716/// Parse eV value from ORCA excited state line.
717fn parse_ev_from_state_line(line: &str) -> Option<f64> {
718    let tokens: Vec<&str> = line.split_whitespace().collect();
719    for (i, &tok) in tokens.iter().enumerate() {
720        if tok == "eV" && i > 0 {
721            return tokens[i - 1].parse().ok();
722        }
723    }
724    None
725}
726
727/// Parse oscillator strength `f=` from ORCA state line.
728fn parse_f_from_state_line(line: &str) -> Option<f64> {
729    for part in line.split_whitespace() {
730        if let Some(val) = part.strip_prefix("f=") {
731            return val.parse().ok();
732        }
733    }
734    None
735}
736
737// ---------------------------------------------------------------------------
738// MolecularOrbital
739// ---------------------------------------------------------------------------
740
741/// Spin channel for a molecular orbital.
742#[derive(Debug, Clone, Copy, PartialEq, Eq)]
743pub enum Spin {
744    /// Alpha (or closed-shell) spin channel.
745    Alpha,
746    /// Beta spin channel.
747    Beta,
748}
749
750/// A single molecular orbital with expansion coefficients over basis functions.
751#[derive(Debug, Clone)]
752pub struct MolecularOrbital {
753    /// MO index (0-based).
754    pub index: usize,
755    /// Orbital energy (Hartree).
756    pub energy: f64,
757    /// Occupation number (0, 1, or 2).
758    pub occupation: f64,
759    /// Spin channel.
760    pub spin: Spin,
761    /// Symmetry label (e.g., "A1", "B2u").
762    pub symmetry: String,
763    /// Expansion coefficients over the AO basis functions.
764    pub coefficients: Vec<f64>,
765}
766
767impl MolecularOrbital {
768    /// Create a new MO record.
769    pub fn new(index: usize, energy: f64, occupation: f64, spin: Spin) -> Self {
770        Self {
771            index,
772            energy,
773            occupation,
774            spin,
775            symmetry: String::new(),
776            coefficients: Vec::new(),
777        }
778    }
779
780    /// Return `true` if this is the HOMO (highest occupied MO in alpha channel).
781    pub fn is_homo(&self, homo_idx: usize) -> bool {
782        self.index == homo_idx
783    }
784
785    /// Return `true` if this is the LUMO.
786    pub fn is_lumo(&self, lumo_idx: usize) -> bool {
787        self.index == lumo_idx
788    }
789
790    /// Compute the norm of the MO coefficient vector (should be ~1 for an orthonormal MO).
791    pub fn coeff_norm(&self) -> f64 {
792        self.coefficients.iter().map(|c| c * c).sum::<f64>().sqrt()
793    }
794}
795
796/// A set of molecular orbitals with helper functions for HOMO/LUMO gap.
797#[derive(Debug, Clone, Default)]
798pub struct MolecularOrbitalSet {
799    /// Alpha MOs.
800    pub alpha_mos: Vec<MolecularOrbital>,
801    /// Beta MOs (empty for restricted calculations).
802    pub beta_mos: Vec<MolecularOrbital>,
803    /// Number of alpha electrons.
804    pub n_alpha: usize,
805    /// Number of beta electrons.
806    pub n_beta: usize,
807}
808
809impl MolecularOrbitalSet {
810    /// Create an empty MO set.
811    pub fn new(n_alpha: usize, n_beta: usize) -> Self {
812        Self {
813            alpha_mos: Vec::new(),
814            beta_mos: Vec::new(),
815            n_alpha,
816            n_beta,
817        }
818    }
819
820    /// Index of the alpha HOMO (0-based).
821    pub fn homo_idx(&self) -> Option<usize> {
822        if self.n_alpha == 0 {
823            None
824        } else {
825            Some(self.n_alpha - 1)
826        }
827    }
828
829    /// Index of the alpha LUMO.
830    pub fn lumo_idx(&self) -> Option<usize> {
831        if self.n_alpha < self.alpha_mos.len() {
832            Some(self.n_alpha)
833        } else {
834            None
835        }
836    }
837
838    /// HOMO–LUMO gap in eV (None if LUMO is not present).
839    pub fn homo_lumo_gap_ev(&self) -> Option<f64> {
840        let homo = self.homo_idx()?;
841        let lumo = self.lumo_idx()?;
842        let e_homo = self.alpha_mos.get(homo)?.energy;
843        let e_lumo = self.alpha_mos.get(lumo)?.energy;
844        // 1 Hartree = 27.2114 eV
845        Some((e_lumo - e_homo) * 27.2114)
846    }
847
848    /// Add an alpha MO.
849    pub fn add_alpha(&mut self, mo: MolecularOrbital) {
850        self.alpha_mos.push(mo);
851    }
852
853    /// Add a beta MO.
854    pub fn add_beta(&mut self, mo: MolecularOrbital) {
855        self.beta_mos.push(mo);
856    }
857}
858
859// ---------------------------------------------------------------------------
860// BasisSet — Gaussian-type orbitals
861// ---------------------------------------------------------------------------
862
863/// Angular momentum shell type.
864#[derive(Debug, Clone, Copy, PartialEq, Eq)]
865pub enum ShellType {
866    /// s-type (l=0).
867    S,
868    /// p-type (l=1).
869    P,
870    /// d-type (l=2).
871    D,
872    /// f-type (l=3).
873    F,
874    /// g-type (l=4).
875    G,
876    /// SP-type (shared s+p exponents, Pople style).
877    SP,
878}
879
880impl fmt::Display for ShellType {
881    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
882        match self {
883            ShellType::S => write!(f, "S"),
884            ShellType::P => write!(f, "P"),
885            ShellType::D => write!(f, "D"),
886            ShellType::F => write!(f, "F"),
887            ShellType::G => write!(f, "G"),
888            ShellType::SP => write!(f, "SP"),
889        }
890    }
891}
892
893/// A single contracted Gaussian shell.
894#[derive(Debug, Clone)]
895pub struct GaussianShell {
896    /// Shell type.
897    pub shell_type: ShellType,
898    /// Exponents of the primitive Gaussians.
899    pub exponents: Vec<f64>,
900    /// Contraction coefficients for s (or s-part of SP).
901    pub coefficients_s: Vec<f64>,
902    /// Contraction coefficients for p-part of SP (empty if not SP).
903    pub coefficients_p: Vec<f64>,
904}
905
906impl GaussianShell {
907    /// Create a pure-type shell (S, P, D, …).
908    pub fn new(shell_type: ShellType, exponents: Vec<f64>, coefficients: Vec<f64>) -> Self {
909        Self {
910            shell_type,
911            exponents,
912            coefficients_s: coefficients,
913            coefficients_p: Vec::new(),
914        }
915    }
916
917    /// Create an SP-type shell (Pople 6-31G style).
918    pub fn new_sp(exponents: Vec<f64>, coeff_s: Vec<f64>, coeff_p: Vec<f64>) -> Self {
919        Self {
920            shell_type: ShellType::SP,
921            exponents,
922            coefficients_s: coeff_s,
923            coefficients_p: coeff_p,
924        }
925    }
926
927    /// Number of primitive Gaussians in this shell.
928    pub fn n_primitives(&self) -> usize {
929        self.exponents.len()
930    }
931
932    /// Number of contracted basis functions for this shell (angular momentum degeneracy).
933    pub fn n_basis_functions(&self) -> usize {
934        match self.shell_type {
935            ShellType::S => 1,
936            ShellType::P => 3,
937            ShellType::D => 6, // Cartesian; use 5 for spherical
938            ShellType::F => 10,
939            ShellType::G => 15,
940            ShellType::SP => 4, // 1 s + 3 p
941        }
942    }
943}
944
945/// Basis set definition for one element.
946#[derive(Debug, Clone)]
947pub struct ElementBasis {
948    /// Element symbol.
949    pub element: String,
950    /// Basis shells.
951    pub shells: Vec<GaussianShell>,
952}
953
954impl ElementBasis {
955    /// Create an empty element basis.
956    pub fn new(element: impl Into<String>) -> Self {
957        Self {
958            element: element.into(),
959            shells: Vec::new(),
960        }
961    }
962
963    /// Add a shell.
964    pub fn add_shell(&mut self, shell: GaussianShell) {
965        self.shells.push(shell);
966    }
967
968    /// Total number of contracted basis functions.
969    pub fn n_basis_functions(&self) -> usize {
970        self.shells.iter().map(|s| s.n_basis_functions()).sum()
971    }
972}
973
974/// A named basis set (e.g., "STO-3G", "6-31G*", "cc-pVDZ").
975#[derive(Debug, Clone)]
976pub struct BasisSet {
977    /// Name of the basis set.
978    pub name: String,
979    /// Per-element basis data.
980    pub elements: HashMap<String, ElementBasis>,
981}
982
983impl BasisSet {
984    /// Create an empty basis set.
985    pub fn new(name: impl Into<String>) -> Self {
986        Self {
987            name: name.into(),
988            elements: HashMap::new(),
989        }
990    }
991
992    /// Add an element basis.
993    pub fn add_element(&mut self, basis: ElementBasis) {
994        self.elements.insert(basis.element.clone(), basis);
995    }
996
997    /// Total number of basis functions for a molecule described by `atom_symbols`.
998    pub fn total_basis_functions(&self, atom_symbols: &[&str]) -> usize {
999        atom_symbols
1000            .iter()
1001            .filter_map(|&sym| self.elements.get(sym))
1002            .map(|eb| eb.n_basis_functions())
1003            .sum()
1004    }
1005}
1006
1007// ---------------------------------------------------------------------------
1008// NormalModes
1009// ---------------------------------------------------------------------------
1010
1011/// Normal mode data for a molecule.
1012#[derive(Debug, Clone)]
1013pub struct NormalModes {
1014    /// Number of atoms.
1015    pub n_atoms: usize,
1016    /// Vibrational frequencies (cm⁻¹), length = 3N - 6 (or 3N - 5 for linear).
1017    pub frequencies: Vec<f64>,
1018    /// IR intensities (km/mol).
1019    pub ir_intensities: Vec<f64>,
1020    /// Raman activities (Å⁴/amu), optional.
1021    pub raman_activities: Vec<f64>,
1022    /// Mass-weighted Cartesian displacement vectors, shape \[n_modes\]\[3*n_atoms\].
1023    pub displacements: Vec<Vec<f64>>,
1024    /// Reduced masses (amu) per mode.
1025    pub reduced_masses: Vec<f64>,
1026    /// Force constants (mdyn/Å) per mode.
1027    pub force_constants: Vec<f64>,
1028}
1029
1030impl NormalModes {
1031    /// Create a new normal modes record.
1032    pub fn new(n_atoms: usize) -> Self {
1033        Self {
1034            n_atoms,
1035            frequencies: Vec::new(),
1036            ir_intensities: Vec::new(),
1037            raman_activities: Vec::new(),
1038            displacements: Vec::new(),
1039            reduced_masses: Vec::new(),
1040            force_constants: Vec::new(),
1041        }
1042    }
1043
1044    /// Expected number of vibrational modes for a non-linear molecule.
1045    pub fn expected_modes_nonlinear(&self) -> usize {
1046        3 * self.n_atoms - 6
1047    }
1048
1049    /// Expected number of vibrational modes for a linear molecule.
1050    pub fn expected_modes_linear(&self) -> usize {
1051        3 * self.n_atoms - 5
1052    }
1053
1054    /// Number of imaginary (negative) frequencies.
1055    pub fn n_imaginary(&self) -> usize {
1056        self.frequencies.iter().filter(|&&f| f < 0.0).count()
1057    }
1058
1059    /// Largest IR intensity and the corresponding frequency.
1060    pub fn strongest_ir_band(&self) -> Option<(f64, f64)> {
1061        self.ir_intensities
1062            .iter()
1063            .copied()
1064            .enumerate()
1065            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1066            .map(|(i, intensity)| (self.frequencies[i], intensity))
1067    }
1068
1069    /// Zero-point vibrational energy (cm⁻¹) = Σ(ν_i / 2) over positive frequencies.
1070    pub fn zpve_cm(&self) -> f64 {
1071        self.frequencies
1072            .iter()
1073            .filter(|&&f| f > 0.0)
1074            .map(|&f| f * 0.5)
1075            .sum()
1076    }
1077}
1078
1079// ---------------------------------------------------------------------------
1080// ElectronicStructure
1081// ---------------------------------------------------------------------------
1082
1083/// Electronic structure thermodynamic data.
1084#[derive(Debug, Clone)]
1085pub struct ElectronicStructure {
1086    /// Total electronic energy (Hartree).
1087    pub total_energy: f64,
1088    /// Zero-point energy correction (Hartree).
1089    pub zero_point_energy: f64,
1090    /// Thermal correction to internal energy at temperature T (Hartree).
1091    pub thermal_energy: f64,
1092    /// Thermal correction to enthalpy (Hartree).
1093    pub enthalpy_correction: f64,
1094    /// Entropy (cal/mol/K).
1095    pub entropy: f64,
1096    /// Temperature (K).
1097    pub temperature: f64,
1098    /// Gibbs free energy = H - T*S (Hartree).
1099    pub gibbs_free_energy: f64,
1100    /// Translational entropy contribution (cal/mol/K).
1101    pub translational_entropy: f64,
1102    /// Rotational entropy contribution (cal/mol/K).
1103    pub rotational_entropy: f64,
1104    /// Vibrational entropy contribution (cal/mol/K).
1105    pub vibrational_entropy: f64,
1106}
1107
1108impl ElectronicStructure {
1109    /// Create an electronic structure record.
1110    pub fn new(total_energy: f64, temperature: f64) -> Self {
1111        Self {
1112            total_energy,
1113            zero_point_energy: 0.0,
1114            thermal_energy: 0.0,
1115            enthalpy_correction: 0.0,
1116            entropy: 0.0,
1117            temperature,
1118            gibbs_free_energy: 0.0,
1119            translational_entropy: 0.0,
1120            rotational_entropy: 0.0,
1121            vibrational_entropy: 0.0,
1122        }
1123    }
1124
1125    /// E0 + ZPE (electronic energy plus zero-point correction).
1126    pub fn e0_plus_zpe(&self) -> f64 {
1127        self.total_energy + self.zero_point_energy
1128    }
1129
1130    /// Enthalpy H = E + ZPE + thermal + kT (Hartree).
1131    ///
1132    /// kT at temperature T in Hartree: k_B * T / E_h
1133    pub fn enthalpy(&self) -> f64 {
1134        // kT/E_h ≈ T / 315775 (Boltzmann in Hartree/K)
1135        let kt = self.temperature / 315_775.13;
1136        self.total_energy + self.zero_point_energy + self.thermal_energy + kt
1137    }
1138
1139    /// Free energy G = H - T*S, with S in cal/mol/K converted to Hartree.
1140    pub fn free_energy(&self) -> f64 {
1141        // 1 cal = 4.184 J; 1 Hartree = 4.3597e-18 J; 1 mol = 6.022e23
1142        // S_hartree = S_cal_mol_K * 4.184 / (4.3597e-18 * 6.022e23) * T
1143        let s_hartree_per_k = self.entropy * 4.184 / (4.3597e-18 * 6.022e23);
1144        self.enthalpy() - self.temperature * s_hartree_per_k
1145    }
1146}
1147
1148// ---------------------------------------------------------------------------
1149// ExcitedStates
1150// ---------------------------------------------------------------------------
1151
1152/// A single excited state transition.
1153#[derive(Debug, Clone)]
1154pub struct ExcitedStateTransition {
1155    /// Excited state index (1-based).
1156    pub state: usize,
1157    /// Excitation energy (eV).
1158    pub energy_ev: f64,
1159    /// Oscillator strength (dimensionless).
1160    pub oscillator_strength: f64,
1161    /// Dominant transition MOs (from MO i to MO a, with coefficient).
1162    pub dominant_transitions: Vec<(usize, usize, f64)>,
1163    /// Transition dipole moment vector (a.u.).
1164    pub transition_dipole: [f64; 3],
1165    /// Singlet or triplet.
1166    pub multiplicity: String,
1167}
1168
1169impl ExcitedStateTransition {
1170    /// Create a new excited state transition.
1171    pub fn new(state: usize, energy_ev: f64, oscillator_strength: f64) -> Self {
1172        Self {
1173            state,
1174            energy_ev,
1175            oscillator_strength,
1176            dominant_transitions: Vec::new(),
1177            transition_dipole: [0.0; 3],
1178            multiplicity: "Singlet".to_string(),
1179        }
1180    }
1181
1182    /// Absorption wavelength in nm.
1183    pub fn wavelength_nm(&self) -> f64 {
1184        1239.84 / self.energy_ev
1185    }
1186
1187    /// Add a dominant transition.
1188    pub fn add_transition(&mut self, from: usize, to: usize, coeff: f64) {
1189        self.dominant_transitions.push((from, to, coeff));
1190    }
1191}
1192
1193/// Collection of excited states and their absorption/emission spectra.
1194#[derive(Debug, Clone)]
1195pub struct ExcitedStates {
1196    /// All excited state transitions.
1197    pub states: Vec<ExcitedStateTransition>,
1198    /// Method used (e.g., "TDDFT", "EOM-CCSD", "ADC(2)").
1199    pub method: String,
1200    /// Ground state energy (Hartree).
1201    pub ground_energy: f64,
1202}
1203
1204impl ExcitedStates {
1205    /// Create a new excited states collection.
1206    pub fn new(method: impl Into<String>, ground_energy: f64) -> Self {
1207        Self {
1208            states: Vec::new(),
1209            method: method.into(),
1210            ground_energy,
1211        }
1212    }
1213
1214    /// Add an excited state.
1215    pub fn add_state(&mut self, state: ExcitedStateTransition) {
1216        self.states.push(state);
1217    }
1218
1219    /// Return the state with the largest oscillator strength (brightest absorption).
1220    pub fn brightest_state(&self) -> Option<&ExcitedStateTransition> {
1221        self.states.iter().max_by(|a, b| {
1222            a.oscillator_strength
1223                .partial_cmp(&b.oscillator_strength)
1224                .unwrap_or(std::cmp::Ordering::Equal)
1225        })
1226    }
1227
1228    /// Simulate a Lorentzian-broadened UV/vis spectrum.
1229    ///
1230    /// Returns `(wavelengths_nm, intensities)` sampled at `n_points` between
1231    /// `lambda_min` and `lambda_max` nm with broadening `fwhm_nm`.
1232    pub fn uv_vis_spectrum(
1233        &self,
1234        lambda_min: f64,
1235        lambda_max: f64,
1236        n_points: usize,
1237        fwhm_nm: f64,
1238    ) -> (Vec<f64>, Vec<f64>) {
1239        let gamma = fwhm_nm / 2.0;
1240        let step = (lambda_max - lambda_min) / n_points.max(1) as f64;
1241        let wavelengths: Vec<f64> = (0..n_points)
1242            .map(|i| lambda_min + i as f64 * step)
1243            .collect();
1244        let intensities: Vec<f64> = wavelengths
1245            .iter()
1246            .map(|&lam| {
1247                self.states
1248                    .iter()
1249                    .map(|s| {
1250                        let dl = lam - s.wavelength_nm();
1251                        s.oscillator_strength * gamma * gamma / (dl * dl + gamma * gamma)
1252                    })
1253                    .sum()
1254            })
1255            .collect();
1256        (wavelengths, intensities)
1257    }
1258}
1259
1260// ---------------------------------------------------------------------------
1261// NboOutput — Natural Bond Orbital analysis
1262// ---------------------------------------------------------------------------
1263
1264/// A single natural bond orbital with occupancy and hybrid composition.
1265#[derive(Debug, Clone)]
1266pub struct NaturalBondOrbital {
1267    /// NBO index (1-based).
1268    pub index: usize,
1269    /// NBO type label (e.g., "BD", "LP", "CR", "BD*").
1270    pub nbo_type: String,
1271    /// Atom indices involved (1 for lone pair/core, 2 for bond).
1272    pub atoms: Vec<usize>,
1273    /// Occupancy (electrons).
1274    pub occupancy: f64,
1275    /// NBO energy (Hartree).
1276    pub energy: f64,
1277    /// Hybrid composition: `(atom_idx, hybrid_type, coefficient)`.
1278    pub hybrids: Vec<(usize, String, f64)>,
1279}
1280
1281impl NaturalBondOrbital {
1282    /// Create a new NBO record.
1283    pub fn new(index: usize, nbo_type: impl Into<String>, occupancy: f64, energy: f64) -> Self {
1284        Self {
1285            index,
1286            nbo_type: nbo_type.into(),
1287            atoms: Vec::new(),
1288            occupancy,
1289            energy,
1290            hybrids: Vec::new(),
1291        }
1292    }
1293
1294    /// Return `true` if this is a lone pair.
1295    pub fn is_lone_pair(&self) -> bool {
1296        self.nbo_type.starts_with("LP")
1297    }
1298
1299    /// Return `true` if this is a bonding NBO.
1300    pub fn is_bonding(&self) -> bool {
1301        self.nbo_type == "BD"
1302    }
1303
1304    /// Return `true` if this is an antibonding NBO.
1305    pub fn is_antibonding(&self) -> bool {
1306        self.nbo_type == "BD*"
1307    }
1308}
1309
1310/// Wiberg bond order matrix (upper triangular, indexed by 0-based atom pairs).
1311#[derive(Debug, Clone)]
1312pub struct WibergBondOrderMatrix {
1313    /// Number of atoms.
1314    pub n_atoms: usize,
1315    /// Bond orders stored as a flat upper-triangular list (i < j).
1316    data: Vec<f64>,
1317}
1318
1319impl WibergBondOrderMatrix {
1320    /// Create an n×n zero Wiberg bond order matrix.
1321    pub fn new(n_atoms: usize) -> Self {
1322        let size = n_atoms * (n_atoms + 1) / 2;
1323        Self {
1324            n_atoms,
1325            data: vec![0.0; size],
1326        }
1327    }
1328
1329    /// Flat index for atom pair (i, j) with i <= j.
1330    fn idx(&self, i: usize, j: usize) -> usize {
1331        let (a, b) = if i <= j { (i, j) } else { (j, i) };
1332        a * self.n_atoms - a * (a + 1) / 2 + b
1333    }
1334
1335    /// Set bond order for atom pair (i, j).
1336    pub fn set(&mut self, i: usize, j: usize, bo: f64) {
1337        let idx = self.idx(i, j);
1338        if idx < self.data.len() {
1339            self.data[idx] = bo;
1340        }
1341    }
1342
1343    /// Get bond order for atom pair (i, j).
1344    pub fn get(&self, i: usize, j: usize) -> f64 {
1345        let idx = self.idx(i, j);
1346        if idx < self.data.len() {
1347            self.data[idx]
1348        } else {
1349            0.0
1350        }
1351    }
1352
1353    /// Valence of atom `i` = sum of all bond orders involving atom `i`.
1354    pub fn valence(&self, i: usize) -> f64 {
1355        (0..self.n_atoms)
1356            .filter(|&j| j != i)
1357            .map(|j| self.get(i, j))
1358            .sum()
1359    }
1360}
1361
1362/// Full NBO analysis output.
1363#[derive(Debug, Clone)]
1364pub struct NboOutput {
1365    /// List of all NBOs.
1366    pub nbos: Vec<NaturalBondOrbital>,
1367    /// Natural atomic charges per atom.
1368    pub natural_charges: Vec<f64>,
1369    /// Wiberg bond order matrix.
1370    pub bond_orders: WibergBondOrderMatrix,
1371    /// Second-order perturbation energies: `(donor_idx, acceptor_idx, E2_kcal_mol)`.
1372    pub second_order_interactions: Vec<(usize, usize, f64)>,
1373}
1374
1375impl NboOutput {
1376    /// Create a new NBO output for `n_atoms`.
1377    pub fn new(n_atoms: usize) -> Self {
1378        Self {
1379            nbos: Vec::new(),
1380            natural_charges: vec![0.0; n_atoms],
1381            bond_orders: WibergBondOrderMatrix::new(n_atoms),
1382            second_order_interactions: Vec::new(),
1383        }
1384    }
1385
1386    /// Total charge from natural atomic charges.
1387    pub fn total_charge(&self) -> f64 {
1388        self.natural_charges.iter().sum()
1389    }
1390
1391    /// Count lone pairs.
1392    pub fn n_lone_pairs(&self) -> usize {
1393        self.nbos.iter().filter(|n| n.is_lone_pair()).count()
1394    }
1395
1396    /// Count bonding NBOs.
1397    pub fn n_bonding(&self) -> usize {
1398        self.nbos.iter().filter(|n| n.is_bonding()).count()
1399    }
1400
1401    /// Strongest donor–acceptor interaction (highest E2).
1402    pub fn strongest_interaction(&self) -> Option<&(usize, usize, f64)> {
1403        self.second_order_interactions
1404            .iter()
1405            .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))
1406    }
1407}
1408
1409// ---------------------------------------------------------------------------
1410// Tests
1411// ---------------------------------------------------------------------------
1412
1413#[cfg(test)]
1414mod tests {
1415    use super::*;
1416
1417    // --- Atom ---
1418    #[test]
1419    fn test_atom_display() {
1420        let a = Atom::new("C", 0.0, 0.0, 0.0);
1421        let s = a.to_string();
1422        assert!(s.contains("C"), "display should contain element: {s}");
1423    }
1424
1425    // --- GaussianInput ---
1426    #[test]
1427    fn test_gaussian_input_render_route() {
1428        let route = GaussianRouteSection::new("B3LYP", "6-31G*", "Opt");
1429        let inp = GaussianInput::new(route, "Test molecule", 0, 1);
1430        let rendered = inp.render();
1431        assert!(rendered.contains("B3LYP/6-31G*"), "route: {rendered}");
1432        assert!(rendered.contains("0 1"), "charge/mult: {rendered}");
1433    }
1434
1435    #[test]
1436    fn test_gaussian_input_num_atoms() {
1437        let route = GaussianRouteSection::new("HF", "STO-3G", "SP");
1438        let mut inp = GaussianInput::new(route, "H2", 0, 1);
1439        inp.add_atom(Atom::new("H", 0.0, 0.0, 0.0));
1440        inp.add_atom(Atom::new("H", 0.74, 0.0, 0.0));
1441        assert_eq!(inp.num_atoms(), 2);
1442    }
1443
1444    #[test]
1445    fn test_gaussian_input_link0() {
1446        let route = GaussianRouteSection::new("B3LYP", "6-31G*", "SP");
1447        let mut inp = GaussianInput::new(route, "test", 0, 1);
1448        inp.add_link0("chk=job.chk");
1449        let rendered = inp.render();
1450        assert!(rendered.contains("%chk=job.chk"), "link0: {rendered}");
1451    }
1452
1453    #[test]
1454    fn test_gaussian_route_keywords() {
1455        let mut route = GaussianRouteSection::new("MP2", "cc-pVDZ", "SP");
1456        route.add_keyword("SCF", "Tight");
1457        let line = route.render();
1458        assert!(line.contains("SCF=Tight"), "keywords: {line}");
1459    }
1460
1461    // --- GaussianOutput ---
1462    #[test]
1463    fn test_gaussian_output_parse_scf_energy() {
1464        let log = "SCF Done:  E(RB3LYP) =  -78.5873 A.U. after    8 cycles\n";
1465        let out = GaussianOutput::parse(log);
1466        let e = out.scf_energy.expect("SCF energy should be parsed");
1467        assert!((e + 78.5873).abs() < 1e-3, "e={e}");
1468    }
1469
1470    #[test]
1471    fn test_gaussian_output_parse_frequencies() {
1472        let log = " Frequencies --   1625.54  3050.12  3100.45\n IR Inten    --      10.20      0.50      5.30\n";
1473        let out = GaussianOutput::parse(log);
1474        assert_eq!(out.frequencies.len(), 3);
1475        assert!((out.frequencies[0].freq_cm - 1625.54).abs() < 0.01);
1476    }
1477
1478    #[test]
1479    fn test_gaussian_output_imaginary_frequency() {
1480        let log = " Frequencies --   -150.3   500.0\n";
1481        let out = GaussianOutput::parse(log);
1482        assert_eq!(out.n_imaginary, 1);
1483    }
1484
1485    #[test]
1486    fn test_gaussian_output_zpe() {
1487        let log = "Zero-point correction=  0.054321 (Hartree/Particle)\n";
1488        let out = GaussianOutput::parse(log);
1489        let zpe = out.zero_point_energy.expect("ZPE");
1490        assert!((zpe - 0.054321).abs() < 1e-6, "zpe={zpe}");
1491    }
1492
1493    #[test]
1494    fn test_gaussian_output_opt_converged() {
1495        let log = "Optimization completed.\n";
1496        let out = GaussianOutput::parse(log);
1497        assert!(out.opt_converged);
1498    }
1499
1500    #[test]
1501    fn test_gaussian_output_lowest_frequency() {
1502        let log = " Frequencies --   300.0   500.0   100.0\n";
1503        let out = GaussianOutput::parse(log);
1504        let lo = out.lowest_frequency().expect("lowest freq");
1505        assert!((lo - 100.0).abs() < 0.01);
1506    }
1507
1508    // --- OrcaInput ---
1509    #[test]
1510    fn test_orca_input_render_keywords() {
1511        let mut inp = OrcaInput::new(0, 1);
1512        inp.add_keyword("B3LYP");
1513        inp.add_keyword("def2-TZVP");
1514        let rendered = inp.render();
1515        assert!(rendered.contains("! B3LYP def2-TZVP"), "render: {rendered}");
1516    }
1517
1518    #[test]
1519    fn test_orca_input_coord_block() {
1520        let mut inp = OrcaInput::new(0, 1);
1521        inp.add_atom(Atom::new("O", 0.0, 0.0, 0.0));
1522        inp.add_atom(Atom::new("H", 0.96, 0.0, 0.0));
1523        let rendered = inp.render();
1524        assert!(rendered.contains("* xyz 0 1"), "coord block: {rendered}");
1525        assert!(rendered.contains("O"), "oxygen: {rendered}");
1526    }
1527
1528    // --- OrcaOutput ---
1529    #[test]
1530    fn test_orca_output_parse_energy() {
1531        let log = "FINAL SINGLE POINT ENERGY       -76.3456789\n";
1532        let out = OrcaOutput::parse(log);
1533        let e = out.electronic_energy.expect("energy");
1534        assert!((e + 76.3456789).abs() < 1e-6, "e={e}");
1535    }
1536
1537    #[test]
1538    fn test_orca_output_total_energy_with_dispersion() {
1539        let log =
1540            "FINAL SINGLE POINT ENERGY       -76.3456789\nDispersion correction    -0.0012345\n";
1541        let out = OrcaOutput::parse(log);
1542        let total = out.total_energy().expect("total");
1543        assert!((total + 76.3469134).abs() < 1e-4, "total={total}");
1544    }
1545
1546    #[test]
1547    fn test_orca_output_opt_converged() {
1548        let log = "OPTIMIZATION HAS CONVERGED\n";
1549        let out = OrcaOutput::parse(log);
1550        assert!(out.opt_converged);
1551    }
1552
1553    // --- MolecularOrbitalSet ---
1554    #[test]
1555    fn test_mo_homo_lumo_gap() {
1556        let mut mo_set = MolecularOrbitalSet::new(5, 5);
1557        // HOMO at index 4, energy = -0.3 Eh; LUMO at index 5, energy = 0.1 Eh
1558        for i in 0..5 {
1559            let e = -0.5 + i as f64 * 0.05;
1560            mo_set.add_alpha(MolecularOrbital::new(i, e, 2.0, Spin::Alpha));
1561        }
1562        mo_set.add_alpha(MolecularOrbital::new(5, 0.1, 0.0, Spin::Alpha));
1563        let gap = mo_set.homo_lumo_gap_ev().expect("gap");
1564        // HOMO energy = -0.5 + 4*0.05 = -0.3; LUMO = 0.1; gap = 0.4 Eh * 27.2114
1565        let expected = 0.4 * 27.2114;
1566        assert!((gap - expected).abs() < 0.01, "gap={gap}");
1567    }
1568
1569    #[test]
1570    fn test_mo_homo_idx() {
1571        let mo_set = MolecularOrbitalSet::new(3, 3);
1572        assert_eq!(mo_set.homo_idx(), Some(2));
1573    }
1574
1575    #[test]
1576    fn test_mo_coeff_norm() {
1577        let mut mo = MolecularOrbital::new(0, -0.5, 2.0, Spin::Alpha);
1578        mo.coefficients = vec![1.0, 0.0, 0.0, 0.0];
1579        assert!((mo.coeff_norm() - 1.0).abs() < 1e-12);
1580    }
1581
1582    // --- BasisSet ---
1583    #[test]
1584    fn test_basis_set_total_functions() {
1585        let mut bs = BasisSet::new("STO-3G");
1586        // Carbon: 1s + 2s + 2p(×3) = 5 basis functions
1587        let mut c_basis = ElementBasis::new("C");
1588        c_basis.add_shell(GaussianShell::new(
1589            ShellType::S,
1590            vec![71.62, 13.04, 3.531],
1591            vec![0.1543, 0.5353, 0.4446],
1592        ));
1593        c_basis.add_shell(GaussianShell::new(
1594            ShellType::SP,
1595            vec![2.941, 0.6835, 0.2222],
1596            vec![-0.09996, 0.3995, 0.7001],
1597        ));
1598        bs.add_element(c_basis);
1599        let total = bs.total_basis_functions(&["C", "C"]);
1600        // Each C: 1 + 4 = 5 basis functions → 10 total
1601        assert_eq!(total, 10, "total={total}");
1602    }
1603
1604    #[test]
1605    fn test_shell_n_primitives() {
1606        let shell = GaussianShell::new(ShellType::D, vec![1.0, 2.0, 3.0], vec![0.1, 0.5, 0.4]);
1607        assert_eq!(shell.n_primitives(), 3);
1608    }
1609
1610    // --- NormalModes ---
1611    #[test]
1612    fn test_normal_modes_n_imaginary() {
1613        let mut nm = NormalModes::new(3);
1614        nm.frequencies = vec![
1615            -150.0, 500.0, 1000.0, 1500.0, 2000.0, 2500.0, 3000.0, 3500.0, 4000.0,
1616        ];
1617        assert_eq!(nm.n_imaginary(), 1);
1618    }
1619
1620    #[test]
1621    fn test_normal_modes_zpve() {
1622        let mut nm = NormalModes::new(2);
1623        nm.frequencies = vec![1000.0, 2000.0];
1624        let zpve = nm.zpve_cm();
1625        assert!((zpve - 1500.0).abs() < 1e-6, "zpve={zpve}");
1626    }
1627
1628    #[test]
1629    fn test_normal_modes_strongest_ir() {
1630        let mut nm = NormalModes::new(3);
1631        nm.frequencies = vec![500.0, 1000.0, 1500.0];
1632        nm.ir_intensities = vec![10.0, 250.0, 5.0];
1633        let (freq, intensity) = nm.strongest_ir_band().expect("IR band");
1634        assert!((freq - 1000.0).abs() < 1e-6);
1635        assert!((intensity - 250.0).abs() < 1e-6);
1636    }
1637
1638    // --- ElectronicStructure ---
1639    #[test]
1640    fn test_electronic_structure_e0_zpe() {
1641        let mut es = ElectronicStructure::new(-76.4, 298.15);
1642        es.zero_point_energy = 0.02;
1643        assert!((es.e0_plus_zpe() + 76.38).abs() < 1e-6);
1644    }
1645
1646    #[test]
1647    fn test_electronic_structure_enthalpy_positive() {
1648        let es = ElectronicStructure::new(-76.4, 298.15);
1649        // Enthalpy should be close to total_energy (small correction)
1650        let h = es.enthalpy();
1651        assert!(
1652            (h - es.total_energy).abs() < 0.01,
1653            "h-e={}",
1654            h - es.total_energy
1655        );
1656    }
1657
1658    // --- ExcitedStates ---
1659    #[test]
1660    fn test_excited_states_wavelength() {
1661        let s = ExcitedStateTransition::new(1, 4.0, 0.3);
1662        // 1239.84 / 4.0 = 309.96 nm
1663        let wl = s.wavelength_nm();
1664        assert!((wl - 309.96).abs() < 0.01, "wl={wl}");
1665    }
1666
1667    #[test]
1668    fn test_excited_states_brightest() {
1669        let mut es = ExcitedStates::new("TDDFT", -76.4);
1670        es.add_state(ExcitedStateTransition::new(1, 4.0, 0.05));
1671        es.add_state(ExcitedStateTransition::new(2, 5.0, 0.80));
1672        es.add_state(ExcitedStateTransition::new(3, 6.0, 0.10));
1673        let brightest = es.brightest_state().expect("brightest");
1674        assert_eq!(brightest.state, 2);
1675    }
1676
1677    #[test]
1678    fn test_excited_states_uv_vis_spectrum() {
1679        let mut es = ExcitedStates::new("TDDFT", -76.4);
1680        es.add_state(ExcitedStateTransition::new(1, 4.0, 1.0));
1681        let (wls, ints) = es.uv_vis_spectrum(200.0, 600.0, 100, 10.0);
1682        assert_eq!(wls.len(), 100);
1683        assert_eq!(ints.len(), 100);
1684        // Intensity should peak near 310 nm
1685        let max_idx = ints
1686            .iter()
1687            .enumerate()
1688            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
1689            .map(|(i, _)| i)
1690            .unwrap();
1691        assert!(
1692            wls[max_idx] > 280.0 && wls[max_idx] < 340.0,
1693            "peak at {}",
1694            wls[max_idx]
1695        );
1696    }
1697
1698    // --- NboOutput ---
1699    #[test]
1700    fn test_nbo_total_charge() {
1701        let mut nbo = NboOutput::new(3);
1702        nbo.natural_charges = vec![-0.5, 0.25, 0.25];
1703        assert!((nbo.total_charge()).abs() < 1e-12);
1704    }
1705
1706    #[test]
1707    fn test_nbo_count_lone_pairs() {
1708        let mut nbo = NboOutput::new(2);
1709        nbo.nbos.push(NaturalBondOrbital::new(1, "LP", 1.98, -0.5));
1710        nbo.nbos.push(NaturalBondOrbital::new(2, "BD", 1.95, -0.4));
1711        nbo.nbos.push(NaturalBondOrbital::new(3, "LP", 1.97, -0.45));
1712        assert_eq!(nbo.n_lone_pairs(), 2);
1713        assert_eq!(nbo.n_bonding(), 1);
1714    }
1715
1716    #[test]
1717    fn test_wiberg_bond_order() {
1718        let mut mat = WibergBondOrderMatrix::new(3);
1719        mat.set(0, 1, 1.95);
1720        mat.set(1, 2, 0.98);
1721        assert!((mat.get(0, 1) - 1.95).abs() < 1e-12);
1722        assert!((mat.get(1, 0) - 1.95).abs() < 1e-12); // symmetric
1723        let val = mat.valence(1);
1724        assert!((val - 2.93).abs() < 1e-6, "valence={val}");
1725    }
1726
1727    #[test]
1728    fn test_nbo_strongest_interaction() {
1729        let mut nbo = NboOutput::new(4);
1730        nbo.second_order_interactions = vec![(0, 1, 5.0), (1, 2, 30.0), (2, 3, 15.0)];
1731        let strongest = nbo.strongest_interaction().expect("strongest");
1732        assert!((strongest.2 - 30.0).abs() < 1e-12);
1733    }
1734}