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    /// One frame of CON text. Filled then flushed with a single `write_all`
43    /// so the sink is not entered once per atom line.
44    scratch: Vec<u8>,
45}
46
47#[derive(Debug)]
48struct MetadataCacheEntry {
49    /// Snapshot of the inputs that fully determine the serialized JSON
50    /// metadata line. Cheap to clone; cheaper than re-serialising the
51    /// whole map on every frame.
52    spec_version: u32,
53    has_velocities: bool,
54    has_forces: bool,
55    has_energies: bool,
56    has_charges: bool,
57    has_spins: bool,
58    has_magmoms: bool,
59    metadata: std::collections::BTreeMap<String, serde_json::Value>,
60    /// Cached serialised metadata line (without trailing newline).
61    serialized: String,
62}
63
64impl MetadataCacheEntry {
65    fn matches(
66        &self,
67        spec_version: u32,
68        has_velocities: bool,
69        has_forces: bool,
70        has_energies: bool,
71        has_charges: bool,
72        has_spins: bool,
73        has_magmoms: bool,
74        metadata: &std::collections::BTreeMap<String, serde_json::Value>,
75    ) -> bool {
76        self.spec_version == spec_version
77            && self.has_velocities == has_velocities
78            && self.has_forces == has_forces
79            && self.has_energies == has_energies
80            && self.has_charges == has_charges
81            && self.has_spins == has_spins
82            && self.has_magmoms == has_magmoms
83            && &self.metadata == metadata
84    }
85}
86
87// General implementation for any type that implements `Write`.
88impl<W: Write> ConFrameWriter<W> {
89    /// Creates a new `ConFrameWriter` that wraps a given writer.
90    ///
91    /// # Arguments
92    ///
93    /// * `writer` - Any type that implements `std::io::Write`, e.g., a `File`.
94    pub fn new(writer: W) -> Self {
95        Self {
96            writer: BufWriter::new(writer),
97            precision: DEFAULT_FLOAT_PRECISION,
98            canonical: false,
99            metadata_cache: None,
100            scratch: Vec::with_capacity(16 * 1024),
101        }
102    }
103
104    /// Creates a new `ConFrameWriter` with a custom floating-point precision.
105    ///
106    /// # Arguments
107    ///
108    /// * `writer` - Any type that implements `std::io::Write`.
109    /// * `precision` - Number of decimal places for floating-point output.
110    pub fn with_precision(writer: W, precision: usize) -> Self {
111        Self {
112            writer: BufWriter::new(writer),
113            precision,
114            canonical: false,
115            metadata_cache: None,
116            scratch: Vec::with_capacity(16 * 1024),
117        }
118    }
119
120    /// Opt-in **canonical** serialization: BTree-ordered metadata keys in JSON,
121    /// fixed section order, stable float precision (default 6). Use for corpus
122    /// materialization and content-stable hashes; not required for on-disk fidelity
123    /// of spans preserved from `next_with_raw_span`.
124    pub fn canonical(mut self, on: bool) -> Self {
125        self.set_canonical(on);
126        self
127    }
128
129    /// Set or clear canonical mode on an existing writer (C ABI / FFI).
130    pub fn set_canonical(&mut self, on: bool) {
131        self.canonical = on;
132        if on {
133            self.metadata_cache = None;
134        }
135    }
136
137    /// Whether canonical serialization is enabled.
138    pub fn is_canonical(&self) -> bool {
139        self.canonical
140    }
141
142    fn refresh_metadata_cache(&mut self, frame: &ConFrame) {
143        let spec_version = frame.header.spec_version;
144        let has_vel = frame.has_velocities();
145        let has_frc = frame.has_forces();
146        let has_eng = frame.has_energies();
147        let has_chg = frame.has_charges();
148        let has_spn = frame.has_spins();
149        let has_mm = frame.has_magmoms();
150
151        let cache_hit = !self.canonical
152            && self.metadata_cache.as_ref().is_some_and(|c| {
153                c.matches(
154                    spec_version,
155                    has_vel,
156                    has_frc,
157                    has_eng,
158                    has_chg,
159                    has_spn,
160                    has_mm,
161                    &frame.header.metadata,
162                )
163            });
164        if cache_hit {
165            return;
166        }
167
168        let mut meta_obj = serde_json::Map::new();
169        meta_obj.insert(meta::CON_SPEC_VERSION.into(), json!(spec_version));
170        let mut sections = Vec::new();
171        if has_vel {
172            sections.push(json!(SECTION_VELOCITIES));
173        }
174        if has_frc {
175            sections.push(json!(SECTION_FORCES));
176        }
177        if has_eng {
178            sections.push(json!(SECTION_ENERGIES));
179        }
180        if has_chg {
181            sections.push(json!(SECTION_CHARGES));
182        }
183        if has_spn {
184            sections.push(json!(SECTION_SPINS));
185        }
186        if has_mm {
187            sections.push(json!(SECTION_MAGMOMS));
188        }
189        let validate = frame
190            .header
191            .metadata
192            .get(meta::VALIDATE)
193            .and_then(|value| value.as_bool())
194            .unwrap_or(false);
195        if !sections.is_empty() || validate {
196            meta_obj.insert(meta::SECTIONS.into(), json!(sections));
197        }
198        for (k, v) in &frame.header.metadata {
199            if k == meta::CON_SPEC_VERSION || k == meta::SECTIONS {
200                continue;
201            }
202            meta_obj.insert(k.clone(), v.clone());
203        }
204        if let Some(u) = meta_obj.get(meta::UNITS).cloned() {
205            if let Ok(c) = crate::units::canonicalize_units_object(&u) {
206                meta_obj.insert(meta::UNITS.into(), c);
207            }
208        }
209        if spec_version >= 3 {
210            let need_default = match meta_obj.get(meta::UNITS) {
211                None => true,
212                Some(u) => crate::units::validate_v3_units_metadata(u).is_err(),
213            };
214            if need_default {
215                meta_obj.insert(meta::UNITS.into(), crate::units::default_v3_units_json());
216            }
217        }
218        let serialized = serde_json::Value::Object(meta_obj).to_string();
219        self.metadata_cache = Some(MetadataCacheEntry {
220            spec_version,
221            has_velocities: has_vel,
222            has_forces: has_frc,
223            has_energies: has_eng,
224            has_charges: has_chg,
225            has_spins: has_spn,
226            has_magmoms: has_mm,
227            metadata: frame.header.metadata.clone(),
228            serialized,
229        });
230    }
231
232    /// Writes a single `ConFrame` to the output stream.
233    pub fn write_frame(&mut self, frame: &ConFrame) -> io::Result<()> {
234        let prec = self.precision;
235        self.refresh_metadata_cache(frame);
236        let meta_line = self
237            .metadata_cache
238            .as_ref()
239            .expect("metadata_cache populated above")
240            .serialized
241            .clone();
242        self.scratch.clear();
243        {
244        let buf = &mut self.scratch;
245
246        // --- Write the 9-line Header ---
247        let _ = writeln!(buf, "{}", frame.header.prebox_header.user);
248        let _ = writeln!(buf, "{meta_line}");
249        push_f64_prec(buf, frame.header.boxl[0], prec);
250        buf.push(b' ');
251        push_f64_prec(buf, frame.header.boxl[1], prec);
252        buf.push(b' ');
253        push_f64_prec(buf, frame.header.boxl[2], prec);
254        buf.push(b'\n');
255        push_f64_prec(buf, frame.header.angles[0], prec);
256        buf.push(b' ');
257        push_f64_prec(buf, frame.header.angles[1], prec);
258        buf.push(b' ');
259        push_f64_prec(buf, frame.header.angles[2], prec);
260        buf.push(b'\n');
261        let _ = writeln!(buf, "{}", frame.header.postbox_header[0]);
262        let _ = writeln!(buf, "{}", frame.header.postbox_header[1]);
263        let _ = writeln!(buf, "{}", frame.header.natm_types);
264
265        for (i, n) in frame.header.natms_per_type.iter().enumerate() {
266            if i > 0 {
267                buf.push(b' ');
268            }
269            push_u64(buf, *n as u64);
270        }
271        buf.push(b'\n');
272
273        for (i, m) in frame.header.masses_per_type.iter().enumerate() {
274            if i > 0 {
275                buf.push(b' ');
276            }
277            push_f64_prec(buf, *m, prec);
278        }
279        buf.push(b'\n');
280
281        // --- Write the Atom Data ---
282        let mut atom_idx_offset = 0;
283        for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
284            let symbol = &frame.atom_data[atom_idx_offset].symbol;
285            let _ = writeln!(buf, "{symbol}");
286            let _ = writeln!(buf, "Coordinates of Component {}", type_idx + 1);
287
288            for i in 0..num_atoms_in_type {
289                let atom = &frame.atom_data[atom_idx_offset + i];
290                push_xyz_line(
291                    buf,
292                    atom.x,
293                    atom.y,
294                    atom.z,
295                    prec,
296                    encode_fixed_bitmask(atom.fixed),
297                    atom.atom_id,
298                );
299            }
300            atom_idx_offset += num_atoms_in_type;
301        }
302
303        // --- Write optional velocity section ---
304        if frame.has_velocities() {
305            buf.push(b'\n');
306
307            let mut vel_idx_offset = 0;
308            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
309                let symbol = &frame.atom_data[vel_idx_offset].symbol;
310                let _ = writeln!(buf, "{symbol}");
311                let _ = writeln!(buf, "Velocities of Component {}", type_idx + 1);
312
313                for i in 0..num_atoms_in_type {
314                    let atom = &frame.atom_data[vel_idx_offset + i];
315                    let [vx, vy, vz] = atom.velocity.unwrap_or([0.0; 3]);
316                    push_xyz_line(
317                        buf,
318                        vx,
319                        vy,
320                        vz,
321                        prec,
322                        encode_fixed_bitmask(atom.fixed),
323                        atom.atom_id,
324                    );
325                }
326                vel_idx_offset += num_atoms_in_type;
327            }
328        }
329
330        // --- Write optional force section ---
331        if frame.has_forces() {
332            buf.push(b'\n');
333
334            let mut force_idx_offset = 0;
335            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
336                let symbol = &frame.atom_data[force_idx_offset].symbol;
337                let _ = writeln!(buf, "{symbol}");
338                let _ = writeln!(buf, "Forces of Component {}", type_idx + 1);
339
340                for i in 0..num_atoms_in_type {
341                    let atom = &frame.atom_data[force_idx_offset + i];
342                    let [fx, fy, fz] = atom.force.unwrap_or([0.0; 3]);
343                    push_xyz_line(
344                        buf,
345                        fx,
346                        fy,
347                        fz,
348                        prec,
349                        encode_fixed_bitmask(atom.fixed),
350                        atom.atom_id,
351                    );
352                }
353                force_idx_offset += num_atoms_in_type;
354            }
355        }
356
357        // --- Write optional energies section ---
358        if frame.has_energies() {
359            buf.push(b'\n');
360
361            let mut energy_idx_offset = 0;
362            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
363                let symbol = &frame.atom_data[energy_idx_offset].symbol;
364                let _ = writeln!(buf, "{symbol}");
365                let _ = writeln!(buf, "Energies of Component {}", type_idx + 1);
366
367                for i in 0..num_atoms_in_type {
368                    let atom = &frame.atom_data[energy_idx_offset + i];
369                    let e = atom.energy.unwrap_or(0.0);
370                    push_scalar_line(
371                        buf,
372                        e,
373                        prec,
374                        encode_fixed_bitmask(atom.fixed),
375                        atom.atom_id,
376                    );
377                }
378                energy_idx_offset += num_atoms_in_type;
379            }
380        }
381
382        if frame.has_charges() {
383            buf.push(b'\n');
384            let mut off = 0;
385            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
386                let symbol = &frame.atom_data[off].symbol;
387                let _ = writeln!(buf, "{symbol}");
388                let _ = writeln!(buf, "Charges of Component {}", type_idx + 1);
389                for i in 0..num_atoms_in_type {
390                    let atom = &frame.atom_data[off + i];
391                    let q = atom.charge.unwrap_or(0.0);
392                    push_scalar_line(
393                        buf,
394                        q,
395                        prec,
396                        encode_fixed_bitmask(atom.fixed),
397                        atom.atom_id,
398                    );
399                }
400                off += num_atoms_in_type;
401            }
402        }
403
404        if frame.has_spins() {
405            buf.push(b'\n');
406            let mut off = 0;
407            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
408                let symbol = &frame.atom_data[off].symbol;
409                let _ = writeln!(buf, "{symbol}");
410                let _ = writeln!(buf, "Spins of Component {}", type_idx + 1);
411                for i in 0..num_atoms_in_type {
412                    let atom = &frame.atom_data[off + i];
413                    let s = atom.spin.unwrap_or(0.0);
414                    push_scalar_line(
415                        buf,
416                        s,
417                        prec,
418                        encode_fixed_bitmask(atom.fixed),
419                        atom.atom_id,
420                    );
421                }
422                off += num_atoms_in_type;
423            }
424        }
425
426        if frame.has_magmoms() {
427            buf.push(b'\n');
428            let mut off = 0;
429            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
430                let symbol = &frame.atom_data[off].symbol;
431                let _ = writeln!(buf, "{symbol}");
432                let _ = writeln!(buf, "Magmoms of Component {}", type_idx + 1);
433                for i in 0..num_atoms_in_type {
434                    let atom = &frame.atom_data[off + i];
435                    let [mx, my, mz] = atom.magmom.unwrap_or([0.0; 3]);
436                    push_xyz_line(
437                        buf,
438                        mx,
439                        my,
440                        mz,
441                        prec,
442                        encode_fixed_bitmask(atom.fixed),
443                        atom.atom_id,
444                    );
445                }
446                off += num_atoms_in_type;
447            }
448        }
449        }
450
451        self.writer.write_all(&self.scratch)
452    }
453
454    /// Writes all frames from an iterator to the output stream.
455    ///
456    /// This is the most convenient way to write a multi-frame file.
457    pub fn extend<'a>(&mut self, frames: impl Iterator<Item = &'a ConFrame>) -> io::Result<()> {
458        for frame in frames {
459            self.write_frame(frame)?;
460        }
461        Ok(())
462    }
463}
464
465fn push_u64(buf: &mut Vec<u8>, n: u64) {
466    push_u128(buf, u128::from(n));
467}
468
469fn push_u128(buf: &mut Vec<u8>, mut n: u128) {
470    let mut tmp = [0u8; 40];
471    let mut i = 40;
472    if n == 0 {
473        buf.push(b'0');
474        return;
475    }
476    while n > 0 {
477        i -= 1;
478        tmp[i] = b'0' + (n % 10) as u8;
479        n /= 10;
480    }
481    buf.extend_from_slice(&tmp[i..]);
482}
483
484/// Fixed-point `f64` with `prec` digits after the decimal, matching `{:.prec$}`.
485/// Non-finite values and `prec > 17` fall back to `std::fmt`.
486fn push_f64_prec(buf: &mut Vec<u8>, v: f64, prec: usize) {
487    // Default writes are prec=6. Higher precision (lossless 17) stays on
488    // std::fmt so binary leftovers match `{:.prec$}`.
489    if !v.is_finite() || prec != 6 {
490        let _ = write!(buf, "{v:.prec$}");
491        return;
492    }
493    if v.is_sign_negative() {
494        buf.push(b'-');
495    }
496    let ax = v.abs();
497    if prec == 0 {
498        push_u128(buf, ax.round() as u128);
499        return;
500    }
501    let scale = 10u128.pow(prec as u32);
502    let n = (ax * scale as f64).round() as u128;
503    let int_part = n / scale;
504    let frac = n % scale;
505    push_u128(buf, int_part);
506    buf.push(b'.');
507    let mut tmp = [b'0'; 20];
508    let mut x = frac;
509    let mut i = prec;
510    while i > 0 {
511        i -= 1;
512        tmp[i] = b'0' + (x % 10) as u8;
513        x /= 10;
514    }
515    buf.extend_from_slice(&tmp[..prec]);
516}
517
518fn push_xyz_line(buf: &mut Vec<u8>, x: f64, y: f64, z: f64, prec: usize, fixed: u8, atom_id: u64) {
519    push_f64_prec(buf, x, prec);
520    buf.push(b' ');
521    push_f64_prec(buf, y, prec);
522    buf.push(b' ');
523    push_f64_prec(buf, z, prec);
524    buf.push(b' ');
525    push_u64(buf, u64::from(fixed));
526    buf.push(b' ');
527    push_u64(buf, atom_id);
528    buf.push(b'\n');
529}
530
531fn push_scalar_line(buf: &mut Vec<u8>, v: f64, prec: usize, fixed: u8, atom_id: u64) {
532    push_f64_prec(buf, v, prec);
533    buf.push(b' ');
534    push_u64(buf, u64::from(fixed));
535    buf.push(b' ');
536    push_u64(buf, atom_id);
537    buf.push(b'\n');
538}
539
540// Implementation block specifically for when the writer is a `File`.
541impl ConFrameWriter<File> {
542    /// Creates a new `ConFrameWriter` that writes to a file at the given path.
543    ///
544    /// This is a convenience function that creates the file and wraps it.
545    pub fn from_path<P: AsRef<Path>>(path: P) -> io::Result<Self> {
546        let file = File::create(path)?;
547        Ok(Self::new(file))
548    }
549
550    /// Creates a new `ConFrameWriter` that writes to a file with a custom precision.
551    pub fn from_path_with_precision<P: AsRef<Path>>(path: P, precision: usize) -> io::Result<Self> {
552        let file = File::create(path)?;
553        Ok(Self::with_precision(file, precision))
554    }
555}
556
557// Gzip-compressed writer constructors.
558impl ConFrameWriter<flate2::write::GzEncoder<File>> {
559    /// Creates a gzip-compressed writer for the given path.
560    pub fn from_path_gzip<P: AsRef<Path>>(path: P) -> io::Result<Self> {
561        let encoder = crate::compression::gzip_writer(path.as_ref())?;
562        Ok(Self::new(encoder))
563    }
564
565    /// Creates a gzip-compressed writer with custom precision.
566    pub fn from_path_gzip_with_precision<P: AsRef<Path>>(
567        path: P,
568        precision: usize,
569    ) -> io::Result<Self> {
570        let encoder = crate::compression::gzip_writer(path.as_ref())?;
571        Ok(Self::with_precision(encoder, precision))
572    }
573}
574
575// Zstd-compressed writer constructors. Available only with the `zstd`
576// Cargo feature.
577#[cfg(feature = "zstd")]
578impl ConFrameWriter<zstd::stream::write::AutoFinishEncoder<'static, File>> {
579    /// Creates a zstd-compressed writer for the given path.
580    pub fn from_path_zstd<P: AsRef<Path>>(path: P) -> io::Result<Self> {
581        let encoder = crate::compression::zstd_writer(path.as_ref())?;
582        Ok(Self::new(encoder))
583    }
584
585    /// Creates a zstd-compressed writer with custom precision.
586    pub fn from_path_zstd_with_precision<P: AsRef<Path>>(
587        path: P,
588        precision: usize,
589    ) -> io::Result<Self> {
590        let encoder = crate::compression::zstd_writer(path.as_ref())?;
591        Ok(Self::with_precision(encoder, precision))
592    }
593}
594
595#[cfg(test)]
596mod float_format_tests {
597    use super::push_f64_prec;
598
599    fn formatted(v: f64, prec: usize) -> String {
600        let mut buf = Vec::new();
601        push_f64_prec(&mut buf, v, prec);
602        String::from_utf8(buf).expect("utf8")
603    }
604
605    #[test]
606    fn matches_std_fixed_precision() {
607        let vals = [
608            0.0,
609            -0.0,
610            1.0,
611            -1.0,
612            0.9045,
613            6.975_299_999_999_995,
614            63.546,
615            1.008,
616            15.3456,
617            90.0,
618            218.0,
619            1e-6,
620            -1.23456789,
621            10.0,
622        ];
623        for prec in [0usize, 6] {
624            for v in vals {
625                let got = formatted(v, prec);
626                let exp = format!("{v:.prec$}");
627                assert_eq!(got, exp, "v={v:?} prec={prec}");
628            }
629        }
630    }
631}