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