1#![allow(dead_code)]
12#![allow(clippy::too_many_arguments)]
13
14use std::collections::HashMap;
15use std::fmt;
16
17#[derive(Debug, Clone, PartialEq)]
23pub struct Atom {
24 pub symbol: String,
26 pub coords: [f64; 3],
28 pub atomic_number: u8,
30}
31
32impl Atom {
33 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 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#[derive(Debug, Clone)]
65pub struct GaussianRouteSection {
66 pub method: String,
68 pub basis_set: String,
70 pub calc_type: String,
72 pub keywords: HashMap<String, String>,
74 pub extra_tokens: Vec<String>,
76}
77
78impl GaussianRouteSection {
79 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 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 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#[derive(Debug, Clone)]
119pub struct GaussianInput {
120 pub link0: Vec<String>,
122 pub route: GaussianRouteSection,
124 pub title: String,
126 pub charge: i32,
128 pub multiplicity: u32,
130 pub atoms: Vec<Atom>,
132 pub z_matrix: Option<String>,
134}
135
136impl GaussianInput {
137 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 pub fn add_atom(&mut self, atom: Atom) {
157 self.atoms.push(atom);
158 }
159
160 pub fn add_link0(&mut self, directive: impl Into<String>) {
162 self.link0.push(directive.into());
163 }
164
165 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 pub fn num_atoms(&self) -> usize {
192 self.atoms.len()
193 }
194}
195
196#[derive(Debug, Clone)]
202pub struct OptStep {
203 pub step: usize,
205 pub energy: f64,
207 pub max_force: f64,
209 pub rms_force: f64,
211 pub max_displacement: f64,
213 pub rms_displacement: f64,
215 pub converged: bool,
217 pub atoms: Vec<Atom>,
219}
220
221impl OptStep {
222 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#[derive(Debug, Clone)]
239pub struct GaussianFrequency {
240 pub freq_cm: f64,
242 pub ir_intensity: f64,
244 pub raman_activity: Option<f64>,
246 pub reduced_mass: f64,
248 pub displacements: Vec<[f64; 3]>,
250}
251
252impl GaussianFrequency {
253 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 pub fn is_imaginary(&self) -> bool {
266 self.freq_cm < 0.0
267 }
268}
269
270#[derive(Debug, Clone)]
272pub struct NmrShielding {
273 pub atom_idx: usize,
275 pub isotropic: f64,
277 pub anisotropy: f64,
279}
280
281#[derive(Debug, Clone, Default)]
283pub struct GaussianOutput {
284 pub scf_energy: Option<f64>,
286 pub opt_converged: bool,
288 pub opt_steps: Vec<OptStep>,
290 pub frequencies: Vec<GaussianFrequency>,
292 pub nmr_shieldings: Vec<NmrShielding>,
294 pub zero_point_energy: Option<f64>,
296 pub thermal_correction_h: Option<f64>,
298 pub thermal_correction_g: Option<f64>,
300 pub final_geometry: Vec<Atom>,
302 pub n_imaginary: usize,
304}
305
306impl GaussianOutput {
307 pub fn new() -> Self {
309 Self::default()
310 }
311
312 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 if trimmed.starts_with("SCF Done:")
325 && let Some(e) = parse_after_eq(trimmed)
326 {
327 out.scf_energy = Some(e);
328 }
329
330 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 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 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 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 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 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 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 if let Some(step) = current_opt_step {
402 out.opt_steps.push(step);
403 }
404
405 out
406 }
407
408 pub fn lowest_frequency(&self) -> Option<f64> {
410 self.frequencies.iter().map(|f| f.freq_cm).reduce(f64::min)
411 }
412
413 pub fn n_frequencies(&self) -> usize {
415 self.frequencies.len()
416 }
417}
418
419fn parse_after_eq(s: &str) -> Option<f64> {
421 let idx = s.rfind('=')?;
422 s[idx + 1..].split_whitespace().next()?.parse().ok()
423}
424
425fn 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#[derive(Debug, Clone)]
440pub struct OrcaSettings {
441 pub max_scf_iter: usize,
443 pub scf_conv: f64,
445 pub grad_conv: f64,
447 pub grid: u8,
449 pub rijcosx: bool,
451 pub aux_basis: Option<String>,
453 pub n_procs: usize,
455 pub mem_per_proc: usize,
457}
458
459impl OrcaSettings {
460 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#[derive(Debug, Clone)]
477pub struct OrcaInput {
478 pub keywords: Vec<String>,
480 pub charge: i32,
482 pub multiplicity: u32,
484 pub atoms: Vec<Atom>,
486 pub settings: OrcaSettings,
488 pub extra_blocks: Vec<String>,
490}
491
492impl OrcaInput {
493 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 pub fn add_keyword(&mut self, kw: impl Into<String>) {
507 self.keywords.push(kw.into());
508 }
509
510 pub fn add_atom(&mut self, atom: Atom) {
512 self.atoms.push(atom);
513 }
514
515 pub fn render(&self) -> String {
517 let mut out = String::new();
518
519 if !self.keywords.is_empty() {
521 out.push_str("! ");
522 out.push_str(&self.keywords.join(" "));
523 out.push('\n');
524 }
525
526 if self.settings.n_procs > 1 {
528 out.push_str(&format!("%pal nprocs {} end\n", self.settings.n_procs));
529 }
530
531 out.push_str(&format!("%maxcore {}\n", self.settings.mem_per_proc));
533
534 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 for block in &self.extra_blocks {
542 out.push_str(block);
543 out.push('\n');
544 }
545
546 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#[derive(Debug, Clone)]
563pub struct OrcaExcitedState {
564 pub state: usize,
566 pub energy_ev: f64,
568 pub oscillator_strength: f64,
570 pub symmetry: String,
572}
573
574impl OrcaExcitedState {
575 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#[derive(Debug, Clone)]
593pub struct AtomGradient {
594 pub atom_idx: usize,
596 pub gradient: [f64; 3],
598}
599
600#[derive(Debug, Clone, Default)]
602pub struct OrcaOutput {
603 pub electronic_energy: Option<f64>,
605 pub dispersion_correction: Option<f64>,
607 pub gradients: Vec<AtomGradient>,
609 pub excited_states: Vec<OrcaExcitedState>,
611 pub opt_converged: bool,
613 pub scf_iterations: usize,
615 pub version: Option<String>,
617 pub final_geometry: Vec<Atom>,
619}
620
621impl OrcaOutput {
622 pub fn new() -> Self {
624 Self::default()
625 }
626
627 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 if trimmed.starts_with("Program Version") {
636 out.version = Some(trimmed.to_string());
637 }
638
639 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 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 if trimmed.contains("OPTIMIZATION HAS CONVERGED") {
661 out.opt_converged = true;
662 }
663
664 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 if trimmed.starts_with("STATE") && trimmed.contains("EXCITATION ENERGY") {
676 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 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 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
716fn 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
727fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
743pub enum Spin {
744 Alpha,
746 Beta,
748}
749
750#[derive(Debug, Clone)]
752pub struct MolecularOrbital {
753 pub index: usize,
755 pub energy: f64,
757 pub occupation: f64,
759 pub spin: Spin,
761 pub symmetry: String,
763 pub coefficients: Vec<f64>,
765}
766
767impl MolecularOrbital {
768 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 pub fn is_homo(&self, homo_idx: usize) -> bool {
782 self.index == homo_idx
783 }
784
785 pub fn is_lumo(&self, lumo_idx: usize) -> bool {
787 self.index == lumo_idx
788 }
789
790 pub fn coeff_norm(&self) -> f64 {
792 self.coefficients.iter().map(|c| c * c).sum::<f64>().sqrt()
793 }
794}
795
796#[derive(Debug, Clone, Default)]
798pub struct MolecularOrbitalSet {
799 pub alpha_mos: Vec<MolecularOrbital>,
801 pub beta_mos: Vec<MolecularOrbital>,
803 pub n_alpha: usize,
805 pub n_beta: usize,
807}
808
809impl MolecularOrbitalSet {
810 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 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 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 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 Some((e_lumo - e_homo) * 27.2114)
846 }
847
848 pub fn add_alpha(&mut self, mo: MolecularOrbital) {
850 self.alpha_mos.push(mo);
851 }
852
853 pub fn add_beta(&mut self, mo: MolecularOrbital) {
855 self.beta_mos.push(mo);
856 }
857}
858
859#[derive(Debug, Clone, Copy, PartialEq, Eq)]
865pub enum ShellType {
866 S,
868 P,
870 D,
872 F,
874 G,
876 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#[derive(Debug, Clone)]
895pub struct GaussianShell {
896 pub shell_type: ShellType,
898 pub exponents: Vec<f64>,
900 pub coefficients_s: Vec<f64>,
902 pub coefficients_p: Vec<f64>,
904}
905
906impl GaussianShell {
907 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 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 pub fn n_primitives(&self) -> usize {
929 self.exponents.len()
930 }
931
932 pub fn n_basis_functions(&self) -> usize {
934 match self.shell_type {
935 ShellType::S => 1,
936 ShellType::P => 3,
937 ShellType::D => 6, ShellType::F => 10,
939 ShellType::G => 15,
940 ShellType::SP => 4, }
942 }
943}
944
945#[derive(Debug, Clone)]
947pub struct ElementBasis {
948 pub element: String,
950 pub shells: Vec<GaussianShell>,
952}
953
954impl ElementBasis {
955 pub fn new(element: impl Into<String>) -> Self {
957 Self {
958 element: element.into(),
959 shells: Vec::new(),
960 }
961 }
962
963 pub fn add_shell(&mut self, shell: GaussianShell) {
965 self.shells.push(shell);
966 }
967
968 pub fn n_basis_functions(&self) -> usize {
970 self.shells.iter().map(|s| s.n_basis_functions()).sum()
971 }
972}
973
974#[derive(Debug, Clone)]
976pub struct BasisSet {
977 pub name: String,
979 pub elements: HashMap<String, ElementBasis>,
981}
982
983impl BasisSet {
984 pub fn new(name: impl Into<String>) -> Self {
986 Self {
987 name: name.into(),
988 elements: HashMap::new(),
989 }
990 }
991
992 pub fn add_element(&mut self, basis: ElementBasis) {
994 self.elements.insert(basis.element.clone(), basis);
995 }
996
997 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#[derive(Debug, Clone)]
1013pub struct NormalModes {
1014 pub n_atoms: usize,
1016 pub frequencies: Vec<f64>,
1018 pub ir_intensities: Vec<f64>,
1020 pub raman_activities: Vec<f64>,
1022 pub displacements: Vec<Vec<f64>>,
1024 pub reduced_masses: Vec<f64>,
1026 pub force_constants: Vec<f64>,
1028}
1029
1030impl NormalModes {
1031 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 pub fn expected_modes_nonlinear(&self) -> usize {
1046 3 * self.n_atoms - 6
1047 }
1048
1049 pub fn expected_modes_linear(&self) -> usize {
1051 3 * self.n_atoms - 5
1052 }
1053
1054 pub fn n_imaginary(&self) -> usize {
1056 self.frequencies.iter().filter(|&&f| f < 0.0).count()
1057 }
1058
1059 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 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#[derive(Debug, Clone)]
1085pub struct ElectronicStructure {
1086 pub total_energy: f64,
1088 pub zero_point_energy: f64,
1090 pub thermal_energy: f64,
1092 pub enthalpy_correction: f64,
1094 pub entropy: f64,
1096 pub temperature: f64,
1098 pub gibbs_free_energy: f64,
1100 pub translational_entropy: f64,
1102 pub rotational_entropy: f64,
1104 pub vibrational_entropy: f64,
1106}
1107
1108impl ElectronicStructure {
1109 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 pub fn e0_plus_zpe(&self) -> f64 {
1127 self.total_energy + self.zero_point_energy
1128 }
1129
1130 pub fn enthalpy(&self) -> f64 {
1134 let kt = self.temperature / 315_775.13;
1136 self.total_energy + self.zero_point_energy + self.thermal_energy + kt
1137 }
1138
1139 pub fn free_energy(&self) -> f64 {
1141 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#[derive(Debug, Clone)]
1154pub struct ExcitedStateTransition {
1155 pub state: usize,
1157 pub energy_ev: f64,
1159 pub oscillator_strength: f64,
1161 pub dominant_transitions: Vec<(usize, usize, f64)>,
1163 pub transition_dipole: [f64; 3],
1165 pub multiplicity: String,
1167}
1168
1169impl ExcitedStateTransition {
1170 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 pub fn wavelength_nm(&self) -> f64 {
1184 1239.84 / self.energy_ev
1185 }
1186
1187 pub fn add_transition(&mut self, from: usize, to: usize, coeff: f64) {
1189 self.dominant_transitions.push((from, to, coeff));
1190 }
1191}
1192
1193#[derive(Debug, Clone)]
1195pub struct ExcitedStates {
1196 pub states: Vec<ExcitedStateTransition>,
1198 pub method: String,
1200 pub ground_energy: f64,
1202}
1203
1204impl ExcitedStates {
1205 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 pub fn add_state(&mut self, state: ExcitedStateTransition) {
1216 self.states.push(state);
1217 }
1218
1219 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 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#[derive(Debug, Clone)]
1266pub struct NaturalBondOrbital {
1267 pub index: usize,
1269 pub nbo_type: String,
1271 pub atoms: Vec<usize>,
1273 pub occupancy: f64,
1275 pub energy: f64,
1277 pub hybrids: Vec<(usize, String, f64)>,
1279}
1280
1281impl NaturalBondOrbital {
1282 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 pub fn is_lone_pair(&self) -> bool {
1296 self.nbo_type.starts_with("LP")
1297 }
1298
1299 pub fn is_bonding(&self) -> bool {
1301 self.nbo_type == "BD"
1302 }
1303
1304 pub fn is_antibonding(&self) -> bool {
1306 self.nbo_type == "BD*"
1307 }
1308}
1309
1310#[derive(Debug, Clone)]
1312pub struct WibergBondOrderMatrix {
1313 pub n_atoms: usize,
1315 data: Vec<f64>,
1317}
1318
1319impl WibergBondOrderMatrix {
1320 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 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 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 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 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#[derive(Debug, Clone)]
1364pub struct NboOutput {
1365 pub nbos: Vec<NaturalBondOrbital>,
1367 pub natural_charges: Vec<f64>,
1369 pub bond_orders: WibergBondOrderMatrix,
1371 pub second_order_interactions: Vec<(usize, usize, f64)>,
1373}
1374
1375impl NboOutput {
1376 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 pub fn total_charge(&self) -> f64 {
1388 self.natural_charges.iter().sum()
1389 }
1390
1391 pub fn n_lone_pairs(&self) -> usize {
1393 self.nbos.iter().filter(|n| n.is_lone_pair()).count()
1394 }
1395
1396 pub fn n_bonding(&self) -> usize {
1398 self.nbos.iter().filter(|n| n.is_bonding()).count()
1399 }
1400
1401 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#[cfg(test)]
1414mod tests {
1415 use super::*;
1416
1417 #[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 #[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 #[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 #[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 #[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 #[test]
1555 fn test_mo_homo_lumo_gap() {
1556 let mut mo_set = MolecularOrbitalSet::new(5, 5);
1557 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 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 #[test]
1584 fn test_basis_set_total_functions() {
1585 let mut bs = BasisSet::new("STO-3G");
1586 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 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 #[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 #[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 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 #[test]
1660 fn test_excited_states_wavelength() {
1661 let s = ExcitedStateTransition::new(1, 4.0, 0.3);
1662 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 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 #[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); 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}