Skip to main content

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}