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