1#![allow(clippy::should_implement_trait)]
2use std::io::{BufRead, Write};
8
9#[derive(Debug, Clone, PartialEq)]
11pub struct GroAtom {
12 pub residue_id: i32,
14 pub residue_name: String,
16 pub atom_name: String,
18 pub atom_id: i32,
20 pub position: [f64; 3],
22 pub velocity: Option<[f64; 3]>,
24}
25
26#[derive(Debug, Clone)]
28pub struct GroFile {
29 pub title: String,
31 pub atoms: Vec<GroAtom>,
33 pub box_vectors: [f64; 3],
35}
36
37impl GroFile {
38 pub fn read(reader: impl BufRead) -> Result<Self, String> {
40 let mut lines = reader.lines();
41
42 let title = lines
44 .next()
45 .ok_or_else(|| "GRO: missing title line".to_string())?
46 .map_err(|e| e.to_string())?;
47
48 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 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 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 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 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 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
250fn 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#[allow(dead_code)]
262impl GroFile {
263 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 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#[allow(dead_code)]
286pub type GroFrame = GroFile;
287
288#[derive(Debug, Clone, Default)]
294pub struct GroTrajectory {
295 pub frames: Vec<GroFile>,
297}
298
299impl GroTrajectory {
300 pub fn new() -> Self {
302 Self::default()
303 }
304
305 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 let n_atoms: usize = match lines[1].trim().parse() {
317 Ok(n) => n,
318 Err(_) => break,
319 };
320 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 let consumed: usize = lines[..frame_lines]
331 .iter()
332 .map(|l| l.len() + 1) .sum();
334 if consumed >= remaining.len() {
335 break;
336 }
337 remaining = &remaining[consumed..];
338 }
339
340 Ok(GroTrajectory { frames })
341 }
342
343 pub fn n_frames(&self) -> usize {
345 self.frames.len()
346 }
347
348 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 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#[allow(dead_code)]
381pub struct GroWriter {
382 inner: GroFile,
383}
384
385#[allow(dead_code)]
386impl GroWriter {
387 pub fn new(gro: GroFile) -> Self {
389 Self { inner: gro }
390 }
391
392 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 pub fn to_gro_string(&self) -> std::result::Result<String, String> {
412 self.inner.to_gro_string()
413 }
414
415 pub fn gro(&self) -> &GroFile {
417 &self.inner
418 }
419
420 pub fn n_atoms(&self) -> usize {
422 self.inner.atoms.len()
423 }
424
425 pub fn all_have_velocities(&self) -> bool {
427 self.inner.atoms.iter().all(|a| a.velocity.is_some())
428 }
429}
430
431#[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#[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#[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#[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#[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#[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#[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#[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]; 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}