Skip to main content

readcon_core/
writer.rs

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