use std::{
collections::HashMap,
io::{self, ErrorKind, Write as _},
path::Path,
process::Stdio,
};
use lin_alg::f64::Vec3;
#[derive(Clone, Debug, Default)]
pub struct OutputEnergy {
pub time: f64,
pub temperature: Option<f32>,
pub pressure: Option<f32>,
pub potential_energy: Option<f32>,
pub kinetic_energy: Option<f32>,
pub total_energy: Option<f32>,
pub volume: Option<f32>,
pub density: Option<f32>,
}
impl OutputEnergy {
pub fn from_edr(path: &Path) -> io::Result<Vec<Self>> {
let dir = path.parent().unwrap_or(Path::new("."));
let edr_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "invalid EDR path"))?;
const XVG_OUT: &str = "energy_out.xvg";
let selection =
b"Temperature\nPressure\nPotential\nKinetic-En.\nTotal-Energy\nVolume\nDensity\n0\n";
let mut child = match super::gmx_command()
.current_dir(dir)
.args(["energy", "-f", edr_name, "-o", XVG_OUT])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) if e.kind() == ErrorKind::NotFound => {
eprintln!("`gmx` not found; skipping energy parsing");
return Ok(Vec::new());
}
Err(e) => return Err(e),
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(selection);
}
let out = child.wait_with_output()?;
if !out.status.success() {
let msg = String::from_utf8_lossy(&out.stderr);
eprintln!("gmx energy exited non-zero: {msg}");
}
let xvg_path = dir.join(XVG_OUT);
if !xvg_path.exists() {
return Ok(Vec::new());
}
let text = std::fs::read_to_string(&xvg_path)?;
parse_xvg(&text)
}
}
#[derive(Clone, Debug)]
pub struct GromacsFrame {
pub time: f64,
pub atom_posits: Vec<Vec3>,
pub atom_velocities: Vec<Vec3>,
pub atom_forces: Vec<Vec3>,
pub energy: Option<OutputEnergy>,
}
#[derive(Clone, Debug, Default)]
pub struct GromacsOutput {
pub log_text: String,
pub setup_failure: bool,
pub trajectory: Vec<GromacsFrame>,
pub solute_atom_count: usize,
pub gro_path: Option<std::path::PathBuf>,
pub trr_path: Option<std::path::PathBuf>,
pub xtc_path: Option<std::path::PathBuf>,
pub final_gro_text: Option<String>,
pub final_topology_text: Option<String>,
}
impl GromacsOutput {
pub fn new(
log_text: String,
mut trajectory: Vec<GromacsFrame>,
energies: Vec<OutputEnergy>,
solute_atom_count: usize,
) -> io::Result<Self> {
attach_energies(&mut trajectory, energies);
Ok(Self {
log_text,
setup_failure: false,
trajectory,
solute_atom_count,
gro_path: None,
trr_path: None,
xtc_path: None,
final_gro_text: None,
final_topology_text: None,
})
}
}
pub fn parse_gro_traj(text: &str) -> io::Result<Vec<GromacsFrame>> {
let mut frames = Vec::new();
let mut lines = text.lines().peekable();
while lines.peek().is_some() {
let title = match lines.next() {
Some(l) => l,
None => break,
};
let time_ps = extract_time(title);
let natoms_str = lines.next().ok_or_else(|| {
io::Error::new(ErrorKind::UnexpectedEof, "Expected atom count after title")
})?;
let natoms: usize = natoms_str
.trim()
.split_whitespace()
.next()
.ok_or_else(|| io::Error::new(ErrorKind::InvalidData, "Empty atom count line"))?
.parse()
.map_err(|_| io::Error::new(ErrorKind::InvalidData, "Could not parse atom count"))?;
let mut posits = Vec::with_capacity(natoms);
for _ in 0..natoms {
let line = lines.next().ok_or_else(|| {
io::Error::new(ErrorKind::UnexpectedEof, "Unexpected end of atom block")
})?;
let x_nm = parse_col(line, 20, 28)?;
let y_nm = parse_col(line, 28, 36)?;
let z_nm = parse_col(line, 36, 44)?;
posits.push(Vec3 {
x: x_nm,
y: y_nm,
z: z_nm,
});
}
lines.next();
frames.push(GromacsFrame {
time: time_ps,
atom_posits: posits,
atom_velocities: Vec::new(),
atom_forces: Vec::new(),
energy: None,
});
}
Ok(frames)
}
fn attach_energies(frames: &mut Vec<GromacsFrame>, energies: Vec<OutputEnergy>) {
if energies.is_empty() {
return;
}
let map: HashMap<i64, OutputEnergy> = energies
.into_iter()
.map(|e| ((e.time * 1_000.0).round() as i64, e))
.collect();
for frame in frames.iter_mut() {
let key = (frame.time * 1_000.0).round() as i64;
frame.energy = map.get(&key).cloned();
}
}
fn parse_xvg(text: &str) -> io::Result<Vec<OutputEnergy>> {
let mut col_names: Vec<String> = Vec::new();
let mut rows: Vec<Vec<f64>> = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('@') {
if let Some(name) = parse_xvg_legend(trimmed) {
col_names.push(name);
}
continue;
}
let vals: Vec<f64> = trimmed
.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect();
if !vals.is_empty() {
rows.push(vals);
}
}
let energies = rows
.into_iter()
.map(|vals| {
let time = vals.first().copied().unwrap_or(0.0);
let mut e = OutputEnergy {
time,
..OutputEnergy::default()
};
for (col, name) in col_names.iter().enumerate() {
let val = vals.get(col + 1).copied().map(|v| v as f32);
match name.as_str() {
"Temperature" => e.temperature = val,
"Pressure" => e.pressure = val,
"Potential" => e.potential_energy = val,
n if n.starts_with("Kinetic") => e.kinetic_energy = val,
n if n.starts_with("Total") => e.total_energy = val,
"Volume" => e.volume = val,
"Density" => e.density = val,
_ => {}
}
}
e
})
.collect();
Ok(energies)
}
fn parse_xvg_legend(line: &str) -> Option<String> {
let rest = line.strip_prefix('@')?.trim();
if !rest.starts_with('s') {
return None;
}
let after_index = rest.trim_start_matches(|c: char| c == 's' || c.is_ascii_digit());
let rest = after_index.trim();
let rest = rest.strip_prefix("legend")?.trim();
let name = rest.trim_matches('"');
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
fn extract_time(title: &str) -> f64 {
for sep in ["t=", "t ="] {
if let Some(pos) = title.find(sep) {
let rest = title[pos + sep.len()..].trim();
if let Some(tok) = rest.split_whitespace().next() {
if let Ok(v) = tok.parse::<f64>() {
return v;
}
}
}
}
0.0
}
fn parse_col(line: &str, start: usize, end: usize) -> io::Result<f64> {
let len = line.len();
if start >= len {
return Ok(0.0);
}
let end = end.min(len);
let s = line[start..end].trim();
if s.is_empty() {
return Ok(0.0);
}
s.parse::<f64>()
.map_err(|_| io::Error::new(ErrorKind::InvalidData, format!("Cannot parse '{s}' as f64")))
}