Skip to main content

readcon_core/
writer.rs

1use crate::types::{
2    ConFrame, SECTION_ENERGIES, SECTION_FORCES, SECTION_VELOCITIES, encode_fixed_bitmask, meta,
3};
4use serde_json::json;
5use std::fs::File;
6use std::io::{self, BufWriter, Write};
7use std::path::Path;
8
9/// Default floating-point precision used for writing coordinates, cell dimensions, and masses.
10const DEFAULT_FLOAT_PRECISION: usize = 6;
11
12/// A writer that can serialize and write `ConFrame` objects to any output stream.
13///
14/// This struct encapsulates a writer (like a file) and provides a high-level API
15/// for writing simulation frames in the `.con` format.
16///
17/// # Example
18/// ```no_run
19/// # use std::fs::File;
20/// # use readcon_core::types::ConFrame;
21/// # use readcon_core::writer::ConFrameWriter;
22/// # let frames: Vec<ConFrame> = Vec::new();
23/// let mut writer = ConFrameWriter::from_path("output.con").unwrap();
24/// writer.extend(frames.iter()).unwrap();
25/// ```
26pub struct ConFrameWriter<W: Write> {
27    writer: BufWriter<W>,
28    precision: usize,
29    /// Cache for the JSON metadata line: when consecutive frames share
30    /// the same (spec_version, sections-set, metadata) triple the
31    /// serialized JSON object is identical, so reusing the cached
32    /// string skips the per-frame `serde_json::Map::insert` rebuild
33    /// and re-serialisation. Hot for trajectory writes where every
34    /// frame has the same `units` / `potential` / `validate` keys.
35    metadata_cache: Option<MetadataCacheEntry>,
36}
37
38#[derive(Debug)]
39struct MetadataCacheEntry {
40    /// Snapshot of the inputs that fully determine the serialized JSON
41    /// metadata line. Cheap to clone; cheaper than re-serialising the
42    /// whole map on every frame.
43    spec_version: u32,
44    has_velocities: bool,
45    has_forces: bool,
46    has_energies: bool,
47    metadata: std::collections::BTreeMap<String, serde_json::Value>,
48    /// Cached serialised metadata line (without trailing newline).
49    serialized: String,
50}
51
52impl MetadataCacheEntry {
53    fn matches(
54        &self,
55        spec_version: u32,
56        has_velocities: bool,
57        has_forces: bool,
58        has_energies: bool,
59        metadata: &std::collections::BTreeMap<String, serde_json::Value>,
60    ) -> bool {
61        self.spec_version == spec_version
62            && self.has_velocities == has_velocities
63            && self.has_forces == has_forces
64            && self.has_energies == has_energies
65            && &self.metadata == metadata
66    }
67}
68
69// General implementation for any type that implements `Write`.
70impl<W: Write> ConFrameWriter<W> {
71    /// Creates a new `ConFrameWriter` that wraps a given writer.
72    ///
73    /// # Arguments
74    ///
75    /// * `writer` - Any type that implements `std::io::Write`, e.g., a `File`.
76    pub fn new(writer: W) -> Self {
77        Self {
78            writer: BufWriter::new(writer),
79            precision: DEFAULT_FLOAT_PRECISION,
80            metadata_cache: None,
81        }
82    }
83
84    /// Creates a new `ConFrameWriter` with a custom floating-point precision.
85    ///
86    /// # Arguments
87    ///
88    /// * `writer` - Any type that implements `std::io::Write`.
89    /// * `precision` - Number of decimal places for floating-point output.
90    pub fn with_precision(writer: W, precision: usize) -> Self {
91        Self {
92            writer: BufWriter::new(writer),
93            precision,
94            metadata_cache: None,
95        }
96    }
97
98    /// Writes a single `ConFrame` to the output stream.
99    pub fn write_frame(&mut self, frame: &ConFrame) -> io::Result<()> {
100        let prec = self.precision;
101
102        // --- Write the 9-line Header ---
103        writeln!(self.writer, "{}", frame.header.prebox_header.user)?;
104
105        // Line 2: serialised JSON metadata. The serialisation is
106        // deterministic in (spec_version, has_*, metadata), so we
107        // cache the previous frame's result and reuse it when the
108        // inputs are unchanged. For trajectory writes where every
109        // frame shares the same `units` / `potential` / `validate`
110        // keys this avoids rebuilding and re-serialising the JSON
111        // object on every frame.
112        let spec_version = frame.header.spec_version;
113        let has_vel = frame.has_velocities();
114        let has_frc = frame.has_forces();
115        let has_eng = frame.has_energies();
116
117        let cache_hit = self
118            .metadata_cache
119            .as_ref()
120            .is_some_and(|c| c.matches(spec_version, has_vel, has_frc, has_eng, &frame.header.metadata));
121
122        if !cache_hit {
123            let mut meta_obj = serde_json::Map::new();
124            meta_obj.insert(
125                meta::CON_SPEC_VERSION.into(),
126                json!(spec_version),
127            );
128            let mut sections = Vec::new();
129            if has_vel {
130                sections.push(json!(SECTION_VELOCITIES));
131            }
132            if has_frc {
133                sections.push(json!(SECTION_FORCES));
134            }
135            if has_eng {
136                sections.push(json!(SECTION_ENERGIES));
137            }
138            let validate = frame
139                .header
140                .metadata
141                .get(meta::VALIDATE)
142                .and_then(|value| value.as_bool())
143                .unwrap_or(false);
144            if !sections.is_empty() || validate {
145                meta_obj.insert(meta::SECTIONS.into(), json!(sections));
146            }
147            for (k, v) in &frame.header.metadata {
148                if k == meta::CON_SPEC_VERSION || k == meta::SECTIONS {
149                    continue;
150                }
151                meta_obj.insert(k.clone(), v.clone());
152            }
153            let serialized = serde_json::Value::Object(meta_obj).to_string();
154            self.metadata_cache = Some(MetadataCacheEntry {
155                spec_version,
156                has_velocities: has_vel,
157                has_forces: has_frc,
158                has_energies: has_eng,
159                metadata: frame.header.metadata.clone(),
160                serialized,
161            });
162        }
163
164        let cached = self
165            .metadata_cache
166            .as_ref()
167            .expect("metadata_cache populated above");
168        writeln!(self.writer, "{}", cached.serialized)?;
169        writeln!(
170            self.writer,
171            "{1:.0$} {2:.0$} {3:.0$}",
172            prec, frame.header.boxl[0], frame.header.boxl[1], frame.header.boxl[2]
173        )?;
174        writeln!(
175            self.writer,
176            "{1:.0$} {2:.0$} {3:.0$}",
177            prec, frame.header.angles[0], frame.header.angles[1], frame.header.angles[2]
178        )?;
179        writeln!(self.writer, "{}", frame.header.postbox_header[0])?;
180        writeln!(self.writer, "{}", frame.header.postbox_header[1])?;
181        writeln!(self.writer, "{}", frame.header.natm_types)?;
182
183        let natms_str: Vec<String> = frame
184            .header
185            .natms_per_type
186            .iter()
187            .map(|n| n.to_string())
188            .collect();
189        writeln!(self.writer, "{}", natms_str.join(" "))?;
190
191        let masses_str: Vec<String> = frame
192            .header
193            .masses_per_type
194            .iter()
195            .map(|m| format!("{:.1$}", m, prec))
196            .collect();
197        writeln!(self.writer, "{}", masses_str.join(" "))?;
198
199        // --- Write the Atom Data ---
200        let mut atom_idx_offset = 0;
201        for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
202            let symbol = &frame.atom_data[atom_idx_offset].symbol;
203            writeln!(self.writer, "{}", symbol)?;
204            writeln!(self.writer, "Coordinates of Component {}", type_idx + 1)?;
205
206            for i in 0..num_atoms_in_type {
207                let atom = &frame.atom_data[atom_idx_offset + i];
208                writeln!(
209                    self.writer,
210                    "{x:.prec$} {y:.prec$} {z:.prec$} {fixed_flag} {atom_id}",
211                    prec = prec,
212                    x = atom.x,
213                    y = atom.y,
214                    z = atom.z,
215                    fixed_flag = encode_fixed_bitmask(atom.fixed),
216                    atom_id = atom.atom_id
217                )?;
218            }
219            atom_idx_offset += num_atoms_in_type;
220        }
221
222        // --- Write optional velocity section ---
223        if frame.has_velocities() {
224            // Blank separator line between coordinates and velocities
225            writeln!(self.writer)?;
226
227            let mut vel_idx_offset = 0;
228            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
229                let symbol = &frame.atom_data[vel_idx_offset].symbol;
230                writeln!(self.writer, "{}", symbol)?;
231                writeln!(self.writer, "Velocities of Component {}", type_idx + 1)?;
232
233                for i in 0..num_atoms_in_type {
234                    let atom = &frame.atom_data[vel_idx_offset + i];
235                    let [vx, vy, vz] = atom.velocity.unwrap_or([0.0; 3]);
236                    writeln!(
237                        self.writer,
238                        "{vx:.prec$} {vy:.prec$} {vz:.prec$} {fixed_flag} {atom_id}",
239                        prec = prec,
240                        fixed_flag = encode_fixed_bitmask(atom.fixed),
241                        atom_id = atom.atom_id
242                    )?;
243                }
244                vel_idx_offset += num_atoms_in_type;
245            }
246        }
247
248        // --- Write optional force section ---
249        if frame.has_forces() {
250            // Blank separator line
251            writeln!(self.writer)?;
252
253            let mut force_idx_offset = 0;
254            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
255                let symbol = &frame.atom_data[force_idx_offset].symbol;
256                writeln!(self.writer, "{}", symbol)?;
257                writeln!(self.writer, "Forces of Component {}", type_idx + 1)?;
258
259                for i in 0..num_atoms_in_type {
260                    let atom = &frame.atom_data[force_idx_offset + i];
261                    let [fx, fy, fz] = atom.force.unwrap_or([0.0; 3]);
262                    writeln!(
263                        self.writer,
264                        "{fx:.prec$} {fy:.prec$} {fz:.prec$} {fixed_flag} {atom_id}",
265                        prec = prec,
266                        fixed_flag = encode_fixed_bitmask(atom.fixed),
267                        atom_id = atom.atom_id
268                    )?;
269                }
270                force_idx_offset += num_atoms_in_type;
271            }
272        }
273
274        // --- Write optional energies section ---
275        if frame.has_energies() {
276            writeln!(self.writer)?;
277
278            let mut energy_idx_offset = 0;
279            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
280                let symbol = &frame.atom_data[energy_idx_offset].symbol;
281                writeln!(self.writer, "{}", symbol)?;
282                writeln!(self.writer, "Energies of Component {}", type_idx + 1)?;
283
284                for i in 0..num_atoms_in_type {
285                    let atom = &frame.atom_data[energy_idx_offset + i];
286                    let e = atom.energy.unwrap_or(0.0);
287                    writeln!(
288                        self.writer,
289                        "{e:.prec$} {fixed_flag} {atom_id}",
290                        prec = prec,
291                        fixed_flag = encode_fixed_bitmask(atom.fixed),
292                        atom_id = atom.atom_id
293                    )?;
294                }
295                energy_idx_offset += num_atoms_in_type;
296            }
297        }
298
299        Ok(())
300    }
301
302    /// Writes all frames from an iterator to the output stream.
303    ///
304    /// This is the most convenient way to write a multi-frame file.
305    pub fn extend<'a>(&mut self, frames: impl Iterator<Item = &'a ConFrame>) -> io::Result<()> {
306        for frame in frames {
307            self.write_frame(frame)?;
308        }
309        Ok(())
310    }
311}
312
313// Implementation block specifically for when the writer is a `File`.
314impl ConFrameWriter<File> {
315    /// Creates a new `ConFrameWriter` that writes to a file at the given path.
316    ///
317    /// This is a convenience function that creates the file and wraps it.
318    pub fn from_path<P: AsRef<Path>>(path: P) -> io::Result<Self> {
319        let file = File::create(path)?;
320        Ok(Self::new(file))
321    }
322
323    /// Creates a new `ConFrameWriter` that writes to a file with a custom precision.
324    pub fn from_path_with_precision<P: AsRef<Path>>(path: P, precision: usize) -> io::Result<Self> {
325        let file = File::create(path)?;
326        Ok(Self::with_precision(file, precision))
327    }
328}
329
330// Gzip-compressed writer constructors.
331impl ConFrameWriter<flate2::write::GzEncoder<File>> {
332    /// Creates a gzip-compressed writer for the given path.
333    pub fn from_path_gzip<P: AsRef<Path>>(path: P) -> io::Result<Self> {
334        let encoder = crate::compression::gzip_writer(path.as_ref())?;
335        Ok(Self::new(encoder))
336    }
337
338    /// Creates a gzip-compressed writer with custom precision.
339    pub fn from_path_gzip_with_precision<P: AsRef<Path>>(
340        path: P,
341        precision: usize,
342    ) -> io::Result<Self> {
343        let encoder = crate::compression::gzip_writer(path.as_ref())?;
344        Ok(Self::with_precision(encoder, precision))
345    }
346}
347
348// Zstd-compressed writer constructors. Available only with the `zstd`
349// Cargo feature.
350#[cfg(feature = "zstd")]
351impl ConFrameWriter<zstd::stream::write::AutoFinishEncoder<'static, File>> {
352    /// Creates a zstd-compressed writer for the given path.
353    pub fn from_path_zstd<P: AsRef<Path>>(path: P) -> io::Result<Self> {
354        let encoder = crate::compression::zstd_writer(path.as_ref())?;
355        Ok(Self::new(encoder))
356    }
357
358    /// Creates a zstd-compressed writer with custom precision.
359    pub fn from_path_zstd_with_precision<P: AsRef<Path>>(
360        path: P,
361        precision: usize,
362    ) -> io::Result<Self> {
363        let encoder = crate::compression::zstd_writer(path.as_ref())?;
364        Ok(Self::with_precision(encoder, precision))
365    }
366}