Skip to main content

oxiphysics_io/
ambermd_io.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! AMBER molecular dynamics file format I/O (prmtop, rst7, mdcrd, mdinfo).
6//!
7//! Supports reading and writing the primary AMBER file formats used in
8//! classical molecular dynamics simulations:
9//!
10//! - `prmtop` / `parm7` – topology + force-field parameters
11//! - `rst7` / `inpcrd` – restart / initial-coordinate files
12//! - `mdcrd` / `nc` – ASCII trajectory files
13//! - `mdin` – MD input (namelist) files
14//! - `mdinfo` – runtime energy/temperature summary files
15//! - GAFF / ff14SB parameter files (MASS, BOND, ANGLE, DIHE sections)
16
17use std::collections::HashMap;
18use std::fmt;
19use std::io::{BufRead, BufReader, BufWriter, Read, Write};
20// ──────────────────────────────────────────────────────────────────────────────
21// Shared error type
22// ──────────────────────────────────────────────────────────────────────────────
23
24/// Errors produced by the AMBER I/O module.
25#[derive(Debug)]
26pub enum AmberIoError {
27    /// The file could not be parsed.
28    ParseError(String),
29    /// An I/O operation failed.
30    IoError(String),
31    /// A required section was not found.
32    MissingSection(String),
33    /// Unexpected end of data.
34    UnexpectedEof,
35}
36
37impl fmt::Display for AmberIoError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            AmberIoError::ParseError(s) => write!(f, "AMBER parse error: {s}"),
41            AmberIoError::IoError(s) => write!(f, "AMBER I/O error: {s}"),
42            AmberIoError::MissingSection(s) => write!(f, "AMBER missing section: {s}"),
43            AmberIoError::UnexpectedEof => write!(f, "AMBER unexpected end of file"),
44        }
45    }
46}
47
48impl std::error::Error for AmberIoError {}
49
50// ──────────────────────────────────────────────────────────────────────────────
51// PrmtopAtom
52// ──────────────────────────────────────────────────────────────────────────────
53
54/// A single atom entry from an AMBER topology (`prmtop`) file.
55#[derive(Debug, Clone, PartialEq)]
56pub struct PrmtopAtom {
57    /// AMBER atom name (up to 4 characters).
58    pub name: String,
59    /// Partial charge in AMBER internal units (e × 18.2223).
60    pub charge: f64,
61    /// Atomic mass in u (unified atomic mass units).
62    pub mass: f64,
63    /// AMBER atom-type string.
64    pub atom_type: String,
65}
66
67impl PrmtopAtom {
68    /// Create a new [`PrmtopAtom`].
69    pub fn new(
70        name: impl Into<String>,
71        charge: f64,
72        mass: f64,
73        atom_type: impl Into<String>,
74    ) -> Self {
75        PrmtopAtom {
76            name: name.into(),
77            charge,
78            mass,
79            atom_type: atom_type.into(),
80        }
81    }
82
83    /// Convert the stored AMBER charge to SI elementary charges (divide by 18.2223).
84    pub fn charge_si(&self) -> f64 {
85        self.charge / 18.2223
86    }
87}
88
89// ──────────────────────────────────────────────────────────────────────────────
90// PrmtopBond
91// ──────────────────────────────────────────────────────────────────────────────
92
93/// Bond connectivity and force-field parameters from a prmtop file.
94#[derive(Debug, Clone, PartialEq)]
95pub struct PrmtopBond {
96    /// Index of the first atom (0-based).
97    pub atom_i: usize,
98    /// Index of the second atom (0-based).
99    pub atom_j: usize,
100    /// Harmonic force constant k (kcal mol⁻¹ Å⁻²).
101    pub force_constant: f64,
102    /// Equilibrium bond length r₀ (Å).
103    pub eq_length: f64,
104}
105
106impl PrmtopBond {
107    /// Create a new [`PrmtopBond`].
108    pub fn new(atom_i: usize, atom_j: usize, force_constant: f64, eq_length: f64) -> Self {
109        PrmtopBond {
110            atom_i,
111            atom_j,
112            force_constant,
113            eq_length,
114        }
115    }
116
117    /// Compute the harmonic potential energy for a given bond length.
118    ///
119    /// `E = k * (r - r0)^2`
120    pub fn energy(&self, r: f64) -> f64 {
121        self.force_constant * (r - self.eq_length).powi(2)
122    }
123}
124
125// ──────────────────────────────────────────────────────────────────────────────
126// PrmtopAngle
127// ──────────────────────────────────────────────────────────────────────────────
128
129/// Angle connectivity and force-field parameters from a prmtop file.
130#[derive(Debug, Clone, PartialEq)]
131pub struct PrmtopAngle {
132    /// Index of atom i (0-based).
133    pub atom_i: usize,
134    /// Index of the central atom j (0-based).
135    pub atom_j: usize,
136    /// Index of atom k (0-based).
137    pub atom_k: usize,
138    /// Harmonic force constant k_θ (kcal mol⁻¹ rad⁻²).
139    pub force_constant: f64,
140    /// Equilibrium angle θ₀ (radians).
141    pub eq_angle: f64,
142}
143
144impl PrmtopAngle {
145    /// Create a new [`PrmtopAngle`].
146    pub fn new(
147        atom_i: usize,
148        atom_j: usize,
149        atom_k: usize,
150        force_constant: f64,
151        eq_angle: f64,
152    ) -> Self {
153        PrmtopAngle {
154            atom_i,
155            atom_j,
156            atom_k,
157            force_constant,
158            eq_angle,
159        }
160    }
161
162    /// Compute the harmonic bending energy for a given angle.
163    ///
164    /// `E = k * (θ - θ0)^2`
165    pub fn energy(&self, theta: f64) -> f64 {
166        self.force_constant * (theta - self.eq_angle).powi(2)
167    }
168}
169
170// ──────────────────────────────────────────────────────────────────────────────
171// PrmtopFile
172// ──────────────────────────────────────────────────────────────────────────────
173
174/// AMBER topology file (`prmtop` / `parm7`) representation.
175///
176/// Only the most commonly used sections are modelled here; raw section data
177/// for every other `%FLAG` is preserved in `extra_sections`.
178#[derive(Debug, Clone, Default)]
179pub struct PrmtopFile {
180    /// Version string from the `%VERSION` line.
181    pub version: String,
182    /// All atoms in topology order.
183    pub atoms: Vec<PrmtopAtom>,
184    /// All covalent bonds.
185    pub bonds: Vec<PrmtopBond>,
186    /// All valence angles.
187    pub angles: Vec<PrmtopAngle>,
188    /// Raw text for unrecognised `%FLAG` sections.
189    pub extra_sections: HashMap<String, Vec<String>>,
190}
191
192impl PrmtopFile {
193    /// Create an empty [`PrmtopFile`].
194    pub fn new() -> Self {
195        PrmtopFile::default()
196    }
197
198    /// Write the topology to any [`Write`] sink in a simplified prmtop-style
199    /// text format.
200    ///
201    /// The output is intentionally human-readable rather than byte-perfect
202    /// AMBER binary prmtop; it is suitable for round-trip testing.
203    pub fn write<W: Write>(&self, writer: &mut W) -> Result<(), AmberIoError> {
204        let mut bw = BufWriter::new(writer);
205        writeln!(bw, "%VERSION  {}", self.version)
206            .map_err(|e| AmberIoError::IoError(e.to_string()))?;
207        writeln!(bw, "%FLAG ATOM_NAME").map_err(|e| AmberIoError::IoError(e.to_string()))?;
208        writeln!(bw, "%FORMAT(20a4)").map_err(|e| AmberIoError::IoError(e.to_string()))?;
209        for (i, atom) in self.atoms.iter().enumerate() {
210            write!(bw, "{:<4}", &atom.name).map_err(|e| AmberIoError::IoError(e.to_string()))?;
211            if (i + 1) % 20 == 0 {
212                writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
213            }
214        }
215        if !self.atoms.len().is_multiple_of(20) {
216            writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
217        }
218
219        writeln!(bw, "%FLAG CHARGE").map_err(|e| AmberIoError::IoError(e.to_string()))?;
220        writeln!(bw, "%FORMAT(5E16.8)").map_err(|e| AmberIoError::IoError(e.to_string()))?;
221        for (i, atom) in self.atoms.iter().enumerate() {
222            write!(bw, "{:16.8E}", atom.charge)
223                .map_err(|e| AmberIoError::IoError(e.to_string()))?;
224            if (i + 1) % 5 == 0 {
225                writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
226            }
227        }
228        if !self.atoms.len().is_multiple_of(5) {
229            writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
230        }
231
232        writeln!(bw, "%FLAG MASS").map_err(|e| AmberIoError::IoError(e.to_string()))?;
233        writeln!(bw, "%FORMAT(5E16.8)").map_err(|e| AmberIoError::IoError(e.to_string()))?;
234        for (i, atom) in self.atoms.iter().enumerate() {
235            write!(bw, "{:16.8E}", atom.mass).map_err(|e| AmberIoError::IoError(e.to_string()))?;
236            if (i + 1) % 5 == 0 {
237                writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
238            }
239        }
240        if !self.atoms.len().is_multiple_of(5) {
241            writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
242        }
243
244        writeln!(bw, "%FLAG ATOM_TYPE_INDEX").map_err(|e| AmberIoError::IoError(e.to_string()))?;
245        writeln!(bw, "%FORMAT(20a4)").map_err(|e| AmberIoError::IoError(e.to_string()))?;
246        for (i, atom) in self.atoms.iter().enumerate() {
247            write!(bw, "{:<4}", &atom.atom_type)
248                .map_err(|e| AmberIoError::IoError(e.to_string()))?;
249            if (i + 1) % 20 == 0 {
250                writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
251            }
252        }
253        if !self.atoms.len().is_multiple_of(20) {
254            writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
255        }
256
257        // Bonds
258        writeln!(bw, "%FLAG BOND_FORCE_CONSTANT")
259            .map_err(|e| AmberIoError::IoError(e.to_string()))?;
260        writeln!(bw, "%FORMAT(5E16.8)").map_err(|e| AmberIoError::IoError(e.to_string()))?;
261        for (i, bond) in self.bonds.iter().enumerate() {
262            write!(bw, "{:16.8E}", bond.force_constant)
263                .map_err(|e| AmberIoError::IoError(e.to_string()))?;
264            if (i + 1) % 5 == 0 {
265                writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
266            }
267        }
268        if !self.bonds.is_empty() && !self.bonds.len().is_multiple_of(5) {
269            writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
270        }
271
272        writeln!(bw, "%FLAG BOND_EQUIL_VALUE").map_err(|e| AmberIoError::IoError(e.to_string()))?;
273        writeln!(bw, "%FORMAT(5E16.8)").map_err(|e| AmberIoError::IoError(e.to_string()))?;
274        for (i, bond) in self.bonds.iter().enumerate() {
275            write!(bw, "{:16.8E}", bond.eq_length)
276                .map_err(|e| AmberIoError::IoError(e.to_string()))?;
277            if (i + 1) % 5 == 0 {
278                writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
279            }
280        }
281        if !self.bonds.is_empty() && !self.bonds.len().is_multiple_of(5) {
282            writeln!(bw).map_err(|e| AmberIoError::IoError(e.to_string()))?;
283        }
284
285        writeln!(bw, "%END").map_err(|e| AmberIoError::IoError(e.to_string()))?;
286        Ok(())
287    }
288
289    /// Parse a simplified prmtop-style text produced by [`PrmtopFile::write`].
290    pub fn read<R: Read>(reader: R) -> Result<Self, AmberIoError> {
291        let br = BufReader::new(reader);
292        let mut file = PrmtopFile::new();
293        let lines: Vec<String> = br
294            .lines()
295            .map(|l| l.map_err(|e| AmberIoError::IoError(e.to_string())))
296            .collect::<Result<_, _>>()?;
297
298        let mut i = 0usize;
299        let mut names: Vec<String> = Vec::new();
300        let mut charges: Vec<f64> = Vec::new();
301        let mut masses: Vec<f64> = Vec::new();
302        let mut types: Vec<String> = Vec::new();
303        let mut bond_kf: Vec<f64> = Vec::new();
304        let mut bond_eq: Vec<f64> = Vec::new();
305
306        while i < lines.len() {
307            let line = lines[i].trim();
308            if line.starts_with("%VERSION") {
309                file.version = line.trim_start_matches("%VERSION").trim().to_string();
310            } else if line == "%FLAG ATOM_NAME" {
311                i += 1; // skip FORMAT
312                i += 1;
313                while i < lines.len() && !lines[i].starts_with('%') {
314                    let row = &lines[i];
315                    let mut pos = 0usize;
316                    while pos + 4 <= row.len() {
317                        names.push(row[pos..pos + 4].trim().to_string());
318                        pos += 4;
319                    }
320                    i += 1;
321                }
322                continue;
323            } else if line == "%FLAG CHARGE" {
324                i += 1;
325                i += 1;
326                while i < lines.len() && !lines[i].starts_with('%') {
327                    for tok in lines[i].split_whitespace() {
328                        charges.push(
329                            tok.parse::<f64>()
330                                .map_err(|e| AmberIoError::ParseError(e.to_string()))?,
331                        );
332                    }
333                    i += 1;
334                }
335                continue;
336            } else if line == "%FLAG MASS" {
337                i += 1;
338                i += 1;
339                while i < lines.len() && !lines[i].starts_with('%') {
340                    for tok in lines[i].split_whitespace() {
341                        masses.push(
342                            tok.parse::<f64>()
343                                .map_err(|e| AmberIoError::ParseError(e.to_string()))?,
344                        );
345                    }
346                    i += 1;
347                }
348                continue;
349            } else if line == "%FLAG ATOM_TYPE_INDEX" {
350                i += 1;
351                i += 1;
352                while i < lines.len() && !lines[i].starts_with('%') {
353                    let row = &lines[i];
354                    let mut pos = 0usize;
355                    while pos + 4 <= row.len() {
356                        types.push(row[pos..pos + 4].trim().to_string());
357                        pos += 4;
358                    }
359                    i += 1;
360                }
361                continue;
362            } else if line == "%FLAG BOND_FORCE_CONSTANT" {
363                i += 1;
364                i += 1;
365                while i < lines.len() && !lines[i].starts_with('%') {
366                    for tok in lines[i].split_whitespace() {
367                        bond_kf.push(
368                            tok.parse::<f64>()
369                                .map_err(|e| AmberIoError::ParseError(e.to_string()))?,
370                        );
371                    }
372                    i += 1;
373                }
374                continue;
375            } else if line == "%FLAG BOND_EQUIL_VALUE" {
376                i += 1;
377                i += 1;
378                while i < lines.len() && !lines[i].starts_with('%') {
379                    for tok in lines[i].split_whitespace() {
380                        bond_eq.push(
381                            tok.parse::<f64>()
382                                .map_err(|e| AmberIoError::ParseError(e.to_string()))?,
383                        );
384                    }
385                    i += 1;
386                }
387                continue;
388            }
389            i += 1;
390        }
391
392        let n = names.len();
393        for idx in 0..n {
394            file.atoms.push(PrmtopAtom {
395                name: names.get(idx).cloned().unwrap_or_default(),
396                charge: charges.get(idx).copied().unwrap_or(0.0),
397                mass: masses.get(idx).copied().unwrap_or(0.0),
398                atom_type: types.get(idx).cloned().unwrap_or_default(),
399            });
400        }
401
402        for idx in 0..bond_kf.len() {
403            file.bonds.push(PrmtopBond {
404                atom_i: idx * 2,
405                atom_j: idx * 2 + 1,
406                force_constant: bond_kf[idx],
407                eq_length: bond_eq.get(idx).copied().unwrap_or(0.0),
408            });
409        }
410
411        Ok(file)
412    }
413
414    /// Return the number of atoms.
415    pub fn natom(&self) -> usize {
416        self.atoms.len()
417    }
418}
419
420// ──────────────────────────────────────────────────────────────────────────────
421// Rst7File
422// ──────────────────────────────────────────────────────────────────────────────
423
424/// AMBER restart / initial-coordinate file (`rst7` / `inpcrd`).
425///
426/// ASCII format:
427/// ```text
428/// title
429/// natom [time]
430/// x1 y1 z1 x2 y2 z2 ...   (10 per line, 12.7 format)
431/// [velocities – same format]
432/// [box: a b c alpha beta gamma]
433/// ```
434#[derive(Debug, Clone, Default)]
435pub struct Rst7File {
436    /// Title line.
437    pub title: String,
438    /// Simulation time (ps) from the header, if present.
439    pub time: Option<f64>,
440    /// Cartesian coordinates (Å) in x,y,z order, length = 3 × NATOM.
441    pub coordinates: Vec<f64>,
442    /// Velocities (Å ps⁻¹) in x,y,z order, same length as coordinates.
443    pub velocities: Vec<f64>,
444    /// Periodic box dimensions: `[a, b, c, alpha, beta, gamma]`.
445    pub box_dimensions: Option<[f64; 6]>,
446}
447
448impl Rst7File {
449    /// Create an empty [`Rst7File`].
450    pub fn new() -> Self {
451        Rst7File::default()
452    }
453
454    /// Return the number of atoms.
455    pub fn natom(&self) -> usize {
456        self.coordinates.len() / 3
457    }
458
459    /// Write the restart file to any [`Write`] sink.
460    ///
461    /// Coordinates are written in 12.7 fixed-point format, 6 values per line
462    /// (= 2 atoms per line, matching the AMBER specification).
463    pub fn write<W: Write>(&self, writer: &mut W) -> Result<(), AmberIoError> {
464        let mut bw = BufWriter::new(writer);
465        writeln!(bw, "{}", self.title).map_err(|e| AmberIoError::IoError(e.to_string()))?;
466        if let Some(t) = self.time {
467            writeln!(bw, "{:6}{:15.7e}", self.natom(), t)
468                .map_err(|e| AmberIoError::IoError(e.to_string()))?;
469        } else {
470            writeln!(bw, "{:6}", self.natom()).map_err(|e| AmberIoError::IoError(e.to_string()))?;
471        }
472
473        write_f64_block(&mut bw, &self.coordinates, 6)?;
474
475        if !self.velocities.is_empty() {
476            write_f64_block(&mut bw, &self.velocities, 6)?;
477        }
478
479        if let Some(b) = self.box_dimensions {
480            writeln!(
481                bw,
482                "{:12.7}{:12.7}{:12.7}{:12.7}{:12.7}{:12.7}",
483                b[0], b[1], b[2], b[3], b[4], b[5]
484            )
485            .map_err(|e| AmberIoError::IoError(e.to_string()))?;
486        }
487
488        Ok(())
489    }
490
491    /// Parse an AMBER restart file from any [`Read`] source.
492    pub fn read<R: Read>(reader: R) -> Result<Self, AmberIoError> {
493        let br = BufReader::new(reader);
494        let mut lines = br.lines();
495        let mut rst = Rst7File::new();
496
497        // title
498        rst.title = lines
499            .next()
500            .ok_or(AmberIoError::UnexpectedEof)?
501            .map_err(|e| AmberIoError::IoError(e.to_string()))?;
502
503        // natom [time]
504        let header = lines
505            .next()
506            .ok_or(AmberIoError::UnexpectedEof)?
507            .map_err(|e| AmberIoError::IoError(e.to_string()))?;
508        let mut hparts = header.split_whitespace();
509        let natom: usize = hparts
510            .next()
511            .ok_or(AmberIoError::ParseError("missing natom".into()))?
512            .parse()
513            .map_err(|e: std::num::ParseIntError| AmberIoError::ParseError(e.to_string()))?;
514        if let Some(t) = hparts.next() {
515            rst.time = Some(
516                t.parse::<f64>()
517                    .map_err(|e| AmberIoError::ParseError(e.to_string()))?,
518            );
519        }
520
521        let n_coord = natom * 3;
522        let mut all_values: Vec<f64> = Vec::new();
523
524        for line in lines {
525            let l = line.map_err(|e| AmberIoError::IoError(e.to_string()))?;
526            let trimmed = l.trim();
527            if trimmed.is_empty() {
528                continue;
529            }
530            // Check if this looks like a box line (6 values, reasonable floats)
531            let parts: Vec<&str> = trimmed.split_whitespace().collect();
532            // If we already have all coords + all vels, remainder is box
533            if all_values.len() >= n_coord * 2 && parts.len() == 6 {
534                let mut box_vals = [0f64; 6];
535                for (i, p) in parts.iter().enumerate() {
536                    box_vals[i] = p
537                        .parse::<f64>()
538                        .map_err(|e| AmberIoError::ParseError(e.to_string()))?;
539                }
540                rst.box_dimensions = Some(box_vals);
541                break;
542            }
543            for p in parts {
544                all_values.push(
545                    p.parse::<f64>()
546                        .map_err(|e| AmberIoError::ParseError(e.to_string()))?,
547                );
548            }
549        }
550
551        rst.coordinates = all_values[..n_coord.min(all_values.len())].to_vec();
552        if all_values.len() > n_coord {
553            rst.velocities = all_values[n_coord..].to_vec();
554        }
555
556        Ok(rst)
557    }
558}
559
560/// Write a slice of `f64` values to a writer, `per_line` values per line,
561/// using 12.7 fixed-point format.
562fn write_f64_block<W: Write>(
563    writer: &mut BufWriter<W>,
564    values: &[f64],
565    per_line: usize,
566) -> Result<(), AmberIoError> {
567    for (i, v) in values.iter().enumerate() {
568        write!(writer, "{:12.7}", v).map_err(|e| AmberIoError::IoError(e.to_string()))?;
569        if (i + 1) % per_line == 0 {
570            writeln!(writer).map_err(|e| AmberIoError::IoError(e.to_string()))?;
571        }
572    }
573    if !values.is_empty() && !values.len().is_multiple_of(per_line) {
574        writeln!(writer).map_err(|e| AmberIoError::IoError(e.to_string()))?;
575    }
576    Ok(())
577}
578
579// ──────────────────────────────────────────────────────────────────────────────
580// MdcrdReader
581// ──────────────────────────────────────────────────────────────────────────────
582
583/// Reader for AMBER ASCII trajectory files (`mdcrd`).
584///
585/// Format:
586/// ```text
587/// title
588/// x1 y1 z1 x2 ... (10 values per line, 8.3 format)
589/// [box a b c] (optional, if IFBOX > 0)
590/// ```
591pub struct MdcrdReader<R: BufRead> {
592    reader: R,
593    /// Number of atoms in each frame.
594    pub natom: usize,
595    /// Whether each frame includes a periodic box record.
596    pub has_box: bool,
597    title_consumed: bool,
598}
599
600impl<R: BufRead> MdcrdReader<R> {
601    /// Create a new [`MdcrdReader`].
602    ///
603    /// - `natom`   – number of atoms per frame
604    /// - `has_box` – set to `true` if the trajectory includes box records
605    pub fn new(reader: R, natom: usize, has_box: bool) -> Self {
606        MdcrdReader {
607            reader,
608            natom,
609            has_box,
610            title_consumed: false,
611        }
612    }
613
614    /// Consume the title line.  Called automatically by the first `read_frame`.
615    fn consume_title(&mut self) -> Result<String, AmberIoError> {
616        let mut title = String::new();
617        self.reader
618            .read_line(&mut title)
619            .map_err(|e| AmberIoError::IoError(e.to_string()))?;
620        self.title_consumed = true;
621        Ok(title.trim_end().to_string())
622    }
623
624    /// Read the next frame and return coordinates (and optionally box).
625    ///
626    /// Returns `Ok(None)` at end of file.
627    pub fn read_frame(&mut self) -> Result<Option<MdcrdFrame>, AmberIoError> {
628        if !self.title_consumed {
629            self.consume_title()?;
630        }
631
632        let n_coords = self.natom * 3;
633        let mut values: Vec<f64> = Vec::with_capacity(n_coords);
634
635        loop {
636            if values.len() >= n_coords {
637                break;
638            }
639            let mut line = String::new();
640            let bytes = self
641                .reader
642                .read_line(&mut line)
643                .map_err(|e| AmberIoError::IoError(e.to_string()))?;
644            if bytes == 0 {
645                if values.is_empty() {
646                    return Ok(None);
647                }
648                break;
649            }
650            for tok in line.split_whitespace() {
651                values.push(
652                    tok.parse::<f64>()
653                        .map_err(|e| AmberIoError::ParseError(e.to_string()))?,
654                );
655                if values.len() >= n_coords {
656                    break;
657                }
658            }
659        }
660
661        if values.is_empty() {
662            return Ok(None);
663        }
664
665        let box_dims = if self.has_box {
666            let mut line = String::new();
667            self.reader
668                .read_line(&mut line)
669                .map_err(|e| AmberIoError::IoError(e.to_string()))?;
670            let parts: Vec<f64> = line
671                .split_whitespace()
672                .filter_map(|t| t.parse::<f64>().ok())
673                .collect();
674            if parts.len() >= 3 {
675                Some([parts[0], parts[1], parts[2]])
676            } else {
677                None
678            }
679        } else {
680            None
681        };
682
683        Ok(Some(MdcrdFrame {
684            coordinates: values,
685            box_dims,
686        }))
687    }
688}
689
690/// A single frame from an AMBER trajectory file.
691#[derive(Debug, Clone)]
692pub struct MdcrdFrame {
693    /// Cartesian coordinates (Å), x,y,z interleaved, length = 3 × NATOM.
694    pub coordinates: Vec<f64>,
695    /// Periodic box `[a, b, c]` (Å), if present.
696    pub box_dims: Option<[f64; 3]>,
697}
698
699impl MdcrdFrame {
700    /// Return the number of atoms in this frame.
701    pub fn natom(&self) -> usize {
702        self.coordinates.len() / 3
703    }
704
705    /// Return atom position as `(x, y, z)` tuple (0-based index).
706    pub fn atom_pos(&self, idx: usize) -> Option<(f64, f64, f64)> {
707        let base = idx * 3;
708        if base + 2 < self.coordinates.len() {
709            Some((
710                self.coordinates[base],
711                self.coordinates[base + 1],
712                self.coordinates[base + 2],
713            ))
714        } else {
715            None
716        }
717    }
718}
719
720// ──────────────────────────────────────────────────────────────────────────────
721// MdinFile
722// ──────────────────────────────────────────────────────────────────────────────
723
724/// AMBER MD input (`mdin`) file representation.
725///
726/// Stores key-value pairs from the `&cntrl` (and optionally `&ewald`, `&pb`,
727/// etc.) namelists as plain strings.
728#[derive(Debug, Clone, Default)]
729pub struct MdinFile {
730    /// Title comment line.
731    pub title: String,
732    /// Parsed key/value entries from `&cntrl`.
733    pub cntrl: HashMap<String, String>,
734    /// Parsed key/value entries from `&ewald` (empty if absent).
735    pub ewald: HashMap<String, String>,
736}
737
738impl MdinFile {
739    /// Create an empty [`MdinFile`].
740    pub fn new() -> Self {
741        MdinFile::default()
742    }
743
744    /// Look up a `&cntrl` integer parameter with a default fallback.
745    pub fn cntrl_int(&self, key: &str, default: i64) -> i64 {
746        self.cntrl
747            .get(key)
748            .and_then(|v| v.trim().parse().ok())
749            .unwrap_or(default)
750    }
751
752    /// Look up a `&cntrl` float parameter with a default fallback.
753    pub fn cntrl_float(&self, key: &str, default: f64) -> f64 {
754        self.cntrl
755            .get(key)
756            .and_then(|v| v.trim().parse().ok())
757            .unwrap_or(default)
758    }
759
760    /// Write the `mdin` file to any [`Write`] sink.
761    pub fn write<W: Write>(&self, writer: &mut W) -> Result<(), AmberIoError> {
762        let mut bw = BufWriter::new(writer);
763        writeln!(bw, "{}", self.title).map_err(|e| AmberIoError::IoError(e.to_string()))?;
764        writeln!(bw, " &cntrl").map_err(|e| AmberIoError::IoError(e.to_string()))?;
765        let mut keys: Vec<&String> = self.cntrl.keys().collect();
766        keys.sort();
767        for k in &keys {
768            writeln!(bw, "  {} = {},", k, self.cntrl[*k])
769                .map_err(|e| AmberIoError::IoError(e.to_string()))?;
770        }
771        writeln!(bw, " /").map_err(|e| AmberIoError::IoError(e.to_string()))?;
772        Ok(())
773    }
774
775    /// Parse an AMBER `mdin` file.
776    pub fn read<R: Read>(reader: R) -> Result<Self, AmberIoError> {
777        let br = BufReader::new(reader);
778        let mut file = MdinFile::new();
779        let mut lines = br.lines().peekable();
780
781        // Title
782        file.title = lines
783            .next()
784            .ok_or(AmberIoError::UnexpectedEof)?
785            .map_err(|e| AmberIoError::IoError(e.to_string()))?;
786
787        let mut current_nl: Option<&str> = None;
788        for line_result in lines {
789            let line = line_result.map_err(|e| AmberIoError::IoError(e.to_string()))?;
790            let trimmed = line.trim();
791
792            if trimmed.starts_with('&') {
793                let nl_name = trimmed.trim_start_matches('&').to_lowercase();
794                current_nl = match nl_name.as_str() {
795                    "cntrl" => Some("cntrl"),
796                    "ewald" => Some("ewald"),
797                    _ => None,
798                };
799                continue;
800            }
801
802            if trimmed == "/" || trimmed == "&end" || trimmed.to_lowercase() == "end" {
803                current_nl = None;
804                continue;
805            }
806
807            if let Some(nl) = current_nl {
808                // Each line may contain multiple key=value, pairs
809                for entry in trimmed.split(',') {
810                    let entry = entry.trim();
811                    if entry.is_empty() {
812                        continue;
813                    }
814                    if let Some(pos) = entry.find('=') {
815                        let key = entry[..pos].trim().to_lowercase();
816                        let val = entry[pos + 1..].trim().to_string();
817                        if !key.is_empty() {
818                            match nl {
819                                "cntrl" => {
820                                    file.cntrl.insert(key, val);
821                                }
822                                "ewald" => {
823                                    file.ewald.insert(key, val);
824                                }
825                                _ => {}
826                            }
827                        }
828                    }
829                }
830            }
831        }
832
833        Ok(file)
834    }
835}
836
837// ──────────────────────────────────────────────────────────────────────────────
838// MdinfoParser
839// ──────────────────────────────────────────────────────────────────────────────
840
841/// Snapshot of energy/thermodynamic data from an AMBER `mdinfo` file.
842#[derive(Debug, Clone, Default)]
843pub struct MdinfoSnapshot {
844    /// MD step number.
845    pub nstep: i64,
846    /// Simulation time (ps).
847    pub time: f64,
848    /// Instantaneous temperature (K).
849    pub temp: f64,
850    /// Pressure (bar).
851    pub press: f64,
852    /// Total energy (kcal mol⁻¹).
853    pub etot: f64,
854    /// Kinetic energy (kcal mol⁻¹).
855    pub ektot: f64,
856    /// Potential energy (kcal mol⁻¹).
857    pub eptot: f64,
858}
859
860/// Parser for AMBER `mdinfo` runtime output files.
861pub struct MdinfoParser;
862
863impl MdinfoParser {
864    /// Parse the first energy snapshot found in the mdinfo text.
865    ///
866    /// Handles lines of the form:
867    /// ```text
868    ///  NSTEP =      500   TIME(PS) =       1.000
869    ///  TEMP(K) =   298.15  PRESS =     0.00
870    ///  ETOT   =   -5000.0  EKTOT  =    1000.0  EPTOT  =  -6000.0
871    /// ```
872    pub fn parse<R: Read>(reader: R) -> Result<MdinfoSnapshot, AmberIoError> {
873        let br = BufReader::new(reader);
874        let mut snap = MdinfoSnapshot::default();
875
876        for line_result in br.lines() {
877            let line = line_result.map_err(|e| AmberIoError::IoError(e.to_string()))?;
878            // Tokenise: split on '=' boundaries.
879            // Strategy: find all '=' positions; everything between the previous
880            // '=' value (a number token) and the next '=' is the key.
881            let chars: Vec<char> = line.chars().collect();
882            let mut i = 0usize;
883            while i < chars.len() {
884                // skip whitespace
885                while i < chars.len() && chars[i].is_whitespace() {
886                    i += 1;
887                }
888                // collect a key token up to '='
889                let key_start = i;
890                while i < chars.len() && chars[i] != '=' {
891                    i += 1;
892                }
893                if i >= chars.len() {
894                    break;
895                }
896                let key: String = chars[key_start..i]
897                    .iter()
898                    .collect::<String>()
899                    .trim()
900                    .to_uppercase();
901                i += 1; // skip '='
902                // collect value: skip whitespace then read non-whitespace
903                while i < chars.len() && chars[i].is_whitespace() {
904                    i += 1;
905                }
906                let val_start = i;
907                while i < chars.len() && !chars[i].is_whitespace() {
908                    i += 1;
909                }
910                let val_str: String = chars[val_start..i].iter().collect();
911                let val: f64 = match val_str.parse() {
912                    Ok(v) => v,
913                    Err(_) => continue,
914                };
915                match key.as_str() {
916                    "NSTEP" => snap.nstep = val as i64,
917                    "TIME(PS)" => snap.time = val,
918                    "TEMP(K)" => snap.temp = val,
919                    "PRESS" => snap.press = val,
920                    "ETOT" => snap.etot = val,
921                    "EKTOT" => snap.ektot = val,
922                    "EPTOT" => snap.eptot = val,
923                    _ => {}
924                }
925            }
926        }
927
928        Ok(snap)
929    }
930}
931
932// ──────────────────────────────────────────────────────────────────────────────
933// AmberForceFieldReader
934// ──────────────────────────────────────────────────────────────────────────────
935
936/// A single mass entry from a GAFF / ff14SB parameter file.
937#[derive(Debug, Clone)]
938pub struct FfMass {
939    /// Atom type label.
940    pub atom_type: String,
941    /// Atomic mass (u).
942    pub mass: f64,
943    /// Polarisability (ų), optional.
944    pub polarizability: f64,
945}
946
947/// A bond parameter entry from a GAFF / ff14SB parameter file.
948#[derive(Debug, Clone)]
949pub struct FfBond {
950    /// First atom type.
951    pub type_i: String,
952    /// Second atom type.
953    pub type_j: String,
954    /// Force constant k (kcal mol⁻¹ Å⁻²).
955    pub force_constant: f64,
956    /// Equilibrium bond length (Å).
957    pub eq_length: f64,
958}
959
960/// An angle parameter entry.
961#[derive(Debug, Clone)]
962pub struct FfAngle {
963    /// Atom types i-j-k.
964    pub type_i: String,
965    /// Central atom type.
966    pub type_j: String,
967    /// Third atom type.
968    pub type_k: String,
969    /// Force constant (kcal mol⁻¹ rad⁻²).
970    pub force_constant: f64,
971    /// Equilibrium angle (degrees).
972    pub eq_angle_deg: f64,
973}
974
975/// A dihedral parameter entry.
976#[derive(Debug, Clone)]
977pub struct FfDihedral {
978    /// Atom types i-j-k-l.
979    pub type_i: String,
980    /// Second atom type.
981    pub type_j: String,
982    /// Third atom type.
983    pub type_k: String,
984    /// Fourth atom type.
985    pub type_l: String,
986    /// Periodicity N.
987    pub periodicity: i32,
988    /// Barrier height V/2 (kcal mol⁻¹).
989    pub barrier: f64,
990    /// Phase angle (degrees).
991    pub phase_deg: f64,
992}
993
994/// Reader for GAFF / ff14SB force-field parameter files.
995#[derive(Debug, Clone, Default)]
996pub struct AmberForceFieldReader {
997    /// Mass parameters.
998    pub masses: Vec<FfMass>,
999    /// Bond parameters.
1000    pub bonds: Vec<FfBond>,
1001    /// Angle parameters.
1002    pub angles: Vec<FfAngle>,
1003    /// Dihedral parameters.
1004    pub dihedrals: Vec<FfDihedral>,
1005}
1006
1007impl AmberForceFieldReader {
1008    /// Create an empty reader.
1009    pub fn new() -> Self {
1010        AmberForceFieldReader::default()
1011    }
1012
1013    /// Parse a GAFF/ff14SB parameter file.
1014    pub fn read<R: Read>(&mut self, reader: R) -> Result<(), AmberIoError> {
1015        let br = BufReader::new(reader);
1016        let lines: Vec<String> = br
1017            .lines()
1018            .map(|l| l.map_err(|e| AmberIoError::IoError(e.to_string())))
1019            .collect::<Result<_, _>>()?;
1020
1021        #[derive(PartialEq)]
1022        enum Section {
1023            None,
1024            Mass,
1025            Bond,
1026            Angle,
1027            Dihe,
1028        }
1029        let mut section = Section::None;
1030
1031        for line in &lines {
1032            let trimmed = line.trim();
1033            if trimmed.is_empty() {
1034                section = Section::None;
1035                continue;
1036            }
1037
1038            let upper = trimmed.to_uppercase();
1039            if upper.starts_with("MASS") {
1040                section = Section::Mass;
1041                continue;
1042            } else if upper.starts_with("BOND") {
1043                section = Section::Bond;
1044                continue;
1045            } else if upper.starts_with("ANGLE") || upper.starts_with("ANGL") {
1046                section = Section::Angle;
1047                continue;
1048            } else if upper.starts_with("DIHE") {
1049                section = Section::Dihe;
1050                continue;
1051            } else if upper.starts_with("NONB") || upper.starts_with("IMPR") {
1052                section = Section::None;
1053                continue;
1054            }
1055
1056            match section {
1057                Section::Mass => {
1058                    // Format: AT  mass  polarizability  !comment
1059                    let clean = trimmed.split('!').next().unwrap_or("").trim();
1060                    let parts: Vec<&str> = clean.split_whitespace().collect();
1061                    if parts.len() >= 2 {
1062                        let mass = parts[1].parse::<f64>().unwrap_or(0.0);
1063                        let pol = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0.0);
1064                        self.masses.push(FfMass {
1065                            atom_type: parts[0].to_string(),
1066                            mass,
1067                            polarizability: pol,
1068                        });
1069                    }
1070                }
1071                Section::Bond => {
1072                    // Format: I1-I2  kf  r0  !comment
1073                    let clean = trimmed.split('!').next().unwrap_or("").trim();
1074                    let parts: Vec<&str> = clean.split_whitespace().collect();
1075                    if parts.len() >= 3 {
1076                        let types: Vec<&str> = parts[0].split('-').collect();
1077                        if types.len() >= 2 {
1078                            let kf = parts[1].parse::<f64>().unwrap_or(0.0);
1079                            let r0 = parts[2].parse::<f64>().unwrap_or(0.0);
1080                            self.bonds.push(FfBond {
1081                                type_i: types[0].trim().to_string(),
1082                                type_j: types[1].trim().to_string(),
1083                                force_constant: kf,
1084                                eq_length: r0,
1085                            });
1086                        }
1087                    }
1088                }
1089                Section::Angle => {
1090                    // Format: I1-I2-I3  kf  theta0  !comment
1091                    let clean = trimmed.split('!').next().unwrap_or("").trim();
1092                    let parts: Vec<&str> = clean.split_whitespace().collect();
1093                    if parts.len() >= 3 {
1094                        let types: Vec<&str> = parts[0].split('-').collect();
1095                        if types.len() >= 3 {
1096                            let kf = parts[1].parse::<f64>().unwrap_or(0.0);
1097                            let th0 = parts[2].parse::<f64>().unwrap_or(0.0);
1098                            self.angles.push(FfAngle {
1099                                type_i: types[0].trim().to_string(),
1100                                type_j: types[1].trim().to_string(),
1101                                type_k: types[2].trim().to_string(),
1102                                force_constant: kf,
1103                                eq_angle_deg: th0,
1104                            });
1105                        }
1106                    }
1107                }
1108                Section::Dihe => {
1109                    // AMBER dihedral format: "I1-I2-I3-I4  idivf  pk  phase  pn  !comment"
1110                    // Atom types are 2 chars each joined by '-'; wildcards use spaces, e.g.:
1111                    //   "c3-hc-n3-c3" or "X -c3-n3-X "
1112                    // Detect the type/number boundary: first whitespace run followed by a
1113                    // token that parses as f64 (the idivf integer).
1114                    let clean = trimmed.split('!').next().unwrap_or("").trim();
1115                    let mut split_at = clean.len();
1116                    let cbytes = clean.as_bytes();
1117                    let mut idx = 0usize;
1118                    while idx < cbytes.len() {
1119                        if cbytes[idx].is_ascii_whitespace() {
1120                            let ws_start = idx;
1121                            while idx < cbytes.len() && cbytes[idx].is_ascii_whitespace() {
1122                                idx += 1;
1123                            }
1124                            if idx < cbytes.len() {
1125                                let rest = &clean[idx..];
1126                                if rest
1127                                    .split_whitespace()
1128                                    .next()
1129                                    .map(|t| t.parse::<f64>().is_ok())
1130                                    .unwrap_or(false)
1131                                {
1132                                    split_at = ws_start;
1133                                    break;
1134                                }
1135                            }
1136                        } else {
1137                            idx += 1;
1138                        }
1139                    }
1140                    let type_field = clean[..split_at].trim();
1141                    let remainder = clean[split_at..].trim();
1142                    // Parse atom types: spaces are part of wildcard names → replace with '-'
1143                    let normalised = type_field.replace(' ', "-");
1144                    let types: Vec<&str> =
1145                        normalised.split('-').filter(|s| !s.is_empty()).collect();
1146                    let nums: Vec<&str> = remainder.split_whitespace().collect();
1147                    if types.len() >= 4 && nums.len() >= 4 {
1148                        let barrier = nums[1].parse::<f64>().unwrap_or(0.0);
1149                        let phase = nums[2].parse::<f64>().unwrap_or(0.0);
1150                        let pn = nums[3].parse::<f64>().unwrap_or(1.0).abs() as i32;
1151                        self.dihedrals.push(FfDihedral {
1152                            type_i: types[0].trim().to_string(),
1153                            type_j: types[1].trim().to_string(),
1154                            type_k: types[2].trim().to_string(),
1155                            type_l: types[3].trim().to_string(),
1156                            periodicity: pn,
1157                            barrier,
1158                            phase_deg: phase,
1159                        });
1160                    }
1161                }
1162                Section::None => {}
1163            }
1164        }
1165
1166        Ok(())
1167    }
1168}
1169
1170// ──────────────────────────────────────────────────────────────────────────────
1171// Utility: AMBER charge unit conversion
1172// ──────────────────────────────────────────────────────────────────────────────
1173
1174/// AMBER internal charge unit factor.
1175///
1176/// AMBER stores partial charges as `q * 18.2223` where `q` is in elementary
1177/// charges.  Divide by this constant to recover the charge in elementary
1178/// charges (e).
1179pub const AMBER_CHARGE_UNIT: f64 = 18.2223;
1180
1181/// Convert an AMBER internal charge value to elementary charges.
1182pub fn amber_charge_to_e(amber_q: f64) -> f64 {
1183    amber_q / AMBER_CHARGE_UNIT
1184}
1185
1186/// Convert an elementary-charge value to AMBER internal units.
1187pub fn e_to_amber_charge(q_e: f64) -> f64 {
1188    q_e * AMBER_CHARGE_UNIT
1189}
1190
1191// ──────────────────────────────────────────────────────────────────────────────
1192// Tests
1193// ──────────────────────────────────────────────────────────────────────────────
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::*;
1198    use std::io::Cursor;
1199
1200    // ── PrmtopAtom ────────────────────────────────────────────────────────────
1201
1202    #[test]
1203    fn test_prmtop_atom_new() {
1204        let a = PrmtopAtom::new("CA", 0.0, 12.011, "CT");
1205        assert_eq!(a.name, "CA");
1206        assert_eq!(a.mass, 12.011);
1207    }
1208
1209    #[test]
1210    fn test_prmtop_atom_charge_si() {
1211        let a = PrmtopAtom::new("N", 18.2223, 14.007, "N");
1212        let q = a.charge_si();
1213        assert!((q - 1.0).abs() < 1e-6, "expected ~1.0, got {q}");
1214    }
1215
1216    #[test]
1217    fn test_prmtop_atom_charge_negative() {
1218        let a = PrmtopAtom::new("O", -0.8 * 18.2223, 15.999, "OS");
1219        assert!(a.charge_si() < 0.0);
1220    }
1221
1222    // ── PrmtopBond ────────────────────────────────────────────────────────────
1223
1224    #[test]
1225    fn test_prmtop_bond_new() {
1226        let b = PrmtopBond::new(0, 1, 340.0, 1.526);
1227        assert!(b.force_constant > 0.0);
1228        assert!(b.eq_length > 0.0);
1229    }
1230
1231    #[test]
1232    fn test_prmtop_bond_energy_at_eq() {
1233        let b = PrmtopBond::new(0, 1, 340.0, 1.526);
1234        let e = b.energy(b.eq_length);
1235        assert!(e.abs() < 1e-12, "energy at eq length should be ~0, got {e}");
1236    }
1237
1238    #[test]
1239    fn test_prmtop_bond_energy_positive() {
1240        let b = PrmtopBond::new(0, 1, 340.0, 1.526);
1241        let e = b.energy(1.7);
1242        assert!(e > 0.0);
1243    }
1244
1245    #[test]
1246    fn test_prmtop_bond_energy_symmetric() {
1247        let b = PrmtopBond::new(0, 1, 340.0, 1.526);
1248        let e1 = b.energy(1.526 + 0.1);
1249        let e2 = b.energy(1.526 - 0.1);
1250        assert!((e1 - e2).abs() < 1e-10);
1251    }
1252
1253    // ── PrmtopAngle ───────────────────────────────────────────────────────────
1254
1255    #[test]
1256    fn test_prmtop_angle_new() {
1257        let a = PrmtopAngle::new(0, 1, 2, 50.0, std::f64::consts::PI * 109.5 / 180.0);
1258        assert_eq!(a.atom_j, 1);
1259    }
1260
1261    #[test]
1262    fn test_prmtop_angle_energy_at_eq() {
1263        let theta0 = 109.5_f64.to_radians();
1264        let a = PrmtopAngle::new(0, 1, 2, 50.0, theta0);
1265        let e = a.energy(theta0);
1266        assert!(e.abs() < 1e-12);
1267    }
1268
1269    #[test]
1270    fn test_prmtop_angle_energy_positive_deviation() {
1271        let theta0 = 109.5_f64.to_radians();
1272        let a = PrmtopAngle::new(0, 1, 2, 50.0, theta0);
1273        let e = a.energy(theta0 + 0.2);
1274        assert!(e > 0.0);
1275    }
1276
1277    // ── PrmtopFile round-trip ─────────────────────────────────────────────────
1278
1279    fn make_prmtop() -> PrmtopFile {
1280        let mut f = PrmtopFile::new();
1281        f.version = "V0001.000".to_string();
1282        f.atoms.push(PrmtopAtom::new(
1283            "N",
1284            -0.4157 * AMBER_CHARGE_UNIT,
1285            14.007,
1286            "N3",
1287        ));
1288        f.atoms.push(PrmtopAtom::new(
1289            "H1",
1290            0.2719 * AMBER_CHARGE_UNIT,
1291            1.008,
1292            "H",
1293        ));
1294        f.atoms.push(PrmtopAtom::new(
1295            "CA",
1296            -0.0249 * AMBER_CHARGE_UNIT,
1297            12.011,
1298            "CT",
1299        ));
1300        f.bonds.push(PrmtopBond::new(0, 1, 434.0, 1.01));
1301        f.bonds.push(PrmtopBond::new(1, 2, 317.0, 1.522));
1302        f
1303    }
1304
1305    #[test]
1306    fn test_prmtop_roundtrip_natom() {
1307        let original = make_prmtop();
1308        let mut buf = Vec::new();
1309        original.write(&mut buf).unwrap();
1310        let restored = PrmtopFile::read(Cursor::new(&buf)).unwrap();
1311        assert_eq!(original.natom(), restored.natom());
1312    }
1313
1314    #[test]
1315    fn test_prmtop_roundtrip_atom_names() {
1316        let original = make_prmtop();
1317        let mut buf = Vec::new();
1318        original.write(&mut buf).unwrap();
1319        let restored = PrmtopFile::read(Cursor::new(&buf)).unwrap();
1320        for (a, b) in original.atoms.iter().zip(restored.atoms.iter()) {
1321            assert_eq!(a.name, b.name);
1322        }
1323    }
1324
1325    #[test]
1326    fn test_prmtop_roundtrip_charges() {
1327        let original = make_prmtop();
1328        let mut buf = Vec::new();
1329        original.write(&mut buf).unwrap();
1330        let restored = PrmtopFile::read(Cursor::new(&buf)).unwrap();
1331        for (a, b) in original.atoms.iter().zip(restored.atoms.iter()) {
1332            assert!(
1333                (a.charge - b.charge).abs() < 1e-4,
1334                "charge mismatch: {} vs {}",
1335                a.charge,
1336                b.charge
1337            );
1338        }
1339    }
1340
1341    #[test]
1342    fn test_prmtop_roundtrip_masses() {
1343        let original = make_prmtop();
1344        let mut buf = Vec::new();
1345        original.write(&mut buf).unwrap();
1346        let restored = PrmtopFile::read(Cursor::new(&buf)).unwrap();
1347        for (a, b) in original.atoms.iter().zip(restored.atoms.iter()) {
1348            assert!((a.mass - b.mass).abs() < 1e-4);
1349        }
1350    }
1351
1352    #[test]
1353    fn test_prmtop_roundtrip_bond_force_constants() {
1354        let original = make_prmtop();
1355        let mut buf = Vec::new();
1356        original.write(&mut buf).unwrap();
1357        let restored = PrmtopFile::read(Cursor::new(&buf)).unwrap();
1358        assert_eq!(original.bonds.len(), restored.bonds.len());
1359        for (a, b) in original.bonds.iter().zip(restored.bonds.iter()) {
1360            assert!(
1361                (a.force_constant - b.force_constant).abs() < 1e-3,
1362                "force_constant mismatch: {} vs {}",
1363                a.force_constant,
1364                b.force_constant
1365            );
1366        }
1367    }
1368
1369    #[test]
1370    fn test_prmtop_bond_parameters_positive() {
1371        let f = make_prmtop();
1372        for bond in &f.bonds {
1373            assert!(bond.force_constant > 0.0, "force constant must be positive");
1374            assert!(bond.eq_length > 0.0, "eq length must be positive");
1375        }
1376    }
1377
1378    #[test]
1379    fn test_prmtop_empty_roundtrip() {
1380        let original = PrmtopFile::new();
1381        let mut buf = Vec::new();
1382        original.write(&mut buf).unwrap();
1383        let restored = PrmtopFile::read(Cursor::new(&buf)).unwrap();
1384        assert_eq!(restored.natom(), 0);
1385    }
1386
1387    // ── Rst7File ──────────────────────────────────────────────────────────────
1388
1389    fn make_rst7(natom: usize) -> Rst7File {
1390        let mut r = Rst7File::new();
1391        r.title = "Test restart file".to_string();
1392        r.time = Some(0.5);
1393        r.coordinates = (0..natom * 3).map(|i| i as f64 * 0.1).collect();
1394        r.velocities = vec![0.01; natom * 3];
1395        r
1396    }
1397
1398    #[test]
1399    fn test_rst7_natom() {
1400        let r = make_rst7(20);
1401        assert_eq!(r.natom(), 20);
1402    }
1403
1404    #[test]
1405    fn test_rst7_coordinate_count_matches_natom() {
1406        let r = make_rst7(7);
1407        assert_eq!(r.coordinates.len(), r.natom() * 3);
1408    }
1409
1410    #[test]
1411    fn test_rst7_write_read_roundtrip_natom() {
1412        let original = make_rst7(10);
1413        let mut buf = Vec::new();
1414        original.write(&mut buf).unwrap();
1415        let restored = Rst7File::read(Cursor::new(&buf)).unwrap();
1416        assert_eq!(original.natom(), restored.natom());
1417    }
1418
1419    #[test]
1420    fn test_rst7_write_read_roundtrip_coordinates() {
1421        let original = make_rst7(5);
1422        let mut buf = Vec::new();
1423        original.write(&mut buf).unwrap();
1424        let restored = Rst7File::read(Cursor::new(&buf)).unwrap();
1425        for (a, b) in original.coordinates.iter().zip(restored.coordinates.iter()) {
1426            assert!((a - b).abs() < 1e-5, "coord mismatch: {a} vs {b}");
1427        }
1428    }
1429
1430    #[test]
1431    fn test_rst7_coordinate_format_12_7() {
1432        // Verify the file actually contains 12.7 format numbers
1433        let r = make_rst7(1);
1434        let mut buf = Vec::new();
1435        r.write(&mut buf).unwrap();
1436        let text = String::from_utf8(buf).unwrap();
1437        // Each coordinate field should be 12 characters wide
1438        // Check at least one line after header (2 lines) has correct format
1439        let data_lines: Vec<&str> = text.lines().skip(2).collect();
1440        let first_data_line = data_lines[0];
1441        // At most 6 values × 12 chars = 72 chars per line
1442        assert!(first_data_line.len() <= 72 + 1); // +1 for newline
1443    }
1444
1445    #[test]
1446    fn test_rst7_time_preserved() {
1447        let original = make_rst7(3);
1448        let mut buf = Vec::new();
1449        original.write(&mut buf).unwrap();
1450        let restored = Rst7File::read(Cursor::new(&buf)).unwrap();
1451        assert!(restored.time.is_some());
1452        assert!((restored.time.unwrap() - 0.5).abs() < 1e-3);
1453    }
1454
1455    #[test]
1456    fn test_rst7_no_time() {
1457        let mut r = make_rst7(2);
1458        r.time = None;
1459        let mut buf = Vec::new();
1460        r.write(&mut buf).unwrap();
1461        let restored = Rst7File::read(Cursor::new(&buf)).unwrap();
1462        assert_eq!(restored.natom(), 2);
1463    }
1464
1465    // ── MdcrdReader ───────────────────────────────────────────────────────────
1466
1467    fn make_mdcrd_text(natom: usize, nframes: usize) -> String {
1468        let mut s = String::from("TITLE : mdcrd test\n");
1469        let n_coord = natom * 3;
1470        for frame in 0..nframes {
1471            let vals: Vec<f64> = (0..n_coord)
1472                .map(|i| (frame * 100 + i) as f64 * 0.1)
1473                .collect();
1474            let mut count = 0usize;
1475            for v in &vals {
1476                s.push_str(&format!("{:8.3}", v));
1477                count += 1;
1478                if count.is_multiple_of(10) {
1479                    s.push('\n');
1480                }
1481            }
1482            if !n_coord.is_multiple_of(10) {
1483                s.push('\n');
1484            }
1485        }
1486        s
1487    }
1488
1489    #[test]
1490    fn test_mdcrd_read_frame_count() {
1491        let natom = 5;
1492        let nframes = 4;
1493        let text = make_mdcrd_text(natom, nframes);
1494        let mut reader = MdcrdReader::new(BufReader::new(Cursor::new(text)), natom, false);
1495        let mut count = 0usize;
1496        while let Some(_frame) = reader.read_frame().unwrap() {
1497            count += 1;
1498        }
1499        assert_eq!(count, nframes);
1500    }
1501
1502    #[test]
1503    fn test_mdcrd_frame_natom() {
1504        let natom = 6;
1505        let text = make_mdcrd_text(natom, 1);
1506        let mut reader = MdcrdReader::new(BufReader::new(Cursor::new(text)), natom, false);
1507        let frame = reader.read_frame().unwrap().unwrap();
1508        assert_eq!(frame.natom(), natom);
1509    }
1510
1511    #[test]
1512    fn test_mdcrd_frame_coord_len() {
1513        let natom = 3;
1514        let text = make_mdcrd_text(natom, 1);
1515        let mut reader = MdcrdReader::new(BufReader::new(Cursor::new(text)), natom, false);
1516        let frame = reader.read_frame().unwrap().unwrap();
1517        assert_eq!(frame.coordinates.len(), natom * 3);
1518    }
1519
1520    #[test]
1521    fn test_mdcrd_atom_pos_valid() {
1522        let natom = 4;
1523        let text = make_mdcrd_text(natom, 1);
1524        let mut reader = MdcrdReader::new(BufReader::new(Cursor::new(text)), natom, false);
1525        let frame = reader.read_frame().unwrap().unwrap();
1526        assert!(frame.atom_pos(0).is_some());
1527        assert!(frame.atom_pos(natom - 1).is_some());
1528    }
1529
1530    #[test]
1531    fn test_mdcrd_atom_pos_out_of_bounds() {
1532        let natom = 2;
1533        let text = make_mdcrd_text(natom, 1);
1534        let mut reader = MdcrdReader::new(BufReader::new(Cursor::new(text)), natom, false);
1535        let frame = reader.read_frame().unwrap().unwrap();
1536        // Index 2 is out of bounds for natom=2 (valid: 0,1)
1537        assert!(frame.atom_pos(100).is_none());
1538    }
1539
1540    #[test]
1541    fn test_mdcrd_eof_returns_none() {
1542        let text = "TITLE\n";
1543        let mut reader = MdcrdReader::new(BufReader::new(Cursor::new(text)), 3, false);
1544        let result = reader.read_frame().unwrap();
1545        assert!(result.is_none());
1546    }
1547
1548    // ── MdinFile ──────────────────────────────────────────────────────────────
1549
1550    fn make_mdin_text() -> &'static str {
1551        "MD simulation test\n \
1552         &cntrl\n  \
1553           nstlim = 1000,\n  \
1554           dt = 0.002,\n  \
1555           temp0 = 300.0,\n  \
1556           ntpr = 100,\n  \
1557           ntwx = 100,\n  \
1558           ntb = 1,\n  \
1559         /\n"
1560    }
1561
1562    #[test]
1563    fn test_mdin_parse_nstlim() {
1564        let f = MdinFile::read(Cursor::new(make_mdin_text())).unwrap();
1565        assert_eq!(f.cntrl_int("nstlim", 0), 1000);
1566    }
1567
1568    #[test]
1569    fn test_mdin_parse_dt() {
1570        let f = MdinFile::read(Cursor::new(make_mdin_text())).unwrap();
1571        let dt = f.cntrl_float("dt", 0.0);
1572        assert!((dt - 0.002).abs() < 1e-6);
1573    }
1574
1575    #[test]
1576    fn test_mdin_parse_temp0() {
1577        let f = MdinFile::read(Cursor::new(make_mdin_text())).unwrap();
1578        let temp = f.cntrl_float("temp0", 0.0);
1579        assert!((temp - 300.0).abs() < 1e-6);
1580    }
1581
1582    #[test]
1583    fn test_mdin_parse_ntpr() {
1584        let f = MdinFile::read(Cursor::new(make_mdin_text())).unwrap();
1585        assert_eq!(f.cntrl_int("ntpr", 0), 100);
1586    }
1587
1588    #[test]
1589    fn test_mdin_default_for_missing_key() {
1590        let f = MdinFile::read(Cursor::new(make_mdin_text())).unwrap();
1591        assert_eq!(f.cntrl_int("nonexistent", 42), 42);
1592    }
1593
1594    #[test]
1595    fn test_mdin_write_read_roundtrip() {
1596        let original = MdinFile::read(Cursor::new(make_mdin_text())).unwrap();
1597        let mut buf = Vec::new();
1598        original.write(&mut buf).unwrap();
1599        let restored = MdinFile::read(Cursor::new(&buf)).unwrap();
1600        assert_eq!(
1601            original.cntrl_int("nstlim", 0),
1602            restored.cntrl_int("nstlim", 0)
1603        );
1604    }
1605
1606    // ── MdinfoParser ──────────────────────────────────────────────────────────
1607
1608    fn make_mdinfo_text() -> &'static str {
1609        " NSTEP =      500   TIME(PS) =       1.000\n \
1610          TEMP(K) =   298.15  PRESS =     0.00\n \
1611          ETOT   =   -5000.0  EKTOT  =    1000.0  EPTOT  =  -6000.0\n"
1612    }
1613
1614    #[test]
1615    fn test_mdinfo_parse_nstep() {
1616        let s = MdinfoParser::parse(Cursor::new(make_mdinfo_text())).unwrap();
1617        assert_eq!(s.nstep, 500);
1618    }
1619
1620    #[test]
1621    fn test_mdinfo_parse_time() {
1622        let s = MdinfoParser::parse(Cursor::new(make_mdinfo_text())).unwrap();
1623        assert!((s.time - 1.0).abs() < 0.01);
1624    }
1625
1626    #[test]
1627    fn test_mdinfo_parse_temp() {
1628        let s = MdinfoParser::parse(Cursor::new(make_mdinfo_text())).unwrap();
1629        assert!((s.temp - 298.15).abs() < 0.1);
1630    }
1631
1632    #[test]
1633    fn test_mdinfo_parse_etot() {
1634        let s = MdinfoParser::parse(Cursor::new(make_mdinfo_text())).unwrap();
1635        assert!((s.etot - (-5000.0)).abs() < 1.0);
1636    }
1637
1638    #[test]
1639    fn test_mdinfo_energy_conservation() {
1640        let s = MdinfoParser::parse(Cursor::new(make_mdinfo_text())).unwrap();
1641        // etot ≈ ektot + eptot
1642        let sum = s.ektot + s.eptot;
1643        assert!((s.etot - sum).abs() < 1.0);
1644    }
1645
1646    // ── AmberForceFieldReader ─────────────────────────────────────────────────
1647
1648    fn make_ff_text() -> &'static str {
1649        "AMBER force field parameters\n\
1650         MASS\n\
1651         c3  12.010  !sp3 carbon\n\
1652         hc   1.008  !H on aliphatic C\n\
1653         n3  14.010  !nitrogen sp3\n\
1654         \n\
1655         BOND\n\
1656         c3-hc  340.0  1.09  !C-H aliphatic\n\
1657         c3-n3  337.0  1.47  !C-N single\n\
1658         \n\
1659         ANGLE\n\
1660         hc-c3-hc  35.0  109.50  !tetrahedral H-C-H\n\
1661         hc-c3-n3  50.0  109.50  !H-C-N\n\
1662         \n\
1663         DIHE\n\
1664         X -c3-n3-X   1  1.40  180.0  2.0  !sp3 C-N dihedral\n\
1665         \n"
1666    }
1667
1668    #[test]
1669    fn test_ff_mass_count() {
1670        let mut r = AmberForceFieldReader::new();
1671        r.read(Cursor::new(make_ff_text())).unwrap();
1672        assert_eq!(r.masses.len(), 3);
1673    }
1674
1675    #[test]
1676    fn test_ff_bond_count() {
1677        let mut r = AmberForceFieldReader::new();
1678        r.read(Cursor::new(make_ff_text())).unwrap();
1679        assert_eq!(r.bonds.len(), 2);
1680    }
1681
1682    #[test]
1683    fn test_ff_angle_count() {
1684        let mut r = AmberForceFieldReader::new();
1685        r.read(Cursor::new(make_ff_text())).unwrap();
1686        assert_eq!(r.angles.len(), 2);
1687    }
1688
1689    #[test]
1690    fn test_ff_dihedral_count() {
1691        let mut r = AmberForceFieldReader::new();
1692        r.read(Cursor::new(make_ff_text())).unwrap();
1693        assert_eq!(r.dihedrals.len(), 1);
1694    }
1695
1696    #[test]
1697    fn test_ff_bond_force_constants_positive() {
1698        let mut r = AmberForceFieldReader::new();
1699        r.read(Cursor::new(make_ff_text())).unwrap();
1700        for b in &r.bonds {
1701            assert!(
1702                b.force_constant > 0.0,
1703                "bond force constant must be positive, got {}",
1704                b.force_constant
1705            );
1706        }
1707    }
1708
1709    #[test]
1710    fn test_ff_bond_eq_lengths_positive() {
1711        let mut r = AmberForceFieldReader::new();
1712        r.read(Cursor::new(make_ff_text())).unwrap();
1713        for b in &r.bonds {
1714            assert!(
1715                b.eq_length > 0.0,
1716                "bond eq length must be positive, got {}",
1717                b.eq_length
1718            );
1719        }
1720    }
1721
1722    #[test]
1723    fn test_ff_angle_force_constants_positive() {
1724        let mut r = AmberForceFieldReader::new();
1725        r.read(Cursor::new(make_ff_text())).unwrap();
1726        for a in &r.angles {
1727            assert!(a.force_constant > 0.0);
1728        }
1729    }
1730
1731    #[test]
1732    fn test_ff_mass_values() {
1733        let mut r = AmberForceFieldReader::new();
1734        r.read(Cursor::new(make_ff_text())).unwrap();
1735        let c3 = r.masses.iter().find(|m| m.atom_type == "c3").unwrap();
1736        assert!((c3.mass - 12.01).abs() < 0.1);
1737    }
1738
1739    // ── Charge conversion utilities ───────────────────────────────────────────
1740
1741    #[test]
1742    fn test_amber_charge_to_e_unit() {
1743        let q = amber_charge_to_e(AMBER_CHARGE_UNIT);
1744        assert!((q - 1.0).abs() < 1e-10);
1745    }
1746
1747    #[test]
1748    fn test_e_to_amber_charge_unit() {
1749        let q = e_to_amber_charge(1.0);
1750        assert!((q - AMBER_CHARGE_UNIT).abs() < 1e-10);
1751    }
1752
1753    #[test]
1754    fn test_amber_charge_roundtrip() {
1755        let original = 0.6;
1756        let roundtripped = amber_charge_to_e(e_to_amber_charge(original));
1757        assert!((original - roundtripped).abs() < 1e-12);
1758    }
1759
1760    #[test]
1761    fn test_amber_charge_factor_value() {
1762        // AMBER factor must be close to 18.2223
1763        assert!((AMBER_CHARGE_UNIT - 18.2223).abs() < 1e-4);
1764    }
1765
1766    #[test]
1767    fn test_amber_charge_negative() {
1768        let q_si = -0.5;
1769        let q_amber = e_to_amber_charge(q_si);
1770        assert!(q_amber < 0.0);
1771        assert!((amber_charge_to_e(q_amber) - q_si).abs() < 1e-12);
1772    }
1773}