Skip to main content

oxiphysics_io/gromacs/
gro_file.rs

1#![allow(clippy::should_implement_trait)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! GROMACS GRO format reader, writer, trajectory, and utility functions.
6
7use std::io::{BufRead, Write};
8
9/// A single atom record in a GRO file.
10#[derive(Debug, Clone, PartialEq)]
11pub struct GroAtom {
12    /// Residue (molecule) number.
13    pub residue_id: i32,
14    /// Residue name (max 5 chars).
15    pub residue_name: String,
16    /// Atom name (max 5 chars).
17    pub atom_name: String,
18    /// Atom number.
19    pub atom_id: i32,
20    /// Position in nm.
21    pub position: [f64; 3],
22    /// Velocity in nm/ps (optional).
23    pub velocity: Option<[f64; 3]>,
24}
25
26/// Parsed representation of a GROMACS GRO file.
27#[derive(Debug, Clone)]
28pub struct GroFile {
29    /// Title / comment line.
30    pub title: String,
31    /// Atom records.
32    pub atoms: Vec<GroAtom>,
33    /// Orthorhombic box vectors in nm.
34    pub box_vectors: [f64; 3],
35}
36
37impl GroFile {
38    /// Parse a GRO file from any `BufRead` source.
39    pub fn read(reader: impl BufRead) -> Result<Self, String> {
40        let mut lines = reader.lines();
41
42        // Line 1: title
43        let title = lines
44            .next()
45            .ok_or_else(|| "GRO: missing title line".to_string())?
46            .map_err(|e| e.to_string())?;
47
48        // Line 2: number of atoms
49        let n_atoms: usize = lines
50            .next()
51            .ok_or_else(|| "GRO: missing atom count line".to_string())?
52            .map_err(|e| e.to_string())?
53            .trim()
54            .parse()
55            .map_err(|e: std::num::ParseIntError| format!("GRO: bad atom count: {e}"))?;
56
57        let mut atoms = Vec::with_capacity(n_atoms);
58
59        for i in 0..n_atoms {
60            let raw = lines
61                .next()
62                .ok_or_else(|| format!("GRO: missing atom line {i}"))?
63                .map_err(|e| e.to_string())?;
64
65            // Fixed-width columns per GRO spec:
66            //  0..5   residue id
67            //  5..10  residue name
68            // 10..15  atom name
69            // 15..20  atom id
70            // 20..28  x
71            // 28..36  y
72            // 36..44  z
73            // 44..52  vx (optional)
74            // 52..60  vy (optional)
75            // 60..68  vz (optional)
76            if raw.len() < 44 {
77                return Err(format!(
78                    "GRO: atom line {i} too short ({} chars)",
79                    raw.len()
80                ));
81            }
82            let residue_id: i32 =
83                raw[0..5]
84                    .trim()
85                    .parse()
86                    .map_err(|e: std::num::ParseIntError| {
87                        format!("GRO: bad residue_id on line {i}: {e}")
88                    })?;
89            let residue_name = raw[5..10].trim().to_string();
90            let atom_name = raw[10..15].trim().to_string();
91            let atom_id: i32 =
92                raw[15..20]
93                    .trim()
94                    .parse()
95                    .map_err(|e: std::num::ParseIntError| {
96                        format!("GRO: bad atom_id on line {i}: {e}")
97                    })?;
98            let x: f64 = raw[20..28]
99                .trim()
100                .parse()
101                .map_err(|e: std::num::ParseFloatError| format!("GRO: bad x on line {i}: {e}"))?;
102            let y: f64 = raw[28..36]
103                .trim()
104                .parse()
105                .map_err(|e: std::num::ParseFloatError| format!("GRO: bad y on line {i}: {e}"))?;
106            let z: f64 = raw[36..44]
107                .trim()
108                .parse()
109                .map_err(|e: std::num::ParseFloatError| format!("GRO: bad z on line {i}: {e}"))?;
110
111            let velocity = if raw.len() >= 68 {
112                let vx: f64 =
113                    raw[44..52]
114                        .trim()
115                        .parse()
116                        .map_err(|e: std::num::ParseFloatError| {
117                            format!("GRO: bad vx on line {i}: {e}")
118                        })?;
119                let vy: f64 =
120                    raw[52..60]
121                        .trim()
122                        .parse()
123                        .map_err(|e: std::num::ParseFloatError| {
124                            format!("GRO: bad vy on line {i}: {e}")
125                        })?;
126                let vz: f64 =
127                    raw[60..68]
128                        .trim()
129                        .parse()
130                        .map_err(|e: std::num::ParseFloatError| {
131                            format!("GRO: bad vz on line {i}: {e}")
132                        })?;
133                Some([vx, vy, vz])
134            } else {
135                None
136            };
137
138            atoms.push(GroAtom {
139                residue_id,
140                residue_name,
141                atom_name,
142                atom_id,
143                position: [x, y, z],
144                velocity,
145            });
146        }
147
148        // Last line: box vectors
149        let box_line = lines
150            .next()
151            .ok_or_else(|| "GRO: missing box vector line".to_string())?
152            .map_err(|e| e.to_string())?;
153        let parts: Vec<&str> = box_line.split_whitespace().collect();
154        if parts.len() < 3 {
155            return Err(format!("GRO: box line has only {} tokens", parts.len()));
156        }
157        let bx: f64 = parts[0]
158            .parse()
159            .map_err(|e: std::num::ParseFloatError| format!("GRO: bad box_x: {e}"))?;
160        let by: f64 = parts[1]
161            .parse()
162            .map_err(|e: std::num::ParseFloatError| format!("GRO: bad box_y: {e}"))?;
163        let bz: f64 = parts[2]
164            .parse()
165            .map_err(|e: std::num::ParseFloatError| format!("GRO: bad box_z: {e}"))?;
166
167        Ok(GroFile {
168            title,
169            atoms,
170            box_vectors: [bx, by, bz],
171        })
172    }
173
174    /// Serialize this `GroFile` to any `Write` sink.
175    pub fn write(&self, mut writer: impl Write) -> Result<(), String> {
176        writeln!(writer, "{}", self.title).map_err(|e| e.to_string())?;
177        writeln!(writer, "{:5}", self.atoms.len()).map_err(|e| e.to_string())?;
178
179        for atom in &self.atoms {
180            let res_name = truncate(&atom.residue_name, 5);
181            let at_name = truncate(&atom.atom_name, 5);
182            let [x, y, z] = atom.position;
183
184            if let Some([vx, vy, vz]) = atom.velocity {
185                writeln!(
186                    writer,
187                    "{:5}{:<5}{:<5}{:5}{:8.3}{:8.3}{:8.3}{:8.4}{:8.4}{:8.4}",
188                    atom.residue_id, res_name, at_name, atom.atom_id, x, y, z, vx, vy, vz,
189                )
190                .map_err(|e| e.to_string())?;
191            } else {
192                writeln!(
193                    writer,
194                    "{:5}{:<5}{:<5}{:5}{:8.3}{:8.3}{:8.3}",
195                    atom.residue_id, res_name, at_name, atom.atom_id, x, y, z,
196                )
197                .map_err(|e| e.to_string())?;
198            }
199        }
200
201        writeln!(
202            writer,
203            "{:10.5} {:10.5} {:10.5}",
204            self.box_vectors[0], self.box_vectors[1], self.box_vectors[2],
205        )
206        .map_err(|e| e.to_string())?;
207
208        Ok(())
209    }
210
211    /// Create a minimal `GroFile` from a slice of positions (nm).
212    ///
213    /// All atoms get `residue_name = "MOL"`, `atom_name = "X"`, `residue_id = 1`.
214    pub fn from_xyz(positions: &[[f64; 3]], box_size: [f64; 3]) -> Self {
215        let atoms = positions
216            .iter()
217            .enumerate()
218            .map(|(i, &pos)| GroAtom {
219                residue_id: 1,
220                residue_name: "MOL".to_string(),
221                atom_name: "X".to_string(),
222                atom_id: (i + 1) as i32,
223                position: pos,
224                velocity: None,
225            })
226            .collect();
227
228        GroFile {
229            title: "Generated by oxiphysics-io".to_string(),
230            atoms,
231            box_vectors: box_size,
232        }
233    }
234
235    /// Return all atom positions converted from nm to Angstrom (x10).
236    pub fn positions_angstrom(&self) -> Vec<[f64; 3]> {
237        self.atoms
238            .iter()
239            .map(|a| {
240                [
241                    a.position[0] * 10.0,
242                    a.position[1] * 10.0,
243                    a.position[2] * 10.0,
244                ]
245            })
246            .collect()
247    }
248}
249
250/// Truncate a string slice to at most `n` chars without panicking.
251fn truncate(s: &str, n: usize) -> &str {
252    let mut idx = s.len();
253    while !s.is_char_boundary(idx) || idx > n {
254        idx -= 1;
255    }
256    &s[..idx]
257}
258
259// ─── GroFile from_str / to_string ────────────────────────────────────────────
260
261#[allow(dead_code)]
262impl GroFile {
263    /// Parse a GRO file from a string.
264    pub fn from_str(data: &str) -> Result<Self, String> {
265        let cursor = std::io::Cursor::new(data.as_bytes());
266        Self::read(cursor)
267    }
268
269    /// Serialize to a String.
270    pub fn to_gro_string(&self) -> Result<String, String> {
271        let mut buf: Vec<u8> = Vec::new();
272        self.write(&mut buf)?;
273        String::from_utf8(buf).map_err(|e| e.to_string())
274    }
275}
276
277// ============================================================================
278// GroFrame type alias
279// ============================================================================
280
281/// A single frame of a GROMACS trajectory (title + atoms + box).
282///
283/// This is a re-export-compatible type alias for [`GroFile`], providing
284/// the name used in the task specification.
285#[allow(dead_code)]
286pub type GroFrame = GroFile;
287
288// ============================================================================
289// GroTrajectory
290// ============================================================================
291
292/// A multi-frame GRO trajectory (sequence of GRO snapshots).
293#[derive(Debug, Clone, Default)]
294pub struct GroTrajectory {
295    /// Individual frames.
296    pub frames: Vec<GroFile>,
297}
298
299impl GroTrajectory {
300    /// Create an empty trajectory.
301    pub fn new() -> Self {
302        Self::default()
303    }
304
305    /// Parse multiple GRO frames concatenated in a single string.
306    pub fn from_str(data: &str) -> Result<Self, String> {
307        let mut frames = Vec::new();
308        let mut remaining = data;
309
310        while !remaining.trim().is_empty() {
311            let lines: Vec<&str> = remaining.lines().collect();
312            if lines.len() < 3 {
313                break;
314            }
315            // Line 0: title, Line 1: N atoms
316            let n_atoms: usize = match lines[1].trim().parse() {
317                Ok(n) => n,
318                Err(_) => break,
319            };
320            // Total lines for one frame: 1 (title) + 1 (count) + n_atoms + 1 (box)
321            let frame_lines = 3 + n_atoms;
322            if lines.len() < frame_lines {
323                break;
324            }
325            let frame_text: String = lines[..frame_lines].join("\n");
326            let frame = GroFile::from_str(&frame_text)?;
327            frames.push(frame);
328
329            // Advance
330            let consumed: usize = lines[..frame_lines]
331                .iter()
332                .map(|l| l.len() + 1) // +1 for newline
333                .sum();
334            if consumed >= remaining.len() {
335                break;
336            }
337            remaining = &remaining[consumed..];
338        }
339
340        Ok(GroTrajectory { frames })
341    }
342
343    /// Number of frames.
344    pub fn n_frames(&self) -> usize {
345        self.frames.len()
346    }
347
348    /// Extract the trajectory of a single atom across all frames.
349    pub fn atom_trajectory(&self, atom_index: usize) -> Vec<[f64; 3]> {
350        self.frames
351            .iter()
352            .filter_map(|f| f.atoms.get(atom_index).map(|a| a.position))
353            .collect()
354    }
355
356    /// Mean box vectors across all frames.
357    pub fn mean_box(&self) -> [f64; 3] {
358        if self.frames.is_empty() {
359            return [0.0; 3];
360        }
361        let n = self.frames.len() as f64;
362        let mut sum = [0.0; 3];
363        for f in &self.frames {
364            sum[0] += f.box_vectors[0];
365            sum[1] += f.box_vectors[1];
366            sum[2] += f.box_vectors[2];
367        }
368        [sum[0] / n, sum[1] / n, sum[2] / n]
369    }
370}
371
372// ============================================================================
373// GroWriter
374// ============================================================================
375
376/// A builder for writing GROMACS GRO files with velocities.
377///
378/// Wraps a `GroFile` and forces all atoms to have their velocity field set
379/// before serialisation, padding missing velocities with zero.
380#[allow(dead_code)]
381pub struct GroWriter {
382    inner: GroFile,
383}
384
385#[allow(dead_code)]
386impl GroWriter {
387    /// Create a new `GroWriter` from an existing `GroFile`.
388    pub fn new(gro: GroFile) -> Self {
389        Self { inner: gro }
390    }
391
392    /// Attach velocities to all atoms.
393    ///
394    /// `velocities` must have the same length as the number of atoms; returns
395    /// an error otherwise.
396    pub fn write_velocities(&mut self, velocities: &[[f64; 3]]) -> std::result::Result<(), String> {
397        if velocities.len() != self.inner.atoms.len() {
398            return Err(format!(
399                "velocity count {} != atom count {}",
400                velocities.len(),
401                self.inner.atoms.len()
402            ));
403        }
404        for (atom, &vel) in self.inner.atoms.iter_mut().zip(velocities.iter()) {
405            atom.velocity = Some(vel);
406        }
407        Ok(())
408    }
409
410    /// Serialise the GRO file (with velocities) to a string.
411    pub fn to_gro_string(&self) -> std::result::Result<String, String> {
412        self.inner.to_gro_string()
413    }
414
415    /// Access the inner `GroFile`.
416    pub fn gro(&self) -> &GroFile {
417        &self.inner
418    }
419
420    /// Number of atoms.
421    pub fn n_atoms(&self) -> usize {
422        self.inner.atoms.len()
423    }
424
425    /// Returns `true` if every atom has a velocity set.
426    pub fn all_have_velocities(&self) -> bool {
427        self.inner.atoms.iter().all(|a| a.velocity.is_some())
428    }
429}
430
431// ============================================================================
432// GRO Utility Functions
433// ============================================================================
434
435/// Merge two `GroFile` structures into one, renumbering atoms and residues.
436///
437/// Atoms from `other` are appended after `base`, with residue IDs offset by
438/// the maximum residue ID in `base` + 1.
439#[allow(dead_code)]
440pub fn merge_gro_files(base: &GroFile, other: &GroFile) -> GroFile {
441    let max_res_id = base.atoms.iter().map(|a| a.residue_id).max().unwrap_or(0);
442    let max_atom_id = base.atoms.iter().map(|a| a.atom_id).max().unwrap_or(0);
443
444    let mut atoms = base.atoms.clone();
445    for (i, a) in other.atoms.iter().enumerate() {
446        atoms.push(GroAtom {
447            residue_id: a.residue_id + max_res_id,
448            residue_name: a.residue_name.clone(),
449            atom_name: a.atom_name.clone(),
450            atom_id: max_atom_id + i as i32 + 1,
451            position: a.position,
452            velocity: a.velocity,
453        });
454    }
455
456    let box_vectors = [
457        base.box_vectors[0].max(other.box_vectors[0]),
458        base.box_vectors[1].max(other.box_vectors[1]),
459        base.box_vectors[2].max(other.box_vectors[2]),
460    ];
461
462    GroFile {
463        title: format!("{} + {}", base.title, other.title),
464        atoms,
465        box_vectors,
466    }
467}
468
469/// Translate all atom positions by `delta`.
470#[allow(dead_code)]
471pub fn translate_gro(gro: &mut GroFile, delta: [f64; 3]) {
472    for atom in &mut gro.atoms {
473        atom.position[0] += delta[0];
474        atom.position[1] += delta[1];
475        atom.position[2] += delta[2];
476    }
477}
478
479/// Compute the centre of geometry of all atoms.
480#[allow(dead_code)]
481pub fn gro_centre_of_geometry(gro: &GroFile) -> [f64; 3] {
482    if gro.atoms.is_empty() {
483        return [0.0; 3];
484    }
485    let n = gro.atoms.len() as f64;
486    let mut sum = [0.0; 3];
487    for a in &gro.atoms {
488        sum[0] += a.position[0];
489        sum[1] += a.position[1];
490        sum[2] += a.position[2];
491    }
492    [sum[0] / n, sum[1] / n, sum[2] / n]
493}
494
495/// Translate the system so that the centre of geometry is at the origin.
496#[allow(dead_code)]
497pub fn centre_gro(gro: &mut GroFile) {
498    let cog = gro_centre_of_geometry(gro);
499    translate_gro(gro, [-cog[0], -cog[1], -cog[2]]);
500}
501
502/// Wrap all atom positions into the periodic box `[0, box_x) x [0, box_y) x [0, box_z)`.
503#[allow(dead_code)]
504pub fn wrap_into_box(gro: &mut GroFile) {
505    for atom in &mut gro.atoms {
506        for d in 0..3 {
507            let l = gro.box_vectors[d];
508            if l > 0.0 {
509                atom.position[d] = ((atom.position[d] % l) + l) % l;
510            }
511        }
512    }
513}
514
515/// Filter atoms by residue name, returning a new `GroFile`.
516#[allow(dead_code)]
517pub fn filter_by_residue(gro: &GroFile, residue_name: &str) -> GroFile {
518    let atoms: Vec<GroAtom> = gro
519        .atoms
520        .iter()
521        .filter(|a| a.residue_name == residue_name)
522        .cloned()
523        .collect();
524    GroFile {
525        title: format!("{} [{}]", gro.title, residue_name),
526        atoms,
527        box_vectors: gro.box_vectors,
528    }
529}
530
531/// Compute all pairwise distances between atoms.
532#[allow(dead_code)]
533pub fn gro_pairwise_distances(gro: &GroFile) -> Vec<f64> {
534    let n = gro.atoms.len();
535    let mut dists = Vec::with_capacity(n * (n - 1) / 2);
536    for i in 0..n {
537        for j in (i + 1)..n {
538            let a = &gro.atoms[i];
539            let b = &gro.atoms[j];
540            let dx = a.position[0] - b.position[0];
541            let dy = a.position[1] - b.position[1];
542            let dz = a.position[2] - b.position[2];
543            dists.push((dx * dx + dy * dy + dz * dz).sqrt());
544        }
545    }
546    dists
547}
548
549// ============================================================================
550// Tests
551// ============================================================================
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use std::io::Cursor;
557
558    fn write_to_string(gro: &GroFile) -> String {
559        let mut buf: Vec<u8> = Vec::new();
560        gro.write(&mut buf).expect("write failed");
561        String::from_utf8(buf).expect("utf8")
562    }
563
564    #[test]
565    fn test_gro_write_read_roundtrip() {
566        let original = GroFile {
567            title: "Test system".to_string(),
568            atoms: vec![
569                GroAtom {
570                    residue_id: 1,
571                    residue_name: "SOL".to_string(),
572                    atom_name: "OW".to_string(),
573                    atom_id: 1,
574                    position: [0.100, 0.200, 0.300],
575                    velocity: Some([0.001, -0.002, 0.003]),
576                },
577                GroAtom {
578                    residue_id: 1,
579                    residue_name: "SOL".to_string(),
580                    atom_name: "HW1".to_string(),
581                    atom_id: 2,
582                    position: [0.110, 0.210, 0.310],
583                    velocity: None,
584                },
585            ],
586            box_vectors: [3.0, 3.0, 3.0],
587        };
588        let text = write_to_string(&original);
589        let parsed = GroFile::read(Cursor::new(text.as_bytes())).expect("parse");
590        assert_eq!(parsed.atoms.len(), 2);
591        assert_eq!(parsed.title.trim(), "Test system");
592        assert!((parsed.box_vectors[0] - 3.0).abs() < 1e-3);
593    }
594
595    #[test]
596    fn test_gro_from_xyz() {
597        let gro = GroFile::from_xyz(&[[1.0, 2.0, 3.0]], [5.0, 5.0, 5.0]);
598        assert_eq!(gro.atoms.len(), 1);
599        assert_eq!(gro.atoms[0].residue_name, "MOL");
600    }
601
602    #[test]
603    fn test_positions_angstrom() {
604        let gro = GroFile::from_xyz(&[[1.0, 0.0, 0.0]], [5.0, 5.0, 5.0]);
605        let ang = gro.positions_angstrom();
606        assert!((ang[0][0] - 10.0).abs() < 1e-9);
607    }
608}
609
610#[cfg(test)]
611mod tests_gro_trajectory {
612    use super::*;
613
614    fn make_gro_text(n_atoms: usize) -> String {
615        let mut s = format!("Test frame\n{n_atoms}\n");
616        for i in 0..n_atoms {
617            s.push_str(&format!(
618                "{:5}{:<5}{:<5}{:5}{:8.3}{:8.3}{:8.3}\n",
619                1,
620                "SOL",
621                "OW",
622                i + 1,
623                i as f64 * 0.1,
624                0.0,
625                0.0
626            ));
627        }
628        s.push_str("3.0 3.0 3.0\n");
629        s
630    }
631
632    #[test]
633    fn test_gro_trajectory_single_frame() {
634        let text = make_gro_text(3);
635        let traj = GroTrajectory::from_str(&text).expect("parse");
636        assert_eq!(traj.n_frames(), 1);
637        assert_eq!(traj.frames[0].atoms.len(), 3);
638    }
639
640    #[test]
641    fn test_gro_trajectory_two_frames() {
642        let mut text = make_gro_text(2);
643        text.push_str(&make_gro_text(2));
644        let traj = GroTrajectory::from_str(&text).expect("parse");
645        assert_eq!(traj.n_frames(), 2);
646    }
647
648    #[test]
649    fn test_gro_trajectory_atom_path() {
650        let mut text = make_gro_text(2);
651        text.push_str(&make_gro_text(2));
652        let traj = GroTrajectory::from_str(&text).expect("parse");
653        let path = traj.atom_trajectory(0);
654        assert_eq!(path.len(), 2);
655    }
656
657    #[test]
658    fn test_gro_trajectory_mean_box() {
659        let text = make_gro_text(1);
660        let traj = GroTrajectory::from_str(&text).expect("parse");
661        let mb = traj.mean_box();
662        assert!((mb[0] - 3.0).abs() < 1e-5);
663    }
664
665    #[test]
666    fn test_gro_trajectory_empty() {
667        let traj = GroTrajectory::from_str("").expect("parse");
668        assert_eq!(traj.n_frames(), 0);
669        assert_eq!(traj.mean_box(), [0.0; 3]);
670    }
671}
672
673#[cfg(test)]
674mod tests_gro_utilities {
675    use super::*;
676
677    fn simple_gro(n: usize) -> GroFile {
678        let atoms: Vec<GroAtom> = (0..n)
679            .map(|i| GroAtom {
680                residue_id: i as i32 + 1,
681                residue_name: "SOL".to_string(),
682                atom_name: "OW".to_string(),
683                atom_id: i as i32 + 1,
684                position: [i as f64 * 0.3, 0.0, 0.0],
685                velocity: None,
686            })
687            .collect();
688        GroFile {
689            title: "test".to_string(),
690            atoms,
691            box_vectors: [3.0, 3.0, 3.0],
692        }
693    }
694
695    #[test]
696    fn merge_gro_atom_count() {
697        let a = simple_gro(3);
698        let b = simple_gro(2);
699        let merged = merge_gro_files(&a, &b);
700        assert_eq!(merged.atoms.len(), 5);
701    }
702
703    #[test]
704    fn merge_gro_residue_ids_no_collision() {
705        let a = simple_gro(2);
706        let b = simple_gro(2);
707        let merged = merge_gro_files(&a, &b);
708        let ids: Vec<i32> = merged.atoms.iter().map(|x| x.residue_id).collect();
709        let unique: std::collections::HashSet<_> = ids.iter().collect();
710        assert_eq!(unique.len(), ids.len());
711    }
712
713    #[test]
714    fn merge_gro_title_combines() {
715        let mut a = simple_gro(1);
716        a.title = "water".to_string();
717        let mut b = simple_gro(1);
718        b.title = "ions".to_string();
719        let m = merge_gro_files(&a, &b);
720        assert!(m.title.contains("water"));
721        assert!(m.title.contains("ions"));
722    }
723
724    #[test]
725    fn translate_gro_shifts_all_atoms() {
726        let mut g = simple_gro(3);
727        let delta = [1.0, 2.0, 3.0];
728        translate_gro(&mut g, delta);
729        for (i, a) in g.atoms.iter().enumerate() {
730            assert!((a.position[0] - (i as f64 * 0.3 + 1.0)).abs() < 1e-12);
731            assert!((a.position[1] - 2.0).abs() < 1e-12);
732            assert!((a.position[2] - 3.0).abs() < 1e-12);
733        }
734    }
735
736    #[test]
737    fn cog_symmetric_atoms() {
738        let mut g = simple_gro(0);
739        g.atoms.push(GroAtom {
740            residue_id: 1,
741            residue_name: "X".to_string(),
742            atom_name: "A".to_string(),
743            atom_id: 1,
744            position: [-1.0, 0.0, 0.0],
745            velocity: None,
746        });
747        g.atoms.push(GroAtom {
748            residue_id: 2,
749            residue_name: "X".to_string(),
750            atom_name: "A".to_string(),
751            atom_id: 2,
752            position: [1.0, 0.0, 0.0],
753            velocity: None,
754        });
755        let cog = gro_centre_of_geometry(&g);
756        assert!(cog[0].abs() < 1e-12);
757    }
758
759    #[test]
760    fn cog_empty_returns_zero() {
761        let g = GroFile {
762            title: "".to_string(),
763            atoms: vec![],
764            box_vectors: [0.0; 3],
765        };
766        assert_eq!(gro_centre_of_geometry(&g), [0.0; 3]);
767    }
768
769    #[test]
770    fn centre_gro_places_cog_at_origin() {
771        let mut g = simple_gro(4);
772        centre_gro(&mut g);
773        let cog = gro_centre_of_geometry(&g);
774        for c in cog {
775            assert!(c.abs() < 1e-12, "cog component {c} not zero");
776        }
777    }
778
779    #[test]
780    fn wrap_into_box_positions_in_range() {
781        let mut g = simple_gro(5);
782        g.atoms[4].position = [4.0, 4.0, 4.0]; // box is 3x3x3
783        wrap_into_box(&mut g);
784        for a in &g.atoms {
785            assert!(a.position[0] >= 0.0 && a.position[0] < 3.0);
786            assert!(a.position[1] >= 0.0 && a.position[1] < 3.0);
787            assert!(a.position[2] >= 0.0 && a.position[2] < 3.0);
788        }
789    }
790
791    #[test]
792    fn filter_by_residue_keeps_matching() {
793        let mut g = simple_gro(3);
794        g.atoms[0].residue_name = "NA".to_string();
795        let filtered = filter_by_residue(&g, "SOL");
796        assert_eq!(filtered.atoms.len(), 2);
797    }
798
799    #[test]
800    fn filter_by_residue_none_match_empty() {
801        let g = simple_gro(3);
802        let filtered = filter_by_residue(&g, "CL");
803        assert!(filtered.atoms.is_empty());
804    }
805
806    #[test]
807    fn pairwise_distances_count() {
808        let g = simple_gro(4);
809        let d = gro_pairwise_distances(&g);
810        assert_eq!(d.len(), 6);
811    }
812
813    #[test]
814    fn pairwise_distances_positive() {
815        let g = simple_gro(3);
816        for d in gro_pairwise_distances(&g) {
817            assert!(d >= 0.0);
818        }
819    }
820
821    #[test]
822    fn test_gro_writer_write_velocities_ok() {
823        let gro = GroFile::from_xyz(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], [5.0, 5.0, 5.0]);
824        let mut writer = GroWriter::new(gro);
825        let vels = [[0.1, 0.0, 0.0], [0.2, 0.0, 0.0]];
826        assert!(writer.write_velocities(&vels).is_ok());
827        assert!(writer.all_have_velocities());
828    }
829
830    #[test]
831    fn test_gro_writer_write_velocities_mismatch_error() {
832        let gro = GroFile::from_xyz(&[[0.0; 3]], [1.0, 1.0, 1.0]);
833        let mut writer = GroWriter::new(gro);
834        let result = writer.write_velocities(&[[0.0; 3], [0.0; 3]]);
835        assert!(result.is_err());
836    }
837
838    #[test]
839    fn test_gro_writer_to_string_contains_velocity() {
840        let gro = GroFile::from_xyz(&[[0.5, 0.5, 0.5]], [2.0, 2.0, 2.0]);
841        let mut writer = GroWriter::new(gro);
842        writer
843            .write_velocities(&[[1.0, 2.0, 3.0]])
844            .expect("write velocities");
845        let text = writer.to_gro_string().expect("to_gro_string");
846        assert!(!text.is_empty());
847        assert_eq!(writer.n_atoms(), 1);
848    }
849
850    #[test]
851    fn test_gro_frame_type_alias() {
852        let frame: GroFrame = GroFile::from_xyz(&[[0.0, 0.0, 0.0]], [3.0, 3.0, 3.0]);
853        assert_eq!(frame.atoms.len(), 1);
854        let s = frame.to_gro_string().expect("to_gro_string");
855        let parsed: GroFrame = GroFile::from_str(&s).expect("parse");
856        assert_eq!(parsed.atoms.len(), 1);
857    }
858}
859
860#[cfg(test)]
861mod tests_gro_extra {
862    use super::*;
863    use std::io::BufReader;
864
865    fn minimal_gro(title: &str, n: usize) -> String {
866        let mut s = format!("{}\n{}\n", title, n);
867        for i in 0..n {
868            s.push_str(&format!(
869                "{:>5}{:<5}{:<5}{:>5}{:>8.3}{:>8.3}{:>8.3}\n",
870                1,
871                "SOL",
872                "OW",
873                i + 1,
874                0.0_f64,
875                0.0_f64,
876                0.0_f64
877            ));
878        }
879        s.push_str("  5.0   5.0   5.0\n");
880        s
881    }
882
883    #[test]
884    fn gro_file_from_str_basic() {
885        let data = minimal_gro("Water box", 2);
886        let gro = GroFile::from_str(&data).expect("parse");
887        assert_eq!(gro.atoms.len(), 2);
888        assert_eq!(gro.title.trim(), "Water box");
889    }
890
891    #[test]
892    fn gro_file_box_vectors_parsed() {
893        let data = minimal_gro("test", 1);
894        let gro = GroFile::from_str(&data).expect("parse");
895        assert!((gro.box_vectors[0] - 5.0).abs() < 1e-9);
896    }
897
898    #[test]
899    fn gro_file_roundtrip_via_string() {
900        let data = minimal_gro("Round trip", 3);
901        let gro = GroFile::from_str(&data).expect("parse");
902        let s = gro.to_gro_string().expect("to_gro_string");
903        let gro2 = GroFile::from_str(&s).expect("reparse");
904        assert_eq!(gro2.atoms.len(), 3);
905    }
906
907    #[test]
908    fn gro_file_from_xyz_creates_atoms() {
909        let positions = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
910        let gro = GroFile::from_xyz(&positions, [10.0, 10.0, 10.0]);
911        assert_eq!(gro.atoms.len(), 3);
912        assert!((gro.atoms[0].position[0] - 1.0).abs() < 1e-9);
913    }
914
915    #[test]
916    fn gro_file_positions_angstrom_conversion() {
917        let gro = GroFile::from_xyz(&[[1.0, 0.0, 0.0]], [5.0, 5.0, 5.0]);
918        let ang = gro.positions_angstrom();
919        assert!((ang[0][0] - 10.0).abs() < 1e-9);
920    }
921
922    #[test]
923    fn gro_file_read_from_bufreader() {
924        let data = minimal_gro("BufRead test", 2);
925        let reader = BufReader::new(data.as_bytes());
926        let gro = GroFile::read(reader).expect("parse");
927        assert_eq!(gro.atoms.len(), 2);
928    }
929
930    #[test]
931    fn gro_file_atoms_have_correct_residue_name() {
932        let data = minimal_gro("check", 2);
933        let gro = GroFile::from_str(&data).expect("parse");
934        assert_eq!(gro.atoms[0].residue_name, "SOL");
935    }
936
937    #[test]
938    fn gro_file_atom_id_sequential() {
939        let data = minimal_gro("seq", 4);
940        let gro = GroFile::from_str(&data).expect("parse");
941        for (i, atom) in gro.atoms.iter().enumerate() {
942            assert_eq!(atom.atom_id, (i + 1) as i32);
943        }
944    }
945}