readcon_core/iterators.rs
1//=============================================================================
2// The Public API - A clean iterator for users of our library
3//=============================================================================
4
5use crate::parser::{parse_declared_sections, parse_single_frame};
6use crate::{error, types};
7use std::iter::Peekable;
8use std::path::Path;
9
10/// An iterator that lazily parses simulation frames from a `.con` or `.convel`
11/// file's contents.
12///
13/// This struct wraps an iterator over the lines of a string and, upon each iteration,
14/// attempts to parse a complete `ConFrame`. Velocity sections are detected
15/// automatically: if a blank line follows the coordinate blocks, the velocity
16/// data is parsed into the atoms.
17///
18/// The iterator yields items of type `Result<ConFrame, ParseError>`, allowing for
19/// robust error handling for each frame.
20pub struct ConFrameIterator<'a> {
21 lines: Peekable<std::str::Lines<'a>>,
22}
23
24impl<'a> ConFrameIterator<'a> {
25 /// Creates a new `ConFrameIterator` from a string slice of the entire file.
26 ///
27 /// # Arguments
28 ///
29 /// * `file_contents` - A string slice containing the text of one or more `.con` frames.
30 pub fn new(file_contents: &'a str) -> Self {
31 ConFrameIterator {
32 lines: file_contents.lines().peekable(),
33 }
34 }
35
36 /// Skips the next frame without fully parsing its atomic data.
37 ///
38 /// This is more efficient than `next()` if you only need to advance the
39 /// iterator. It reads the frame's header to determine how many lines to skip,
40 /// including any velocity section if present.
41 ///
42 /// # Returns
43 ///
44 /// * `Some(Ok(()))` on a successful skip.
45 /// * `Some(Err(ParseError::...))` if there's an error parsing the header.
46 /// * `None` if the iterator is already at the end.
47 pub fn forward(&mut self) -> Option<Result<(), error::ParseError>> {
48 // Skip frame by parsing only required header fields to avoid full parsing overhead
49 self.lines.peek()?;
50
51 // Manually consume the first 6 lines of the header, which we don't need for skipping.
52 for _ in 0..6 {
53 if self.lines.next().is_none() {
54 return Some(Err(error::ParseError::IncompleteHeader));
55 }
56 }
57
58 // Line 7: natm_types. We need to parse this.
59 let natm_types: usize = match self.lines.next() {
60 Some(line) => match crate::parser::parse_line_of_n::<usize>(line, 1) {
61 Ok(v) => v[0],
62 Err(e) => return Some(Err(e)),
63 },
64 None => return Some(Err(error::ParseError::IncompleteHeader)),
65 };
66
67 // Line 8: natms_per_type. We need this to sum the total number of atoms.
68 let natms_per_type: Vec<usize> = match self.lines.next() {
69 Some(line) => match crate::parser::parse_line_of_n(line, natm_types) {
70 Ok(v) => v,
71 Err(e) => return Some(Err(e)),
72 },
73 None => return Some(Err(error::ParseError::IncompleteHeader)),
74 };
75
76 // Line 9: masses_per_type. We just need to consume this line.
77 if self.lines.next().is_none() {
78 return Some(Err(error::ParseError::IncompleteHeader));
79 }
80
81 // Calculate how many more lines to skip for coordinate blocks.
82 let total_atoms: usize = natms_per_type.iter().sum();
83 // For each atom type, there is a symbol line and a "Coordinates..." line.
84 let non_atom_lines = natm_types * 2;
85 let lines_to_skip = total_atoms + non_atom_lines;
86
87 // Advance the iterator by skipping the coordinate block lines.
88 for _ in 0..lines_to_skip {
89 if self.lines.next().is_none() {
90 // The file ended before the header's promise was fulfilled.
91 return Some(Err(error::ParseError::IncompleteFrame));
92 }
93 }
94
95 // Skip additional sections (velocities, forces).
96 // Each section has: blank separator + same structure as coordinate blocks.
97 // We don't have access to the parsed sections list here (we only parsed
98 // the header minimally), so we detect sections by peeking for blank separators.
99 loop {
100 match self.lines.peek() {
101 Some(line) if line.trim().is_empty() => {
102 self.lines.next(); // consume blank separator
103 let section_lines = total_atoms + non_atom_lines;
104 for _ in 0..section_lines {
105 if self.lines.next().is_none() {
106 return Some(Err(error::ParseError::IncompleteFrame));
107 }
108 }
109 }
110 _ => break,
111 }
112 }
113
114 Some(Ok(()))
115 }
116}
117
118impl<'a> Iterator for ConFrameIterator<'a> {
119 /// The type of item yielded by the iterator.
120 ///
121 /// Each item is a `Result` that contains a successfully parsed `ConFrame` or a
122 /// `ParseError` if the frame's data is malformed.
123 type Item = Result<types::ConFrame, error::ParseError>;
124
125 /// Advances the iterator and attempts to parse the next frame.
126 ///
127 /// This method will return `None` only when there are no more lines to consume.
128 /// If there are lines but they do not form a complete frame, it will return
129 /// `Some(Err(ParseError::...))`.
130 fn next(&mut self) -> Option<Self::Item> {
131 // If there are no more lines at all, the iterator is exhausted.
132 self.lines.peek()?;
133 // Otherwise, attempt to parse the next frame from the available lines.
134 let mut frame = match parse_single_frame(&mut self.lines) {
135 Ok(f) => f,
136 Err(e) => return Some(Err(e)),
137 };
138 // Parse declared sections (velocities, forces) or fall back to legacy velocity detection
139 match parse_declared_sections(&mut self.lines, &mut frame.header, &mut frame.atom_data) {
140 Ok(_) => {}
141 Err(e) => return Some(Err(e)),
142 }
143 Some(Ok(frame))
144 }
145}
146
147/// Reads all frames from a file.
148///
149/// For files smaller than 64 KiB, uses a simple `read_to_string` to avoid
150/// the fixed overhead of mmap (VMA creation, page fault, munmap). For larger
151/// trajectory files, uses memory-mapped I/O to let the OS page cache handle
152/// the data.
153pub fn read_all_frames(path: &Path) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
154 let contents = crate::compression::read_file_contents(path)?;
155 let text = contents.as_str()?;
156 let iter = ConFrameIterator::new(text);
157 let frames: Result<Vec<_>, _> = iter.collect();
158 Ok(frames?)
159}
160
161/// Reads only the first frame from a file.
162///
163/// More efficient than `read_all_frames` for single-frame access because it
164/// stops parsing after the first frame rather than collecting all of them.
165pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
166 let contents = crate::compression::read_file_contents(path)?;
167 let text = contents.as_str()?;
168 let mut iter = ConFrameIterator::new(text);
169 match iter.next() {
170 Some(Ok(frame)) => Ok(frame),
171 Some(Err(e)) => Err(Box::new(e)),
172 None => Err("No frames found in file".into()),
173 }
174}
175
176/// Parses frames in parallel using rayon, splitting on frame boundaries.
177///
178/// Phase 1: sequential scan to find byte offsets of each frame's start.
179/// Phase 2: parallel parse of each frame slice using rayon.
180///
181/// Requires the `parallel` feature.
182#[cfg(feature = "parallel")]
183pub fn parse_frames_parallel(
184 file_contents: &str,
185) -> Vec<Result<types::ConFrame, error::ParseError>> {
186 use rayon::prelude::*;
187
188 // Phase 1: find frame byte boundaries by scanning for header patterns.
189 // Each frame starts with a header: 2 comment lines, then a line with 3 floats (box).
190 // We identify boundaries by walking through the file with a ConFrameIterator
191 // and recording byte positions.
192 let mut boundaries: Vec<usize> = Vec::new();
193 boundaries.push(0);
194
195 // Walk through the file using the forward() method to find frame boundaries
196 let mut scanner = ConFrameIterator::new(file_contents);
197 while scanner.forward().is_some() {
198 // After forward(), the internal iterator is positioned right after the frame.
199 // We need to figure out the byte offset of the next frame start.
200 // Since Peekable<Lines> doesn't expose byte offsets, we use a different approach:
201 // count lines consumed per frame and convert to byte offsets.
202 }
203
204 // Simpler approach: split into frame text chunks by parsing sequentially,
205 // recording where each frame starts and ends in the string.
206 boundaries.clear();
207 let lines: Vec<&str> = file_contents.lines().collect();
208 let mut line_idx = 0;
209 let total_lines = lines.len();
210
211 while line_idx < total_lines {
212 // Record the byte offset of this frame's start
213 let byte_offset: usize = lines[..line_idx]
214 .iter()
215 .map(|l| l.len() + 1) // +1 for newline
216 .sum();
217 boundaries.push(byte_offset);
218
219 // Skip 6 header lines (prebox1, prebox2, boxl, angles, postbox1, postbox2)
220 if line_idx + 6 >= total_lines {
221 break;
222 }
223 line_idx += 6;
224
225 // Line 7: natm_types
226 let natm_types: usize = match lines.get(line_idx) {
227 Some(l) => match crate::parser::parse_line_of_n::<usize>(l, 1) {
228 Ok(v) => v[0],
229 Err(_) => break,
230 },
231 None => break,
232 };
233 line_idx += 1;
234
235 // Line 8: natms_per_type
236 let natms_per_type: Vec<usize> = match lines.get(line_idx) {
237 Some(l) => match crate::parser::parse_line_of_n(l, natm_types) {
238 Ok(v) => v,
239 Err(_) => break,
240 },
241 None => break,
242 };
243 line_idx += 1;
244
245 // Line 9: masses (just skip)
246 line_idx += 1;
247
248 // Skip coordinate blocks
249 let total_atoms: usize = natms_per_type.iter().sum();
250 let coord_lines = total_atoms + natm_types * 2;
251 line_idx += coord_lines;
252
253 // Skip any additional sections (velocities, forces, etc.)
254 // Each section starts with a blank separator followed by the same
255 // number of lines as coordinate blocks.
256 while line_idx < total_lines {
257 if let Some(l) = lines.get(line_idx) {
258 if l.trim().is_empty() {
259 line_idx += 1; // blank separator
260 line_idx += coord_lines; // section blocks same size
261 } else {
262 break;
263 }
264 } else {
265 break;
266 }
267 }
268 }
269
270 // Phase 2: parallel parse each frame chunk
271 let num_frames = boundaries.len();
272 (0..num_frames)
273 .into_par_iter()
274 .map(|i| {
275 let start = boundaries[i];
276 let end = if i + 1 < num_frames {
277 boundaries[i + 1]
278 } else {
279 file_contents.len()
280 };
281 let chunk = &file_contents[start..end];
282 let mut iter = ConFrameIterator::new(chunk);
283 match iter.next() {
284 Some(result) => result,
285 None => Err(error::ParseError::IncompleteFrame),
286 }
287 })
288 .collect()
289}