1use chematic_core::{Atom, AtomIdx, Element, Molecule, MoleculeBuilder};
29
30type GjfResult = (Molecule, Vec<(f64, f64, f64)>, i32, u32);
33
34#[derive(Debug, Clone, PartialEq)]
40pub enum GaussianError {
41 MissingChargeMultiplicity,
43 NoAtoms,
45 UnknownElement(String),
47 InvalidCoordinate(String),
49 NoStandardOrientation,
51}
52
53impl core::fmt::Display for GaussianError {
54 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
55 match self {
56 Self::MissingChargeMultiplicity => {
57 write!(f, "charge/multiplicity line not found in GJF")
58 }
59 Self::NoAtoms => write!(f, "no atom coordinates found in file"),
60 Self::UnknownElement(s) => write!(f, "unknown element '{s}'"),
61 Self::InvalidCoordinate(s) => write!(f, "invalid coordinate value '{s}'"),
62 Self::NoStandardOrientation => {
63 write!(f, "no 'Standard orientation' block found in Gaussian log")
64 }
65 }
66 }
67}
68
69impl std::error::Error for GaussianError {}
70
71pub struct GaussianLogResult {
77 pub mol: Molecule,
79 pub coords: Vec<(f64, f64, f64)>,
81 pub scf_energy: Option<f64>,
83}
84
85pub fn parse_gjf(input: &str) -> Result<GjfResult, GaussianError> {
95 let sections: Vec<Vec<&str>> = {
97 let mut secs: Vec<Vec<&str>> = Vec::new();
98 let mut current: Vec<&str> = Vec::new();
99 for line in input.lines() {
100 let trimmed = line.trim();
101 if trimmed.is_empty() {
102 if !current.is_empty() {
103 secs.push(current.clone());
104 current.clear();
105 }
106 } else {
107 current.push(trimmed);
108 }
109 }
110 if !current.is_empty() {
111 secs.push(current);
112 }
113 secs
114 };
115
116 let route_idx = sections
121 .iter()
122 .position(|sec| sec.iter().any(|l| l.starts_with('#')))
123 .ok_or(GaussianError::MissingChargeMultiplicity)?;
124 let cm_idx = route_idx + 2;
125 if cm_idx >= sections.len() {
126 return Err(GaussianError::MissingChargeMultiplicity);
127 }
128 {
130 let parts: Vec<&str> = sections[cm_idx][0].split_whitespace().collect();
131 if parts.len() < 2 || parts[0].parse::<i32>().is_err() || parts[1].parse::<u32>().is_err() {
132 return Err(GaussianError::MissingChargeMultiplicity);
133 }
134 }
135
136 let parts: Vec<&str> = sections[cm_idx][0].split_whitespace().collect();
137 let charge: i32 = parts[0].parse().unwrap();
138 let multiplicity: u32 = parts[1].parse().unwrap();
139
140 let coord_lines: &[&str] = if sections[cm_idx].len() > 1 {
142 §ions[cm_idx][1..]
143 } else if let Some(next) = sections.get(cm_idx + 1) {
144 next.as_slice()
145 } else {
146 return Err(GaussianError::NoAtoms);
147 };
148
149 parse_atom_coords(coord_lines, charge, multiplicity)
150}
151
152fn parse_atom_coords(
153 lines: &[&str],
154 charge: i32,
155 multiplicity: u32,
156) -> Result<GjfResult, GaussianError> {
157 let mut builder = MoleculeBuilder::new();
158 let mut coords: Vec<(f64, f64, f64)> = Vec::new();
159
160 for line in lines {
161 let line = line.trim();
162 if line.is_empty() || line.starts_with('!') {
163 continue;
164 }
165 if line.starts_with("Variables:") || line.starts_with("Constants:") {
166 break;
167 }
168 let parts: Vec<&str> = line.split_whitespace().collect();
169 if parts.len() < 4 {
170 continue;
171 }
172 let raw_sym = parts[0].trim_end_matches(|c: char| c.is_ascii_digit());
173 let elem = if raw_sym.is_empty() {
174 let atomic_num: u8 = parts[0]
176 .parse()
177 .map_err(|_| GaussianError::UnknownElement(parts[0].to_string()))?;
178 Element::from_atomic_number(atomic_num)
179 .ok_or_else(|| GaussianError::UnknownElement(parts[0].to_string()))?
180 } else {
181 Element::from_symbol(raw_sym)
182 .ok_or_else(|| GaussianError::UnknownElement(raw_sym.to_string()))?
183 };
184
185 let x: f64 = parts[1]
186 .parse()
187 .map_err(|_| GaussianError::InvalidCoordinate(parts[1].to_string()))?;
188 let y: f64 = parts[2]
189 .parse()
190 .map_err(|_| GaussianError::InvalidCoordinate(parts[2].to_string()))?;
191 let z: f64 = parts[3]
192 .parse()
193 .map_err(|_| GaussianError::InvalidCoordinate(parts[3].to_string()))?;
194
195 builder.add_atom(Atom::new(elem));
196 coords.push((x, y, z));
197 }
198
199 if coords.is_empty() {
200 return Err(GaussianError::NoAtoms);
201 }
202
203 Ok((builder.build(), coords, charge, multiplicity))
204}
205
206pub fn write_gjf(
215 mol: &Molecule,
216 coords: &[(f64, f64, f64)],
217 charge: i32,
218 multiplicity: u32,
219 method: &str,
220 title: &str,
221) -> String {
222 let method = if method.is_empty() {
223 "B3LYP/6-31G* opt"
224 } else {
225 method
226 };
227 let title = if title.is_empty() { "chematic" } else { title };
228
229 let mut out = format!("# {method}\n\n{title}\n\n{charge} {multiplicity}\n");
230
231 for i in 0..mol.atom_count() {
232 let idx = AtomIdx(i as u32);
233 let sym = mol.atom(idx).element.symbol();
234 let (x, y, z) = coords.get(i).copied().unwrap_or((0.0, 0.0, 0.0));
235 out.push_str(&format!("{sym:<3} {:12.6} {:12.6} {:12.6}\n", x, y, z));
236 }
237 out.push('\n');
238 out
239}
240
241pub fn parse_gaussian_log(input: &str) -> Result<GaussianLogResult, GaussianError> {
253 let lines: Vec<&str> = input.lines().collect();
254
255 let last_orient_start = lines
257 .iter()
258 .rposition(|l| l.contains("Standard orientation:"))
259 .ok_or(GaussianError::NoStandardOrientation)?;
260
261 let mut builder = MoleculeBuilder::new();
262 let mut coords: Vec<(f64, f64, f64)> = Vec::new();
263 let mut dashes_seen = 0usize;
264 let mut in_table = false;
265
266 for line in &lines[last_orient_start + 1..] {
267 let trimmed = line.trim();
268 if trimmed.starts_with("---") {
269 dashes_seen += 1;
270 if dashes_seen == 2 {
271 in_table = true; } else if in_table {
273 break; }
275 continue;
276 }
277 if !in_table {
278 continue;
279 }
280 let parts: Vec<&str> = trimmed.split_whitespace().collect();
283 let (an_col, x_col) = match parts.len() {
284 n if n >= 6 => (1, 3), 5 => (1, 2), _ => continue,
287 };
288 let atomic_num: u8 = parts[an_col]
289 .parse()
290 .map_err(|_| GaussianError::UnknownElement(parts[an_col].to_string()))?;
291 let elem = Element::from_atomic_number(atomic_num)
292 .ok_or_else(|| GaussianError::UnknownElement(parts[an_col].to_string()))?;
293 let x: f64 = parts[x_col]
294 .parse()
295 .map_err(|_| GaussianError::InvalidCoordinate(parts[x_col].to_string()))?;
296 let y: f64 = parts[x_col + 1]
297 .parse()
298 .map_err(|_| GaussianError::InvalidCoordinate(parts[x_col + 1].to_string()))?;
299 let z: f64 = parts[x_col + 2]
300 .parse()
301 .map_err(|_| GaussianError::InvalidCoordinate(parts[x_col + 2].to_string()))?;
302
303 builder.add_atom(Atom::new(elem));
304 coords.push((x, y, z));
305 }
306
307 if coords.is_empty() {
308 return Err(GaussianError::NoAtoms);
309 }
310
311 let scf_energy = lines
313 .iter()
314 .rfind(|l| l.contains("SCF Done:"))
315 .and_then(|l| {
316 let after = l[l.find('=')? + 1..].trim();
317 after.split_whitespace().next()?.parse::<f64>().ok()
318 });
319
320 Ok(GaussianLogResult {
321 mol: builder.build(),
322 coords,
323 scf_energy,
324 })
325}
326
327#[cfg(test)]
332mod tests {
333 use super::*;
334
335 const ETHANOL_GJF: &str = r#"# B3LYP/6-31G* opt
336
337Ethanol
338
3390 1
340C 0.000000 0.000000 0.000000
341C 1.531000 0.000000 0.000000
342O 2.058000 1.198000 0.000000
343H -0.390000 1.020000 0.000000
344H -0.390000 -0.510000 0.884000
345H -0.390000 -0.510000 -0.884000
346H 1.921000 -1.020000 0.000000
347H 1.921000 0.510000 -0.884000
348H 2.028000 1.604000 0.886000
349
350"#;
351
352 const LOG_SNIPPET: &str = r#"
353 Standard orientation:
354 ---------------------------------------------------------------------
355 Center Atomic Atomic Coordinates (Angstroms)
356 Number Number Type X Y Z
357 ---------------------------------------------------------------------
358 1 6 0 0.000000 0.000000 0.000000
359 2 6 0 1.531000 0.000000 0.000000
360 3 8 0 2.058000 1.198000 0.000000
361 ---------------------------------------------------------------------
362 SCF Done: E(RB3LYP) = -154.0987765432 A.U. after 12 cycles
363"#;
364
365 #[test]
366 fn parse_gjf_atom_count() {
367 let (mol, coords, charge, mult) = parse_gjf(ETHANOL_GJF).unwrap();
368 assert_eq!(mol.atom_count(), 9);
369 assert_eq!(coords.len(), 9);
370 assert_eq!(charge, 0);
371 assert_eq!(mult, 1);
372 }
373
374 #[test]
375 fn parse_gjf_first_coord() {
376 let (_, coords, _, _) = parse_gjf(ETHANOL_GJF).unwrap();
377 let (x, y, z) = coords[0];
378 assert!(x.abs() < 1e-6 && y.abs() < 1e-6 && z.abs() < 1e-6);
379 }
380
381 #[test]
382 fn write_gjf_roundtrip() {
383 let (mol, coords, charge, mult) = parse_gjf(ETHANOL_GJF).unwrap();
384 let out = write_gjf(&mol, &coords, charge, mult, "B3LYP/6-31G* opt", "Ethanol");
385 assert!(out.contains("# B3LYP/6-31G* opt"));
386 assert!(out.contains("0 1"));
387 let (mol2, coords2, _, _) = parse_gjf(&out).unwrap();
388 assert_eq!(mol2.atom_count(), 9);
389 assert_eq!(coords2.len(), 9);
390 }
391
392 #[test]
393 fn parse_gjf_charged_molecule() {
394 let gjf =
395 "# HF/STO-3G\n\nwater cation\n\n1 2\nO 0.0 0.0 0.0\nH 0.96 0.0 0.0\nH -0.24 0.93 0.0\n";
396 let (mol, _, charge, mult) = parse_gjf(gjf).unwrap();
397 assert_eq!(mol.atom_count(), 3);
398 assert_eq!(charge, 1);
399 assert_eq!(mult, 2);
400 }
401
402 #[test]
403 fn parse_log_geometry() {
404 let r = parse_gaussian_log(LOG_SNIPPET).unwrap();
405 assert_eq!(r.mol.atom_count(), 3);
406 assert_eq!(r.coords.len(), 3);
407 let (x, y, z) = r.coords[0];
408 assert!(x.abs() < 1e-6 && y.abs() < 1e-6 && z.abs() < 1e-6);
409 }
410
411 #[test]
412 fn parse_log_energy() {
413 let r = parse_gaussian_log(LOG_SNIPPET).unwrap();
414 let e = r.scf_energy.expect("energy should be present");
415 assert!((e - (-154.0987765432)).abs() < 1e-8);
416 }
417
418 #[test]
419 fn parse_log_last_orientation() {
420 let input = format!(
422 "{}\n Standard orientation:\n ---\n ---\n 1 6 0 9.0 9.0 9.0\n ---\n{}",
423 LOG_SNIPPET, ""
424 );
425 let r = parse_gaussian_log(&input).unwrap();
426 assert!((r.coords[0].0 - 9.0).abs() < 1e-6);
428 }
429}