Skip to main content

oxiphysics_io/
binary_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Binary data I/O for OxiPhysics trajectory files.
5//!
6//! Provides a compact binary header, frame-level read/write of atomic
7//! positions, little-endian byte-conversion helpers, and lossy quantized
8//! compression for trajectory data.
9
10use std::io::{Read, Write};
11
12// ── Header ────────────────────────────────────────────────────────────────────
13
14/// Binary file header for an OxiPhysics trajectory file.
15#[derive(Debug, Clone, PartialEq)]
16pub struct BinaryHeader {
17    /// Magic bytes identifying the file format (e.g. `b"OXIP"`).
18    pub magic: [u8; 4],
19    /// Format version number.
20    pub version: u32,
21    /// Number of atoms per frame.
22    pub n_atoms: u32,
23    /// Total number of frames in the file.
24    pub n_frames: u32,
25    /// Time step in picoseconds.
26    pub dt: f64,
27    /// Endianness flag: `0` = little-endian, `1` = big-endian.
28    pub endian: u8,
29}
30
31/// Byte size of a serialised [`BinaryHeader`].
32pub const HEADER_SIZE: usize = 4 + 4 + 4 + 4 + 8 + 1; // 25 bytes
33
34/// Write a [`BinaryHeader`] to `path`, creating or truncating the file.
35pub fn write_binary_header(path: &str, header: &BinaryHeader) -> Result<(), std::io::Error> {
36    let mut file = std::fs::File::create(path)?;
37    file.write_all(&header.magic)?;
38    file.write_all(&header.version.to_le_bytes())?;
39    file.write_all(&header.n_atoms.to_le_bytes())?;
40    file.write_all(&header.n_frames.to_le_bytes())?;
41    file.write_all(&f64_to_bytes_le(header.dt))?;
42    file.write_all(&[header.endian])?;
43    Ok(())
44}
45
46/// Read a [`BinaryHeader`] from `path`.
47pub fn read_binary_header(path: &str) -> Result<BinaryHeader, std::io::Error> {
48    let mut file = std::fs::File::open(path)?;
49    let mut magic = [0u8; 4];
50    file.read_exact(&mut magic)?;
51    let mut buf4 = [0u8; 4];
52    file.read_exact(&mut buf4)?;
53    let version = u32::from_le_bytes(buf4);
54    file.read_exact(&mut buf4)?;
55    let n_atoms = u32::from_le_bytes(buf4);
56    file.read_exact(&mut buf4)?;
57    let n_frames = u32::from_le_bytes(buf4);
58    let mut buf8 = [0u8; 8];
59    file.read_exact(&mut buf8)?;
60    let dt = f64_from_bytes_le(buf8);
61    let mut endian_buf = [0u8; 1];
62    file.read_exact(&mut endian_buf)?;
63    Ok(BinaryHeader {
64        magic,
65        version,
66        n_atoms,
67        n_frames,
68        dt,
69        endian: endian_buf[0],
70    })
71}
72
73// ── Frame I/O ─────────────────────────────────────────────────────────────────
74
75/// Write one frame of atomic positions to an open file.
76///
77/// Each atom contributes 12 bytes (3 × f32 little-endian).
78pub fn write_frame_binary(
79    file: &mut std::fs::File,
80    positions: &[[f32; 3]],
81) -> Result<(), std::io::Error> {
82    for pos in positions {
83        file.write_all(&f32_to_bytes_le(pos[0]))?;
84        file.write_all(&f32_to_bytes_le(pos[1]))?;
85        file.write_all(&f32_to_bytes_le(pos[2]))?;
86    }
87    Ok(())
88}
89
90/// Read one frame of `n_atoms` atomic positions from an open file.
91pub fn read_frame_binary(
92    file: &mut std::fs::File,
93    n_atoms: usize,
94) -> Result<Vec<[f32; 3]>, std::io::Error> {
95    let mut positions = Vec::with_capacity(n_atoms);
96    let mut buf4 = [0u8; 4];
97    for _ in 0..n_atoms {
98        file.read_exact(&mut buf4)?;
99        let x = f32_from_bytes_le(buf4);
100        file.read_exact(&mut buf4)?;
101        let y = f32_from_bytes_le(buf4);
102        file.read_exact(&mut buf4)?;
103        let z = f32_from_bytes_le(buf4);
104        positions.push([x, y, z]);
105    }
106    Ok(positions)
107}
108
109// ── Byte-conversion helpers ───────────────────────────────────────────────────
110
111/// Encode a `f64` value as 8 little-endian bytes.
112pub fn f64_to_bytes_le(val: f64) -> [u8; 8] {
113    val.to_bits().to_le_bytes()
114}
115
116/// Decode a `f64` value from 8 little-endian bytes.
117pub fn f64_from_bytes_le(bytes: [u8; 8]) -> f64 {
118    f64::from_bits(u64::from_le_bytes(bytes))
119}
120
121/// Encode a `f32` value as 4 little-endian bytes.
122pub fn f32_to_bytes_le(val: f32) -> [u8; 4] {
123    val.to_bits().to_le_bytes()
124}
125
126/// Decode a `f32` value from 4 little-endian bytes.
127pub fn f32_from_bytes_le(bytes: [u8; 4]) -> f32 {
128    f32::from_bits(u32::from_le_bytes(bytes))
129}
130
131// ── Quantized compression ─────────────────────────────────────────────────────
132
133/// Lossy quantization of positions to `i16`.
134///
135/// Each coordinate is multiplied by `1.0 / scale` and clamped to
136/// `[i16::MIN, i16::MAX]`.  Typical `scale` value: `0.001` for 1 pm
137/// resolution with positions in Ångström.
138pub fn compress_positions_quantized(positions: &[[f32; 3]], scale: f32) -> Vec<i16> {
139    if scale == 0.0 {
140        return vec![0i16; positions.len() * 3];
141    }
142    let inv = 1.0 / scale;
143    let mut out = Vec::with_capacity(positions.len() * 3);
144    for pos in positions {
145        for &c in pos {
146            let q = (c * inv).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
147            out.push(q);
148        }
149    }
150    out
151}
152
153/// Reconstruct positions from quantized `i16` data.
154///
155/// The inverse of [`compress_positions_quantized`]: multiplies by `scale`.
156pub fn decompress_positions_quantized(data: &[i16], scale: f32) -> Vec<[f32; 3]> {
157    let n = data.len() / 3;
158    let mut out = Vec::with_capacity(n);
159    for i in 0..n {
160        let x = data[i * 3] as f32 * scale;
161        let y = data[i * 3 + 1] as f32 * scale;
162        let z = data[i * 3 + 2] as f32 * scale;
163        out.push([x, y, z]);
164    }
165    out
166}
167
168// ── Tests ─────────────────────────────────────────────────────────────────────
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn sample_header() -> BinaryHeader {
175        BinaryHeader {
176            magic: *b"OXIP",
177            version: 1,
178            n_atoms: 100,
179            n_frames: 50,
180            dt: 0.002,
181            endian: 0,
182        }
183    }
184
185    #[test]
186    fn test_f64_roundtrip() {
187        let val = std::f64::consts::PI;
188        let bytes = f64_to_bytes_le(val);
189        let back = f64_from_bytes_le(bytes);
190        assert!((back - val).abs() < 1e-15);
191    }
192
193    #[test]
194    fn test_f64_zero() {
195        let bytes = f64_to_bytes_le(0.0);
196        let back = f64_from_bytes_le(bytes);
197        assert!((back).abs() < 1e-15);
198    }
199
200    #[test]
201    fn test_f64_negative() {
202        let val = -1.23456789e-10;
203        let bytes = f64_to_bytes_le(val);
204        let back = f64_from_bytes_le(bytes);
205        assert!((back - val).abs() < 1e-20);
206    }
207
208    #[test]
209    fn test_f32_roundtrip() {
210        let val = 3.15625_f32;
211        let bytes = f32_to_bytes_le(val);
212        let back = f32_from_bytes_le(bytes);
213        assert!((back - val).abs() < 1e-6);
214    }
215
216    #[test]
217    fn test_f32_negative() {
218        let val = -0.001_f32;
219        let bytes = f32_to_bytes_le(val);
220        let back = f32_from_bytes_le(bytes);
221        assert!((back - val).abs() < 1e-7);
222    }
223
224    #[test]
225    fn test_write_read_header_roundtrip() {
226        let path = "/tmp/test_oxiphysics_header.bin";
227        let hdr = sample_header();
228        write_binary_header(path, &hdr).unwrap();
229        let hdr2 = read_binary_header(path).unwrap();
230        assert_eq!(hdr2.magic, *b"OXIP");
231        assert_eq!(hdr2.version, 1);
232        assert_eq!(hdr2.n_atoms, 100);
233        assert_eq!(hdr2.n_frames, 50);
234        assert!((hdr2.dt - 0.002).abs() < 1e-12);
235        assert_eq!(hdr2.endian, 0);
236    }
237
238    #[test]
239    fn test_header_magic() {
240        let path = "/tmp/test_oxiphysics_header_magic.bin";
241        let hdr = BinaryHeader {
242            magic: *b"TEST",
243            ..sample_header()
244        };
245        write_binary_header(path, &hdr).unwrap();
246        let hdr2 = read_binary_header(path).unwrap();
247        assert_eq!(hdr2.magic, *b"TEST");
248    }
249
250    #[test]
251    fn test_header_size() {
252        assert_eq!(HEADER_SIZE, 25);
253    }
254
255    #[test]
256    fn test_write_read_frame_roundtrip() {
257        let path = "/tmp/test_oxiphysics_frame.bin";
258        let positions = vec![[1.0_f32, 2.0, 3.0], [4.0, 5.0, 6.0], [-1.0, 0.0, 0.5]];
259        {
260            let mut file = std::fs::File::create(path).unwrap();
261            write_frame_binary(&mut file, &positions).unwrap();
262        }
263        {
264            let mut file = std::fs::File::open(path).unwrap();
265            let back = read_frame_binary(&mut file, 3).unwrap();
266            assert_eq!(back.len(), 3);
267            assert!((back[0][0] - 1.0).abs() < 1e-6);
268            assert!((back[2][2] - 0.5).abs() < 1e-6);
269        }
270    }
271
272    #[test]
273    fn test_write_read_frame_single_atom() {
274        let path = "/tmp/test_oxiphysics_frame_single.bin";
275        let positions = vec![[0.1_f32, 0.2, 0.3]];
276        {
277            let mut file = std::fs::File::create(path).unwrap();
278            write_frame_binary(&mut file, &positions).unwrap();
279        }
280        {
281            let mut file = std::fs::File::open(path).unwrap();
282            let back = read_frame_binary(&mut file, 1).unwrap();
283            assert!((back[0][1] - 0.2).abs() < 1e-6);
284        }
285    }
286
287    #[test]
288    fn test_write_read_empty_frame() {
289        let path = "/tmp/test_oxiphysics_frame_empty.bin";
290        {
291            let mut file = std::fs::File::create(path).unwrap();
292            write_frame_binary(&mut file, &[]).unwrap();
293        }
294        {
295            let mut file = std::fs::File::open(path).unwrap();
296            let back = read_frame_binary(&mut file, 0).unwrap();
297            assert!(back.is_empty());
298        }
299    }
300
301    #[test]
302    fn test_compress_decompress_positions() {
303        let positions = vec![[1.0_f32, 2.0, -1.5], [0.5, 0.0, 3.0]];
304        let scale = 0.001;
305        let compressed = compress_positions_quantized(&positions, scale);
306        let decompressed = decompress_positions_quantized(&compressed, scale);
307        assert_eq!(decompressed.len(), 2);
308        assert!((decompressed[0][0] - 1.0).abs() < scale * 2.0);
309        assert!((decompressed[1][2] - 3.0).abs() < scale * 2.0);
310    }
311
312    #[test]
313    fn test_compress_empty() {
314        let compressed = compress_positions_quantized(&[], 0.001);
315        assert!(compressed.is_empty());
316    }
317
318    #[test]
319    fn test_decompress_empty() {
320        let decompressed = decompress_positions_quantized(&[], 0.001);
321        assert!(decompressed.is_empty());
322    }
323
324    #[test]
325    fn test_compress_zero_scale() {
326        // scale=0 returns all zeros
327        let positions = vec![[1.0_f32, 2.0, 3.0]];
328        let compressed = compress_positions_quantized(&positions, 0.0);
329        assert_eq!(compressed, vec![0i16, 0, 0]);
330    }
331
332    #[test]
333    fn test_compress_large_positive() {
334        // Value that would overflow i16: clamps to i16::MAX
335        let positions = vec![[1e10_f32, 0.0, 0.0]];
336        let compressed = compress_positions_quantized(&positions, 0.001);
337        assert_eq!(compressed[0], i16::MAX);
338    }
339
340    #[test]
341    fn test_compress_large_negative() {
342        let positions = vec![[-1e10_f32, 0.0, 0.0]];
343        let compressed = compress_positions_quantized(&positions, 0.001);
344        assert_eq!(compressed[0], i16::MIN);
345    }
346
347    #[test]
348    fn test_compress_count() {
349        let positions = vec![[0.0_f32; 3]; 5];
350        let compressed = compress_positions_quantized(&positions, 0.01);
351        assert_eq!(compressed.len(), 15);
352    }
353
354    #[test]
355    fn test_decompress_count() {
356        let data = vec![0i16; 12];
357        let out = decompress_positions_quantized(&data, 0.01);
358        assert_eq!(out.len(), 4);
359    }
360
361    #[test]
362    fn test_multiple_frames_sequential() {
363        let path = "/tmp/test_oxiphysics_multiframe.bin";
364        let frame1 = vec![[1.0_f32, 0.0, 0.0]];
365        let frame2 = vec![[2.0_f32, 0.0, 0.0]];
366        {
367            let mut file = std::fs::File::create(path).unwrap();
368            write_frame_binary(&mut file, &frame1).unwrap();
369            write_frame_binary(&mut file, &frame2).unwrap();
370        }
371        {
372            let mut file = std::fs::File::open(path).unwrap();
373            let f1 = read_frame_binary(&mut file, 1).unwrap();
374            let f2 = read_frame_binary(&mut file, 1).unwrap();
375            assert!((f1[0][0] - 1.0).abs() < 1e-6);
376            assert!((f2[0][0] - 2.0).abs() < 1e-6);
377        }
378    }
379
380    #[test]
381    fn test_header_dt_precision() {
382        let path = "/tmp/test_oxiphysics_header_dt.bin";
383        let hdr = BinaryHeader {
384            dt: 1.23456789012345e-4,
385            ..sample_header()
386        };
387        write_binary_header(path, &hdr).unwrap();
388        let hdr2 = read_binary_header(path).unwrap();
389        assert!((hdr2.dt - 1.23456789012345e-4).abs() < 1e-18);
390    }
391
392    #[test]
393    fn test_header_large_n_atoms() {
394        let path = "/tmp/test_oxiphysics_header_large.bin";
395        let hdr = BinaryHeader {
396            n_atoms: 1_000_000,
397            ..sample_header()
398        };
399        write_binary_header(path, &hdr).unwrap();
400        let hdr2 = read_binary_header(path).unwrap();
401        assert_eq!(hdr2.n_atoms, 1_000_000);
402    }
403
404    #[test]
405    fn test_quantize_negative_values() {
406        let positions = vec![[-0.5_f32, -1.0, -2.0]];
407        let scale = 0.001;
408        let compressed = compress_positions_quantized(&positions, scale);
409        let back = decompress_positions_quantized(&compressed, scale);
410        assert!((back[0][0] - (-0.5)).abs() < scale * 2.0);
411    }
412
413    #[test]
414    fn test_f32_bytes_all_zeros() {
415        let bytes = f32_to_bytes_le(0.0);
416        assert_eq!(bytes, [0, 0, 0, 0]);
417    }
418
419    #[test]
420    fn test_f64_bytes_all_zeros() {
421        let bytes = f64_to_bytes_le(0.0);
422        assert_eq!(bytes, [0, 0, 0, 0, 0, 0, 0, 0]);
423    }
424}