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 /// Raw bytes of the source string, alongside a cursor. Used by
23 /// [`Self::forward_fast`] to skip frames via direct memchr-bulk
24 /// `\n` lookup instead of advancing the line iterator one call at
25 /// a time. The cursor is only nudged forward from `forward_fast`;
26 /// callers that mix `next()` / `forward()` with `forward_fast`
27 /// will see the cursor reset to whatever the line iterator's
28 /// current view of the slice is.
29 bytes: &'a [u8],
30 cursor: usize,
31}
32
33impl<'a> ConFrameIterator<'a> {
34 /// Creates a new `ConFrameIterator` from a string slice of the entire file.
35 ///
36 /// # Arguments
37 ///
38 /// * `file_contents` - A string slice containing the text of one or more `.con` frames.
39 pub fn new(file_contents: &'a str) -> Self {
40 ConFrameIterator {
41 lines: file_contents.lines().peekable(),
42 bytes: file_contents.as_bytes(),
43 cursor: 0,
44 }
45 }
46
47 /// Bulk-skips `n` lines starting at `self.cursor` using memchr to
48 /// find each `\n`. Returns `Ok(())` when `n` lines were consumed,
49 /// `Err(IncompleteFrame)` when the byte slice ran out first.
50 ///
51 /// Used by [`Self::forward_fast`] to avoid the per-line iterator
52 /// overhead of `Peekable<Lines>::next()` on workloads that only
53 /// want to skip past the current frame.
54 fn advance_lines(&mut self, n: usize) -> Result<(), error::ParseError> {
55 for _ in 0..n {
56 let rest = &self.bytes[self.cursor..];
57 match memchr::memchr(b'\n', rest) {
58 Some(pos) => self.cursor += pos + 1,
59 None => {
60 if rest.is_empty() {
61 return Err(error::ParseError::IncompleteFrame);
62 }
63 self.cursor = self.bytes.len();
64 return Err(error::ParseError::IncompleteFrame);
65 }
66 }
67 }
68 Ok(())
69 }
70
71 /// Returns the bytes of the current line at `self.cursor` (without
72 /// the trailing `\n`) as a `&str`, advancing the cursor past the
73 /// newline.
74 fn read_line_str(&mut self) -> Option<&'a str> {
75 let rest = &self.bytes[self.cursor..];
76 if rest.is_empty() {
77 return None;
78 }
79 let (line_bytes, advance) = match memchr::memchr(b'\n', rest) {
80 Some(pos) => (&rest[..pos], pos + 1),
81 None => (rest, rest.len()),
82 };
83 // Strip optional trailing \r for Windows line endings.
84 let trimmed = if line_bytes.last() == Some(&b'\r') {
85 &line_bytes[..line_bytes.len() - 1]
86 } else {
87 line_bytes
88 };
89 // SAFETY: source was a `&str` so any UTF-8 prefix terminated by
90 // an ASCII byte (`\n`, `\r`) remains valid UTF-8.
91 let line = unsafe { std::str::from_utf8_unchecked(trimmed) };
92 self.cursor += advance;
93 Some(line)
94 }
95
96 /// memchr-backed equivalent of [`Self::forward`]. Skips the next
97 /// frame without fully parsing its atom data, walking the
98 /// underlying byte slice with `memchr` directly instead of the
99 /// peekable line iterator. The two methods are not interleavable:
100 /// once the caller starts using `forward_fast`, switching to
101 /// `next()` or `forward()` resets to the line iterator's view and
102 /// loses any progress the cursor made.
103 pub fn forward_fast(&mut self) -> Option<Result<(), error::ParseError>> {
104 if self.cursor >= self.bytes.len() {
105 return None;
106 }
107 // Lines 1..=6 of the header are skipped wholesale.
108 if let Err(e) = self.advance_lines(6) {
109 return Some(Err(e));
110 }
111 // Line 7: natm_types.
112 let natm_types: usize = match self.read_line_str() {
113 Some(line) => match crate::parser::parse_line_of_n::<usize>(line, 1) {
114 Ok(v) => v[0],
115 Err(e) => return Some(Err(e)),
116 },
117 None => return Some(Err(error::ParseError::IncompleteHeader)),
118 };
119 // Line 8: natms_per_type.
120 let natms_per_type: Vec<usize> = match self.read_line_str() {
121 Some(line) => match crate::parser::parse_line_of_n(line, natm_types) {
122 Ok(v) => v,
123 Err(e) => return Some(Err(e)),
124 },
125 None => return Some(Err(error::ParseError::IncompleteHeader)),
126 };
127 // Line 9: masses_per_type, consumed.
128 if let Err(e) = self.advance_lines(1) {
129 return Some(Err(e));
130 }
131 let total_atoms: usize = natms_per_type.iter().sum();
132 let coord_block_lines = total_atoms + natm_types * 2;
133 if let Err(e) = self.advance_lines(coord_block_lines) {
134 return Some(Err(e));
135 }
136 // Optional sections: blank line + same-shape block, repeated.
137 loop {
138 let rest = &self.bytes[self.cursor..];
139 // Peek the next line without advancing the cursor.
140 let next_eol = memchr::memchr(b'\n', rest);
141 let line = match next_eol {
142 Some(pos) => &rest[..pos],
143 None => rest,
144 };
145 let is_blank = line
146 .iter()
147 .all(|b| matches!(b, b' ' | b'\t' | b'\r'));
148 if !is_blank || line.is_empty() && self.cursor >= self.bytes.len() {
149 break;
150 }
151 if !is_blank {
152 break;
153 }
154 // Consume the blank separator and the section block.
155 self.cursor += next_eol.map(|p| p + 1).unwrap_or(rest.len());
156 if let Err(e) = self.advance_lines(coord_block_lines) {
157 return Some(Err(e));
158 }
159 }
160 Some(Ok(()))
161 }
162
163 /// Skips the next frame without fully parsing its atomic data.
164 ///
165 /// This is more efficient than `next()` if you only need to advance the
166 /// iterator. It reads the frame's header to determine how many lines to skip,
167 /// including any velocity section if present.
168 ///
169 /// # Returns
170 ///
171 /// * `Some(Ok(()))` on a successful skip.
172 /// * `Some(Err(ParseError::...))` if there's an error parsing the header.
173 /// * `None` if the iterator is already at the end.
174 pub fn forward(&mut self) -> Option<Result<(), error::ParseError>> {
175 // Skip frame by parsing only required header fields to avoid full parsing overhead
176 self.lines.peek()?;
177
178 // Manually consume the first 6 lines of the header, which we don't need for skipping.
179 for _ in 0..6 {
180 if self.lines.next().is_none() {
181 return Some(Err(error::ParseError::IncompleteHeader));
182 }
183 }
184
185 // Line 7: natm_types. We need to parse this.
186 let natm_types: usize = match self.lines.next() {
187 Some(line) => match crate::parser::parse_line_of_n::<usize>(line, 1) {
188 Ok(v) => v[0],
189 Err(e) => return Some(Err(e)),
190 },
191 None => return Some(Err(error::ParseError::IncompleteHeader)),
192 };
193
194 // Line 8: natms_per_type. We need this to sum the total number of atoms.
195 let natms_per_type: Vec<usize> = match self.lines.next() {
196 Some(line) => match crate::parser::parse_line_of_n(line, natm_types) {
197 Ok(v) => v,
198 Err(e) => return Some(Err(e)),
199 },
200 None => return Some(Err(error::ParseError::IncompleteHeader)),
201 };
202
203 // Line 9: masses_per_type. We just need to consume this line.
204 if self.lines.next().is_none() {
205 return Some(Err(error::ParseError::IncompleteHeader));
206 }
207
208 // Calculate how many more lines to skip for coordinate blocks.
209 let total_atoms: usize = natms_per_type.iter().sum();
210 // For each atom type, there is a symbol line and a "Coordinates..." line.
211 let non_atom_lines = natm_types * 2;
212 let lines_to_skip = total_atoms + non_atom_lines;
213
214 // Advance the iterator by skipping the coordinate block lines.
215 for _ in 0..lines_to_skip {
216 if self.lines.next().is_none() {
217 // The file ended before the header's promise was fulfilled.
218 return Some(Err(error::ParseError::IncompleteFrame));
219 }
220 }
221
222 // Skip additional sections (velocities, forces).
223 // Each section has: blank separator + same structure as coordinate blocks.
224 // We don't have access to the parsed sections list here (we only parsed
225 // the header minimally), so we detect sections by peeking for blank separators.
226 loop {
227 match self.lines.peek() {
228 Some(line) if line.trim().is_empty() => {
229 self.lines.next(); // consume blank separator
230 let section_lines = total_atoms + non_atom_lines;
231 for _ in 0..section_lines {
232 if self.lines.next().is_none() {
233 return Some(Err(error::ParseError::IncompleteFrame));
234 }
235 }
236 }
237 _ => break,
238 }
239 }
240
241 Some(Ok(()))
242 }
243}
244
245impl<'a> Iterator for ConFrameIterator<'a> {
246 /// The type of item yielded by the iterator.
247 ///
248 /// Each item is a `Result` that contains a successfully parsed `ConFrame` or a
249 /// `ParseError` if the frame's data is malformed.
250 type Item = Result<types::ConFrame, error::ParseError>;
251
252 /// Advances the iterator and attempts to parse the next frame.
253 ///
254 /// This method will return `None` only when there are no more lines to consume.
255 /// If there are lines but they do not form a complete frame, it will return
256 /// `Some(Err(ParseError::...))`.
257 fn next(&mut self) -> Option<Self::Item> {
258 // If there are no more lines at all, the iterator is exhausted.
259 self.lines.peek()?;
260 // Otherwise, attempt to parse the next frame from the available lines.
261 let mut frame = match parse_single_frame(&mut self.lines) {
262 Ok(f) => f,
263 Err(e) => return Some(Err(e)),
264 };
265 // Parse declared sections (velocities, forces) or fall back to legacy velocity detection
266 match parse_declared_sections(&mut self.lines, &mut frame.header, &mut frame.atom_data) {
267 Ok(_) => {}
268 Err(e) => return Some(Err(e)),
269 }
270 Some(Ok(frame))
271 }
272}
273
274/// Reads all frames from a file.
275///
276/// For files smaller than 64 KiB, uses a simple `read_to_string` to avoid
277/// the fixed overhead of mmap (VMA creation, page fault, munmap). For larger
278/// trajectory files, uses memory-mapped I/O to let the OS page cache handle
279/// the data.
280pub fn read_all_frames(path: &Path) -> Result<Vec<types::ConFrame>, Box<dyn std::error::Error>> {
281 let contents = crate::compression::read_file_contents(path)?;
282 let text = contents.as_str()?;
283 let iter = ConFrameIterator::new(text);
284 let frames: Result<Vec<_>, _> = iter.collect();
285 Ok(frames?)
286}
287
288/// Reads only the first frame from a file.
289///
290/// More efficient than `read_all_frames` for single-frame access because it
291/// stops parsing after the first frame rather than collecting all of them.
292pub fn read_first_frame(path: &Path) -> Result<types::ConFrame, Box<dyn std::error::Error>> {
293 let contents = crate::compression::read_file_contents(path)?;
294 let text = contents.as_str()?;
295 let mut iter = ConFrameIterator::new(text);
296 match iter.next() {
297 Some(Ok(frame)) => Ok(frame),
298 Some(Err(e)) => Err(Box::new(e)),
299 None => Err("No frames found in file".into()),
300 }
301}
302
303/// Parses frames in parallel using rayon, splitting on frame boundaries.
304///
305/// Phase 1: sequential O(N) scan via memchr-backed
306/// [`ConFrameIterator::forward_fast`] to find byte offsets of every
307/// frame's start. The previous implementation built a `Vec<&str>` of
308/// every line and called `lines[..i].iter().map(|l| l.len() +
309/// 1).sum()` on every frame, which is O(N^2) in line count and
310/// dominated runtime on multi-frame trajectories.
311///
312/// Phase 2: parallel parse of each frame slice using rayon.
313///
314/// Requires the `parallel` feature.
315#[cfg(feature = "parallel")]
316pub fn parse_frames_parallel(
317 file_contents: &str,
318) -> Vec<Result<types::ConFrame, error::ParseError>> {
319 use rayon::prelude::*;
320
321 // Phase 1: walk the file once with forward_fast and snapshot the
322 // cursor before each frame.
323 let mut boundaries: Vec<usize> = Vec::new();
324 let mut scanner = ConFrameIterator::new(file_contents);
325 loop {
326 let start = scanner.cursor;
327 if start >= scanner.bytes.len() {
328 break;
329 }
330 boundaries.push(start);
331 match scanner.forward_fast() {
332 Some(Ok(())) => {}
333 Some(Err(_)) | None => break,
334 }
335 }
336
337 // Phase 2: parallel parse each frame chunk
338 let num_frames = boundaries.len();
339 (0..num_frames)
340 .into_par_iter()
341 .map(|i| {
342 let start = boundaries[i];
343 let end = if i + 1 < num_frames {
344 boundaries[i + 1]
345 } else {
346 file_contents.len()
347 };
348 let chunk = &file_contents[start..end];
349 let mut iter = ConFrameIterator::new(chunk);
350 match iter.next() {
351 Some(result) => result,
352 None => Err(error::ParseError::IncompleteFrame),
353 }
354 })
355 .collect()
356}