Skip to main content

oxiphysics_io/
xtc_dcd.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Simplified binary trajectory format implementations for XTC and DCD.
6//!
7//! These are in-memory, simplified versions suitable for testing and
8//! lightweight use. Real XTC uses lossy compression (XDR); real DCD is
9//! a CHARMM/NAMD Fortran binary format. Both are faithfully mimicked
10//! structurally but without external dependencies.
11//!
12//! Also provides:
13//! - Frame seeking (by index)
14//! - Trajectory metadata
15//! - Coordinate compression (quantization)
16//! - Box matrix utilities
17
18#![allow(dead_code)]
19
20// ─────────────────────────────────────────────
21//  XTC  (simplified, lossless)
22// ─────────────────────────────────────────────
23
24/// Magic number written at the beginning of the simplified XTC byte stream.
25const XTC_MAGIC: u32 = 0x5854_4300;
26
27/// A single frame of XTC trajectory data.
28pub struct XtcFrame {
29    /// Simulation step number.
30    pub step: i32,
31    /// Simulation time (ps).
32    pub time: f32,
33    /// 3×3 box matrix (nm).
34    pub box_matrix: [[f32; 3]; 3],
35    /// Atom positions `[x, y, z]` in nm.
36    pub positions: Vec<[f32; 3]>,
37}
38
39impl XtcFrame {
40    /// Number of atoms in this frame.
41    pub fn n_atoms(&self) -> usize {
42        self.positions.len()
43    }
44
45    /// Compute the center of mass of all positions (unweighted).
46    pub fn center_of_geometry(&self) -> [f32; 3] {
47        if self.positions.is_empty() {
48            return [0.0; 3];
49        }
50        let n = self.positions.len() as f32;
51        let mut cx = 0.0f32;
52        let mut cy = 0.0f32;
53        let mut cz = 0.0f32;
54        for p in &self.positions {
55            cx += p[0];
56            cy += p[1];
57            cz += p[2];
58        }
59        [cx / n, cy / n, cz / n]
60    }
61
62    /// Compute the bounding box of all positions: (min, max).
63    pub fn bounding_box(&self) -> ([f32; 3], [f32; 3]) {
64        if self.positions.is_empty() {
65            return ([0.0; 3], [0.0; 3]);
66        }
67        let mut min = self.positions[0];
68        let mut max = self.positions[0];
69        for p in &self.positions {
70            for i in 0..3 {
71                if p[i] < min[i] {
72                    min[i] = p[i];
73                }
74                if p[i] > max[i] {
75                    max[i] = p[i];
76                }
77            }
78        }
79        (min, max)
80    }
81
82    /// Box volume (product of diagonal elements for orthorhombic box).
83    pub fn box_volume(&self) -> f32 {
84        self.box_matrix[0][0] * self.box_matrix[1][1] * self.box_matrix[2][2]
85    }
86}
87
88/// In-memory XTC-like writer.
89pub struct SimpleXtcWriter {
90    /// All frames accumulated so far.
91    pub frames: Vec<XtcFrame>,
92}
93
94impl SimpleXtcWriter {
95    /// Create an empty writer.
96    pub fn new() -> Self {
97        Self { frames: Vec::new() }
98    }
99
100    /// Append a frame.  The box matrix is set to an identity-like 3 nm cube.
101    pub fn add_frame(&mut self, step: i32, time: f32, positions: Vec<[f32; 3]>) {
102        let box_matrix = [[3.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 3.0]];
103        self.frames.push(XtcFrame {
104            step,
105            time,
106            box_matrix,
107            positions,
108        });
109    }
110
111    /// Append a frame with a custom box matrix.
112    pub fn add_frame_with_box(
113        &mut self,
114        step: i32,
115        time: f32,
116        positions: Vec<[f32; 3]>,
117        box_matrix: [[f32; 3]; 3],
118    ) {
119        self.frames.push(XtcFrame {
120            step,
121            time,
122            box_matrix,
123            positions,
124        });
125    }
126
127    /// Number of frames currently stored.
128    pub fn frame_count(&self) -> usize {
129        self.frames.len()
130    }
131
132    /// Serialise to bytes.
133    ///
134    /// Binary layout:
135    /// ```text
136    /// [4]  magic  (u32 LE) = 0x58544300
137    /// [4]  n_frames (u32 LE)
138    /// per frame:
139    ///   [4]  step     (i32 LE)
140    ///   [4]  time     (f32 LE)
141    ///   [4]  n_atoms  (u32 LE)
142    ///   [36] box_matrix (9 × f32 LE, row-major)
143    ///   [n_atoms*12] positions (n_atoms × 3 × f32 LE)
144    /// ```
145    pub fn to_bytes(&self) -> Vec<u8> {
146        let mut buf = Vec::new();
147
148        buf.extend_from_slice(&XTC_MAGIC.to_le_bytes());
149        buf.extend_from_slice(&(self.frames.len() as u32).to_le_bytes());
150
151        for frame in &self.frames {
152            buf.extend_from_slice(&frame.step.to_le_bytes());
153            buf.extend_from_slice(&frame.time.to_le_bytes());
154            buf.extend_from_slice(&(frame.positions.len() as u32).to_le_bytes());
155
156            for row in &frame.box_matrix {
157                for &v in row {
158                    buf.extend_from_slice(&v.to_le_bytes());
159                }
160            }
161
162            for pos in &frame.positions {
163                for &v in pos {
164                    buf.extend_from_slice(&v.to_le_bytes());
165                }
166            }
167        }
168
169        buf
170    }
171}
172
173impl Default for SimpleXtcWriter {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179/// In-memory XTC reader.
180pub struct SimpleXtcReader;
181
182impl SimpleXtcReader {
183    /// Parse a byte slice produced by [`SimpleXtcWriter::to_bytes`].
184    pub fn from_bytes(data: &[u8]) -> Result<Vec<XtcFrame>, String> {
185        let mut cur = 0usize;
186
187        macro_rules! read_u32 {
188            () => {{
189                if cur + 4 > data.len() {
190                    return Err(format!("XTC: unexpected EOF at offset {cur}"));
191                }
192                let v = u32::from_le_bytes(
193                    data[cur..cur + 4]
194                        .try_into()
195                        .expect("slice length must match"),
196                );
197                cur += 4;
198                v
199            }};
200        }
201        macro_rules! read_i32 {
202            () => {{
203                if cur + 4 > data.len() {
204                    return Err(format!("XTC: unexpected EOF at offset {cur}"));
205                }
206                let v = i32::from_le_bytes(
207                    data[cur..cur + 4]
208                        .try_into()
209                        .expect("slice length must match"),
210                );
211                cur += 4;
212                v
213            }};
214        }
215        macro_rules! read_f32 {
216            () => {{
217                if cur + 4 > data.len() {
218                    return Err(format!("XTC: unexpected EOF at offset {cur}"));
219                }
220                let v = f32::from_le_bytes(
221                    data[cur..cur + 4]
222                        .try_into()
223                        .expect("slice length must match"),
224                );
225                cur += 4;
226                v
227            }};
228        }
229
230        let magic = read_u32!();
231        if magic != XTC_MAGIC {
232            return Err(format!("XTC: bad magic 0x{magic:08X}"));
233        }
234
235        let n_frames = read_u32!() as usize;
236        let mut frames = Vec::with_capacity(n_frames);
237
238        for _ in 0..n_frames {
239            let step = read_i32!();
240            let time = read_f32!();
241            let n_atoms = read_u32!() as usize;
242
243            let mut box_matrix = [[0f32; 3]; 3];
244            for row in &mut box_matrix {
245                for v in row.iter_mut() {
246                    *v = read_f32!();
247                }
248            }
249
250            let mut positions = Vec::with_capacity(n_atoms);
251            for _ in 0..n_atoms {
252                let x = read_f32!();
253                let y = read_f32!();
254                let z = read_f32!();
255                positions.push([x, y, z]);
256            }
257
258            frames.push(XtcFrame {
259                step,
260                time,
261                box_matrix,
262                positions,
263            });
264        }
265
266        Ok(frames)
267    }
268
269    /// Read only frame at a given index (seeks past earlier frames).
270    pub fn read_frame_at(data: &[u8], frame_idx: usize) -> Result<XtcFrame, String> {
271        let frames = Self::from_bytes(data)?;
272        if frame_idx >= frames.len() {
273            return Err(format!(
274                "frame index {frame_idx} out of range (total {})",
275                frames.len()
276            ));
277        }
278        // We have to parse all frames in this simplified format,
279        // but we return only the requested one.
280        let mut frames = frames;
281        Ok(frames.swap_remove(frame_idx.min(frames.len() - 1)))
282    }
283}
284
285/// Trajectory metadata extracted from an XTC byte stream.
286pub struct TrajectoryMetadata {
287    /// Number of frames.
288    pub n_frames: usize,
289    /// Number of atoms (from first frame).
290    pub n_atoms: usize,
291    /// Time of first frame (ps).
292    pub first_time: f32,
293    /// Time of last frame (ps).
294    pub last_time: f32,
295    /// Step of first frame.
296    pub first_step: i32,
297    /// Step of last frame.
298    pub last_step: i32,
299}
300
301impl TrajectoryMetadata {
302    /// Extract metadata from parsed XTC frames.
303    pub fn from_xtc_frames(frames: &[XtcFrame]) -> Option<Self> {
304        if frames.is_empty() {
305            return None;
306        }
307        Some(Self {
308            n_frames: frames.len(),
309            n_atoms: frames[0].positions.len(),
310            first_time: frames[0].time,
311            last_time: frames[frames.len() - 1].time,
312            first_step: frames[0].step,
313            last_step: frames[frames.len() - 1].step,
314        })
315    }
316
317    /// Duration of the trajectory in ps.
318    pub fn duration(&self) -> f32 {
319        self.last_time - self.first_time
320    }
321
322    /// Average time step between frames.
323    pub fn avg_dt(&self) -> f32 {
324        if self.n_frames <= 1 {
325            return 0.0;
326        }
327        self.duration() / (self.n_frames - 1) as f32
328    }
329}
330
331// ─────────────────────────────────────────────
332//  Coordinate compression (quantization)
333// ─────────────────────────────────────────────
334
335/// Simple lossy coordinate compression via fixed-point quantization.
336///
337/// Positions are quantized to a fixed number of decimal places,
338/// reducing precision but allowing more compact storage.
339pub struct CoordinateQuantizer {
340    /// Number of decimal places to preserve.
341    pub precision: u32,
342    /// Scale factor = 10^precision.
343    pub scale: f32,
344}
345
346impl CoordinateQuantizer {
347    /// Create a quantizer with the given precision (decimal places).
348    pub fn new(precision: u32) -> Self {
349        let scale = 10.0_f32.powi(precision as i32);
350        Self { precision, scale }
351    }
352
353    /// Quantize a position to fixed-point, then round back to f32.
354    pub fn quantize(&self, pos: [f32; 3]) -> [f32; 3] {
355        [
356            (pos[0] * self.scale).round() / self.scale,
357            (pos[1] * self.scale).round() / self.scale,
358            (pos[2] * self.scale).round() / self.scale,
359        ]
360    }
361
362    /// Quantize all positions in-place.
363    pub fn quantize_frame(&self, positions: &mut [[f32; 3]]) {
364        for p in positions.iter_mut() {
365            *p = self.quantize(*p);
366        }
367    }
368
369    /// Maximum quantization error per axis.
370    pub fn max_error(&self) -> f32 {
371        0.5 / self.scale
372    }
373}
374
375// ─────────────────────────────────────────────
376//  DCD  (simplified Fortran-record binary)
377// ─────────────────────────────────────────────
378
379/// Header metadata for a DCD file.
380pub struct DcdHeader {
381    /// Number of atoms.
382    pub n_atoms: u32,
383    /// Number of frames.
384    pub n_frames: u32,
385    /// Integration timestep (fs).
386    pub timestep: f32,
387    /// Title string (up to 80 characters in the real format).
388    pub title: String,
389}
390
391impl DcdHeader {
392    /// Duration of the trajectory in fs.
393    pub fn duration(&self) -> f32 {
394        self.timestep * self.n_frames as f32
395    }
396}
397
398/// A single DCD coordinate frame.  CHARMM stores x, y, z as separate arrays.
399pub struct DcdFrame {
400    /// X coordinates (Å).
401    pub x: Vec<f32>,
402    /// Y coordinates (Å).
403    pub y: Vec<f32>,
404    /// Z coordinates (Å).
405    pub z: Vec<f32>,
406}
407
408impl DcdFrame {
409    /// Number of atoms.
410    pub fn n_atoms(&self) -> usize {
411        self.x.len()
412    }
413
414    /// Get position of atom i as \[x, y, z\].
415    pub fn position(&self, i: usize) -> [f32; 3] {
416        [self.x[i], self.y[i], self.z[i]]
417    }
418
419    /// Convert to interleaved positions.
420    pub fn to_interleaved(&self) -> Vec<[f32; 3]> {
421        (0..self.n_atoms()).map(|i| self.position(i)).collect()
422    }
423
424    /// Center of geometry (unweighted).
425    pub fn center_of_geometry(&self) -> [f32; 3] {
426        if self.x.is_empty() {
427            return [0.0; 3];
428        }
429        let n = self.x.len() as f32;
430        let cx: f32 = self.x.iter().sum::<f32>() / n;
431        let cy: f32 = self.y.iter().sum::<f32>() / n;
432        let cz: f32 = self.z.iter().sum::<f32>() / n;
433        [cx, cy, cz]
434    }
435}
436
437/// In-memory DCD writer.
438pub struct SimpleDcdWriter {
439    /// File header.
440    pub header: DcdHeader,
441    /// Accumulated frames.
442    pub frames: Vec<DcdFrame>,
443}
444
445impl SimpleDcdWriter {
446    /// Create a new writer.  `n_atoms` is fixed for the entire trajectory.
447    pub fn new(n_atoms: u32, timestep: f32) -> Self {
448        let header = DcdHeader {
449            n_atoms,
450            n_frames: 0,
451            timestep,
452            title: String::from("OxiPhysics simplified DCD"),
453        };
454        Self {
455            header,
456            frames: Vec::new(),
457        }
458    }
459
460    /// Create a writer with a custom title.
461    pub fn with_title(n_atoms: u32, timestep: f32, title: &str) -> Self {
462        let header = DcdHeader {
463            n_atoms,
464            n_frames: 0,
465            timestep,
466            title: title.to_string(),
467        };
468        Self {
469            header,
470            frames: Vec::new(),
471        }
472    }
473
474    /// Append a frame from interleaved `[x, y, z]` positions.
475    pub fn add_frame(&mut self, positions: &[[f32; 3]]) {
476        let x: Vec<f32> = positions.iter().map(|p| p[0]).collect();
477        let y: Vec<f32> = positions.iter().map(|p| p[1]).collect();
478        let z: Vec<f32> = positions.iter().map(|p| p[2]).collect();
479        self.frames.push(DcdFrame { x, y, z });
480        self.header.n_frames = self.frames.len() as u32;
481    }
482
483    /// Append a frame from separate x, y, z arrays.
484    pub fn add_frame_xyz(&mut self, x: Vec<f32>, y: Vec<f32>, z: Vec<f32>) {
485        self.frames.push(DcdFrame { x, y, z });
486        self.header.n_frames = self.frames.len() as u32;
487    }
488
489    /// Number of frames.
490    pub fn frame_count(&self) -> usize {
491        self.frames.len()
492    }
493
494    /// Serialise to a simplified DCD byte stream.
495    ///
496    /// Each Fortran record is wrapped with a 4-byte integer giving the
497    /// byte length of the payload (written before **and** after the block).
498    ///
499    /// Layout:
500    /// ```text
501    /// HEADER BLOCK:
502    ///   [4] block_len
503    ///   [4] "CORD" signature
504    ///   [4] n_frames  (u32 LE)
505    ///   [4] n_atoms   (u32 LE)
506    ///   [4] timestep  (f32 LE)
507    ///   [80] title (padded with spaces)
508    ///   [4] block_len  (repeated)
509    ///
510    /// per frame:
511    ///   X block: [4] len_x | x[0..n] as f32 LE | [4] len_x
512    ///   Y block: [4] len_y | y[0..n] as f32 LE | [4] len_y
513    ///   Z block: [4] len_z | z[0..n] as f32 LE | [4] len_z
514    /// ```
515    pub fn to_bytes(&self) -> Vec<u8> {
516        let mut buf = Vec::new();
517
518        // ── header block ──────────────────────────────────────────────────
519        // payload: "CORD"(4) + n_frames(4) + n_atoms(4) + timestep(4) + title(80) = 96 bytes
520        let hdr_payload_len: u32 = 4 + 4 + 4 + 4 + 80;
521        buf.extend_from_slice(&hdr_payload_len.to_le_bytes());
522        buf.extend_from_slice(b"CORD");
523        buf.extend_from_slice(&self.header.n_frames.to_le_bytes());
524        buf.extend_from_slice(&self.header.n_atoms.to_le_bytes());
525        buf.extend_from_slice(&self.header.timestep.to_le_bytes());
526
527        // title: exactly 80 bytes, space-padded / truncated
528        let mut title_bytes = [b' '; 80];
529        let src = self.header.title.as_bytes();
530        let copy_len = src.len().min(80);
531        title_bytes[..copy_len].copy_from_slice(&src[..copy_len]);
532        buf.extend_from_slice(&title_bytes);
533
534        buf.extend_from_slice(&hdr_payload_len.to_le_bytes()); // closing record length
535
536        // ── coordinate frames ─────────────────────────────────────────────
537        let coord_block_len = self.header.n_atoms * 4; // n_atoms × sizeof(f32)
538
539        for frame in &self.frames {
540            for coords in [&frame.x, &frame.y, &frame.z] {
541                buf.extend_from_slice(&coord_block_len.to_le_bytes());
542                for &v in coords.iter() {
543                    buf.extend_from_slice(&v.to_le_bytes());
544                }
545                buf.extend_from_slice(&coord_block_len.to_le_bytes());
546            }
547        }
548
549        buf
550    }
551}
552
553/// In-memory DCD reader.
554pub struct SimpleDcdReader;
555
556impl SimpleDcdReader {
557    /// Parse a byte slice produced by [`SimpleDcdWriter::to_bytes`].
558    pub fn from_bytes(data: &[u8]) -> Result<(DcdHeader, Vec<DcdFrame>), String> {
559        let mut cur = 0usize;
560
561        macro_rules! need {
562            ($n:expr) => {
563                if cur + $n > data.len() {
564                    return Err(format!("DCD: unexpected EOF at offset {cur}"));
565                }
566            };
567        }
568        macro_rules! read_u32 {
569            () => {{
570                need!(4);
571                let v = u32::from_le_bytes(
572                    data[cur..cur + 4]
573                        .try_into()
574                        .expect("slice length must match"),
575                );
576                cur += 4;
577                v
578            }};
579        }
580        macro_rules! read_f32 {
581            () => {{
582                need!(4);
583                let v = f32::from_le_bytes(
584                    data[cur..cur + 4]
585                        .try_into()
586                        .expect("slice length must match"),
587                );
588                cur += 4;
589                v
590            }};
591        }
592
593        // ── header block ──────────────────────────────────────────────────
594        let hdr_len = read_u32!() as usize;
595        if hdr_len < 4 + 4 + 4 + 4 + 80 {
596            return Err(format!("DCD: header block too short ({hdr_len})"));
597        }
598
599        need!(4);
600        let sig = &data[cur..cur + 4];
601        if sig != b"CORD" {
602            return Err("DCD: expected 'CORD' signature".to_string());
603        }
604        cur += 4;
605
606        let n_frames = read_u32!();
607        let n_atoms = read_u32!();
608        let timestep = read_f32!();
609
610        need!(80);
611        let title_raw = &data[cur..cur + 80];
612        cur += 80;
613        let title = String::from_utf8_lossy(title_raw).trim_end().to_string();
614
615        // closing record length
616        let hdr_len2 = read_u32!();
617        if hdr_len2 as usize != hdr_len {
618            return Err(format!(
619                "DCD: header closing length mismatch ({hdr_len2} vs {hdr_len})"
620            ));
621        }
622
623        let header = DcdHeader {
624            n_atoms,
625            n_frames,
626            timestep,
627            title,
628        };
629        let n = n_atoms as usize;
630
631        // ── frames ────────────────────────────────────────────────────────
632        let mut frames = Vec::with_capacity(n_frames as usize);
633
634        for frame_idx in 0..n_frames {
635            let mut xyz: [Vec<f32>; 3] = [
636                Vec::with_capacity(n),
637                Vec::with_capacity(n),
638                Vec::with_capacity(n),
639            ];
640
641            for dim in 0..3usize {
642                let block_len = read_u32!() as usize;
643                let expected = n * 4;
644                if block_len != expected {
645                    return Err(format!(
646                        "DCD: frame {frame_idx} dim {dim}: block length {block_len} != {expected}"
647                    ));
648                }
649                for _ in 0..n {
650                    xyz[dim].push(read_f32!());
651                }
652                let block_len2 = read_u32!() as usize;
653                if block_len2 != block_len {
654                    return Err(format!(
655                        "DCD: frame {frame_idx} dim {dim}: closing block length mismatch"
656                    ));
657                }
658            }
659
660            let [x, y, z] = xyz;
661            frames.push(DcdFrame { x, y, z });
662        }
663
664        Ok((header, frames))
665    }
666
667    /// Read metadata from a DCD byte stream without parsing all frames.
668    pub fn read_metadata(data: &[u8]) -> Result<DcdHeader, String> {
669        let (header, _) = Self::from_bytes(data)?;
670        Ok(header)
671    }
672}
673
674// ─────────────────────────────────────────────
675//  DCD trajectory writing helpers
676// ─────────────────────────────────────────────
677
678/// Write a sequence of interleaved position frames to a DCD byte stream.
679///
680/// Each element in `frames` is a slice of `[f32; 3]` positions for one frame.
681/// Returns the complete DCD byte stream.
682#[allow(dead_code)]
683pub fn write_dcd_trajectory(n_atoms: u32, timestep: f32, frames: &[Vec<[f32; 3]>]) -> Vec<u8> {
684    let mut writer = SimpleDcdWriter::new(n_atoms, timestep);
685    for frame in frames {
686        writer.add_frame(frame);
687    }
688    writer.to_bytes()
689}
690
691/// Append a single frame to an existing DCD byte stream.
692///
693/// This reads the existing stream, appends the new frame, and returns the
694/// updated byte stream.
695#[allow(dead_code)]
696pub fn append_dcd_frame(existing: &[u8], new_positions: &[[f32; 3]]) -> Result<Vec<u8>, String> {
697    let (header, mut frames) = SimpleDcdReader::from_bytes(existing)?;
698    let mut writer = SimpleDcdWriter::with_title(header.n_atoms, header.timestep, &header.title);
699    for frame in &frames {
700        writer.add_frame_xyz(frame.x.clone(), frame.y.clone(), frame.z.clone());
701    }
702    // Add new frame
703    let new_frame: Vec<[f32; 3]> = new_positions.to_vec();
704    writer.add_frame(&new_frame);
705    // Suppress warning about unused mutable variable
706    frames.clear();
707    Ok(writer.to_bytes())
708}
709
710// ─────────────────────────────────────────────
711//  XTC precision control
712// ─────────────────────────────────────────────
713
714/// Apply a specific precision level to all frames in a writer.
715///
716/// Precision is given as the number of decimal places to keep (1-6).
717/// Higher precision retains more spatial detail but produces larger files.
718#[allow(dead_code)]
719pub fn apply_xtc_precision(writer: &mut SimpleXtcWriter, precision: u32) {
720    let q = CoordinateQuantizer::new(precision);
721    for frame in writer.frames.iter_mut() {
722        q.quantize_frame(&mut frame.positions);
723    }
724}
725
726/// Round-trip an XTC byte stream through a given precision level.
727///
728/// Returns the re-encoded bytes at the given coordinate precision.
729#[allow(dead_code)]
730pub fn xtc_recompress(data: &[u8], precision: u32) -> Result<Vec<u8>, String> {
731    let frames = SimpleXtcReader::from_bytes(data)?;
732    let q = CoordinateQuantizer::new(precision);
733    let mut writer = SimpleXtcWriter::new();
734    for mut frame in frames {
735        q.quantize_frame(&mut frame.positions);
736        writer.add_frame_with_box(frame.step, frame.time, frame.positions, frame.box_matrix);
737    }
738    Ok(writer.to_bytes())
739}
740
741/// Compute the root-mean-square deviation (RMSD) between two sets of positions.
742#[allow(dead_code)]
743pub fn compute_rmsd(a: &[[f32; 3]], b: &[[f32; 3]]) -> f32 {
744    if a.is_empty() || a.len() != b.len() {
745        return 0.0;
746    }
747    let n = a.len() as f32;
748    let sum_sq: f32 = a
749        .iter()
750        .zip(b.iter())
751        .map(|(ai, bi)| {
752            let dx = ai[0] - bi[0];
753            let dy = ai[1] - bi[1];
754            let dz = ai[2] - bi[2];
755            dx * dx + dy * dy + dz * dz
756        })
757        .sum();
758    (sum_sq / n).sqrt()
759}
760
761// ─────────────────────────────────────────────
762//  Trajectory format conversion (XTC ↔ DCD)
763// ─────────────────────────────────────────────
764
765/// Convert a simplified XTC byte stream to a DCD byte stream.
766///
767/// Frame times (in ps) are preserved in the DCD timestep if all frames have
768/// the same inter-frame interval; otherwise the average is used.
769/// Atom count is taken from the first XTC frame.
770#[allow(dead_code)]
771pub fn xtc_to_dcd(xtc_data: &[u8]) -> Result<Vec<u8>, String> {
772    let frames = SimpleXtcReader::from_bytes(xtc_data)?;
773    if frames.is_empty() {
774        return Ok(SimpleDcdWriter::new(0, 0.0).to_bytes());
775    }
776    let n_atoms = frames[0].n_atoms() as u32;
777
778    // Compute average timestep
779    let meta = TrajectoryMetadata::from_xtc_frames(&frames).expect("value should be present");
780    let timestep = meta.avg_dt();
781
782    let mut writer = SimpleDcdWriter::with_title(n_atoms, timestep, "Converted from XTC");
783    for frame in &frames {
784        let interleaved = frame.positions.clone();
785        writer.add_frame(&interleaved);
786    }
787    Ok(writer.to_bytes())
788}
789
790/// Convert a simplified DCD byte stream to an XTC byte stream.
791///
792/// Positions are converted from Å (DCD) to nm (XTC) by dividing by 10.
793#[allow(dead_code)]
794pub fn dcd_to_xtc(dcd_data: &[u8]) -> Result<Vec<u8>, String> {
795    let (header, frames) = SimpleDcdReader::from_bytes(dcd_data)?;
796    let timestep_ps = header.timestep;
797    let mut writer = SimpleXtcWriter::new();
798    for (idx, frame) in frames.iter().enumerate() {
799        let positions_nm: Vec<[f32; 3]> = (0..frame.n_atoms())
800            .map(|i| [frame.x[i] / 10.0, frame.y[i] / 10.0, frame.z[i] / 10.0])
801            .collect();
802        let time_ps = timestep_ps * idx as f32;
803        writer.add_frame(idx as i32, time_ps, positions_nm);
804    }
805    Ok(writer.to_bytes())
806}
807
808// ─────────────────────────────────────────────
809//  Frame time extraction
810// ─────────────────────────────────────────────
811
812/// Extract the simulation time (in ps) for every frame in an XTC byte stream.
813#[allow(dead_code)]
814pub fn xtc_extract_frame_times(data: &[u8]) -> Result<Vec<f32>, String> {
815    let frames = SimpleXtcReader::from_bytes(data)?;
816    Ok(frames.iter().map(|f| f.time).collect())
817}
818
819/// Extract the simulation step number for every frame in an XTC byte stream.
820#[allow(dead_code)]
821pub fn xtc_extract_frame_steps(data: &[u8]) -> Result<Vec<i32>, String> {
822    let frames = SimpleXtcReader::from_bytes(data)?;
823    Ok(frames.iter().map(|f| f.step).collect())
824}
825
826/// Compute inter-frame time deltas (ps) from an XTC byte stream.
827#[allow(dead_code)]
828pub fn xtc_frame_time_deltas(data: &[u8]) -> Result<Vec<f32>, String> {
829    let times = xtc_extract_frame_times(data)?;
830    if times.len() < 2 {
831        return Ok(Vec::new());
832    }
833    Ok(times.windows(2).map(|w| w[1] - w[0]).collect())
834}
835
836// ─────────────────────────────────────────────
837//  Velocity data support
838// ─────────────────────────────────────────────
839
840/// An XTC-like frame augmented with per-atom velocity data.
841pub struct XtcFrameWithVelocity {
842    /// Underlying position frame.
843    pub frame: XtcFrame,
844    /// Per-atom velocities `[vx, vy, vz]` in nm/ps.
845    pub velocities: Vec<[f32; 3]>,
846}
847
848impl XtcFrameWithVelocity {
849    /// Create from a position-only frame and separately computed velocities.
850    #[allow(dead_code)]
851    pub fn new(frame: XtcFrame, velocities: Vec<[f32; 3]>) -> Self {
852        Self { frame, velocities }
853    }
854
855    /// Kinetic energy (sum of 0.5 * v^2 for each atom, unweighted).
856    #[allow(dead_code)]
857    pub fn kinetic_energy_proxy(&self) -> f32 {
858        self.velocities
859            .iter()
860            .map(|v| 0.5 * (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]))
861            .sum()
862    }
863
864    /// RMS speed (root-mean-square over all atoms).
865    #[allow(dead_code)]
866    pub fn rms_speed(&self) -> f32 {
867        if self.velocities.is_empty() {
868            return 0.0;
869        }
870        let n = self.velocities.len() as f32;
871        let sum_sq: f32 = self
872            .velocities
873            .iter()
874            .map(|v| v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
875            .sum();
876        (sum_sq / n).sqrt()
877    }
878
879    /// Maximum speed among all atoms.
880    #[allow(dead_code)]
881    pub fn max_speed(&self) -> f32 {
882        self.velocities
883            .iter()
884            .map(|v| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt())
885            .fold(0.0f32, f32::max)
886    }
887}
888
889/// A writer for XTC-like trajectories that includes velocity channels.
890///
891/// The binary layout appends a velocity block after each position block,
892/// using the same length-prefix convention as DCD.
893pub struct XtcVelocityWriter {
894    /// Underlying XTC writer for positions.
895    pub positions: SimpleXtcWriter,
896    /// Velocity data per frame.
897    pub velocities: Vec<Vec<[f32; 3]>>,
898}
899
900impl XtcVelocityWriter {
901    /// Create an empty velocity writer.
902    #[allow(dead_code)]
903    pub fn new() -> Self {
904        Self {
905            positions: SimpleXtcWriter::new(),
906            velocities: Vec::new(),
907        }
908    }
909
910    /// Add a frame with both positions and velocities.
911    #[allow(dead_code)]
912    pub fn add_frame(
913        &mut self,
914        step: i32,
915        time: f32,
916        positions: Vec<[f32; 3]>,
917        velocities: Vec<[f32; 3]>,
918    ) {
919        self.positions.add_frame(step, time, positions);
920        self.velocities.push(velocities);
921    }
922
923    /// Number of frames stored.
924    #[allow(dead_code)]
925    pub fn frame_count(&self) -> usize {
926        self.positions.frame_count()
927    }
928
929    /// Serialize positions and velocities to bytes.
930    ///
931    /// Layout: XTC bytes + velocity magic (4) + velocity payload.
932    #[allow(dead_code)]
933    pub fn to_bytes(&self) -> Vec<u8> {
934        let mut buf = self.positions.to_bytes();
935        // Velocity block marker
936        buf.extend_from_slice(b"OXVL");
937        buf.extend_from_slice(&(self.velocities.len() as u32).to_le_bytes());
938        for frame_vels in &self.velocities {
939            buf.extend_from_slice(&(frame_vels.len() as u32).to_le_bytes());
940            for v in frame_vels {
941                for &c in v {
942                    buf.extend_from_slice(&c.to_le_bytes());
943                }
944            }
945        }
946        buf
947    }
948
949    /// Compute kinetic energy proxy for a frame.
950    #[allow(dead_code)]
951    pub fn frame_kinetic_energy(&self, frame_idx: usize) -> f32 {
952        if frame_idx >= self.velocities.len() {
953            return 0.0;
954        }
955        self.velocities[frame_idx]
956            .iter()
957            .map(|v| 0.5 * (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]))
958            .sum()
959    }
960}
961
962impl Default for XtcVelocityWriter {
963    fn default() -> Self {
964        Self::new()
965    }
966}
967
968/// Compute finite-difference velocities between consecutive XTC frames.
969///
970/// Returns one fewer velocity frame than position frames.
971/// `dt_ps` is the time step in picoseconds between consecutive frames.
972#[allow(dead_code)]
973pub fn compute_finite_difference_velocities(frames: &[XtcFrame], dt_ps: f32) -> Vec<Vec<[f32; 3]>> {
974    if frames.len() < 2 || dt_ps < 1e-30 {
975        return Vec::new();
976    }
977    frames
978        .windows(2)
979        .map(|w| {
980            let n = w[0].positions.len().min(w[1].positions.len());
981            (0..n)
982                .map(|i| {
983                    let p0 = w[0].positions[i];
984                    let p1 = w[1].positions[i];
985                    [
986                        (p1[0] - p0[0]) / dt_ps,
987                        (p1[1] - p0[1]) / dt_ps,
988                        (p1[2] - p0[2]) / dt_ps,
989                    ]
990                })
991                .collect()
992        })
993        .collect()
994}
995
996// ─────────────────────────────────────────────
997//  Tests
998// ─────────────────────────────────────────────
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    fn approx_eq(a: f32, b: f32) -> bool {
1005        (a - b).abs() < 1e-5
1006    }
1007
1008    // ── XTC tests ────────────────────────────────────────────────────────
1009
1010    #[test]
1011    fn xtc_empty_writer() {
1012        let w = SimpleXtcWriter::new();
1013        assert_eq!(w.frame_count(), 0);
1014        let bytes = w.to_bytes();
1015        // magic (4) + n_frames (4) = 8 bytes
1016        assert_eq!(bytes.len(), 8);
1017    }
1018
1019    #[test]
1020    fn xtc_round_trip_3frames_5atoms() {
1021        let mut writer = SimpleXtcWriter::new();
1022
1023        let pos0: Vec<[f32; 3]> = (0..5)
1024            .map(|i| [i as f32 * 0.1, i as f32 * 0.2, i as f32 * 0.3])
1025            .collect();
1026        let pos1: Vec<[f32; 3]> = (0..5)
1027            .map(|i| [i as f32 * 0.4, i as f32 * 0.5, i as f32 * 0.6])
1028            .collect();
1029        let pos2: Vec<[f32; 3]> = (0..5)
1030            .map(|i| [i as f32 * 0.7, i as f32 * 0.8, i as f32 * 0.9])
1031            .collect();
1032
1033        writer.add_frame(0, 0.0, pos0.clone());
1034        writer.add_frame(1, 0.5, pos1.clone());
1035        writer.add_frame(2, 1.0, pos2.clone());
1036
1037        assert_eq!(writer.frame_count(), 3);
1038
1039        let bytes = writer.to_bytes();
1040        let frames = SimpleXtcReader::from_bytes(&bytes).expect("XTC parse failed");
1041
1042        assert_eq!(frames.len(), 3);
1043
1044        for (f, original) in frames.iter().zip([&pos0, &pos1, &pos2]) {
1045            assert_eq!(f.positions.len(), 5);
1046            for (got, exp) in f.positions.iter().zip(original.iter()) {
1047                for k in 0..3 {
1048                    assert!(
1049                        approx_eq(got[k], exp[k]),
1050                        "mismatch: got {} exp {}",
1051                        got[k],
1052                        exp[k]
1053                    );
1054                }
1055            }
1056        }
1057
1058        assert_eq!(frames[0].step, 0);
1059        assert!(approx_eq(frames[1].time, 0.5));
1060        assert_eq!(frames[2].step, 2);
1061    }
1062
1063    #[test]
1064    fn xtc_frame_count_check() {
1065        let mut w = SimpleXtcWriter::new();
1066        for i in 0..10 {
1067            w.add_frame(i, i as f32 * 0.1, vec![[0.0; 3]]);
1068        }
1069        assert_eq!(w.frame_count(), 10);
1070    }
1071
1072    // ── XTC frame properties ─────────────────────────────────────────────
1073
1074    #[test]
1075    fn xtc_frame_center_of_geometry() {
1076        let frame = XtcFrame {
1077            step: 0,
1078            time: 0.0,
1079            box_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1080            positions: vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]],
1081        };
1082        let cog = frame.center_of_geometry();
1083        assert!(approx_eq(cog[0], 2.0 / 3.0));
1084        assert!(approx_eq(cog[1], 2.0 / 3.0));
1085        assert!(approx_eq(cog[2], 0.0));
1086    }
1087
1088    #[test]
1089    fn xtc_frame_bounding_box() {
1090        let frame = XtcFrame {
1091            step: 0,
1092            time: 0.0,
1093            box_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1094            positions: vec![[-1.0, 2.0, 0.0], [3.0, -1.0, 5.0]],
1095        };
1096        let (min, max) = frame.bounding_box();
1097        assert!(approx_eq(min[0], -1.0));
1098        assert!(approx_eq(max[0], 3.0));
1099        assert!(approx_eq(min[1], -1.0));
1100        assert!(approx_eq(max[1], 2.0));
1101    }
1102
1103    #[test]
1104    fn xtc_frame_box_volume() {
1105        let frame = XtcFrame {
1106            step: 0,
1107            time: 0.0,
1108            box_matrix: [[3.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 5.0]],
1109            positions: vec![],
1110        };
1111        assert!(approx_eq(frame.box_volume(), 60.0));
1112    }
1113
1114    // ── Frame seeking ────────────────────────────────────────────────────
1115
1116    #[test]
1117    fn xtc_read_frame_at() {
1118        let mut writer = SimpleXtcWriter::new();
1119        writer.add_frame(0, 0.0, vec![[1.0, 0.0, 0.0]]);
1120        writer.add_frame(1, 0.5, vec![[2.0, 0.0, 0.0]]);
1121        writer.add_frame(2, 1.0, vec![[3.0, 0.0, 0.0]]);
1122
1123        let bytes = writer.to_bytes();
1124        let frame = SimpleXtcReader::read_frame_at(&bytes, 1).unwrap();
1125        assert_eq!(frame.step, 1);
1126    }
1127
1128    #[test]
1129    fn xtc_read_frame_at_out_of_range() {
1130        let mut writer = SimpleXtcWriter::new();
1131        writer.add_frame(0, 0.0, vec![[0.0; 3]]);
1132        let bytes = writer.to_bytes();
1133        assert!(SimpleXtcReader::read_frame_at(&bytes, 5).is_err());
1134    }
1135
1136    // ── Trajectory metadata ──────────────────────────────────────────────
1137
1138    #[test]
1139    fn trajectory_metadata_basic() {
1140        let mut writer = SimpleXtcWriter::new();
1141        writer.add_frame(0, 0.0, vec![[0.0; 3]; 10]);
1142        writer.add_frame(100, 10.0, vec![[0.0; 3]; 10]);
1143        writer.add_frame(200, 20.0, vec![[0.0; 3]; 10]);
1144
1145        let bytes = writer.to_bytes();
1146        let frames = SimpleXtcReader::from_bytes(&bytes).unwrap();
1147        let meta = TrajectoryMetadata::from_xtc_frames(&frames).unwrap();
1148
1149        assert_eq!(meta.n_frames, 3);
1150        assert_eq!(meta.n_atoms, 10);
1151        assert!(approx_eq(meta.first_time, 0.0));
1152        assert!(approx_eq(meta.last_time, 20.0));
1153        assert!(approx_eq(meta.duration(), 20.0));
1154        assert!(approx_eq(meta.avg_dt(), 10.0));
1155    }
1156
1157    #[test]
1158    fn trajectory_metadata_empty() {
1159        let frames: Vec<XtcFrame> = vec![];
1160        assert!(TrajectoryMetadata::from_xtc_frames(&frames).is_none());
1161    }
1162
1163    // ── Coordinate quantizer ─────────────────────────────────────────────
1164
1165    #[test]
1166    fn quantizer_precision_3() {
1167        let q = CoordinateQuantizer::new(3);
1168        let pos = [1.23456, 7.89012, -0.00050];
1169        let qp = q.quantize(pos);
1170        assert!(approx_eq(qp[0], 1.235));
1171        assert!(approx_eq(qp[1], 7.890));
1172        // -0.0005 rounds to -0.001 or 0.0 depending on float rounding
1173        assert!(qp[2].abs() < 0.001 + 1e-6);
1174    }
1175
1176    #[test]
1177    fn quantizer_max_error() {
1178        let q = CoordinateQuantizer::new(3);
1179        assert!(approx_eq(q.max_error(), 0.0005));
1180    }
1181
1182    #[test]
1183    fn quantizer_frame() {
1184        let q = CoordinateQuantizer::new(2);
1185        let mut positions = vec![[1.234, 5.678, 9.012]];
1186        q.quantize_frame(&mut positions);
1187        assert!(approx_eq(positions[0][0], 1.23));
1188        assert!(approx_eq(positions[0][1], 5.68));
1189    }
1190
1191    // ── Custom box matrix ────────────────────────────────────────────────
1192
1193    #[test]
1194    fn xtc_custom_box_matrix() {
1195        let mut writer = SimpleXtcWriter::new();
1196        let custom_box = [[5.0, 0.0, 0.0], [0.0, 6.0, 0.0], [0.0, 0.0, 7.0]];
1197        writer.add_frame_with_box(0, 0.0, vec![[0.0; 3]], custom_box);
1198
1199        let bytes = writer.to_bytes();
1200        let frames = SimpleXtcReader::from_bytes(&bytes).unwrap();
1201        assert!(approx_eq(frames[0].box_matrix[0][0], 5.0));
1202        assert!(approx_eq(frames[0].box_matrix[1][1], 6.0));
1203        assert!(approx_eq(frames[0].box_matrix[2][2], 7.0));
1204    }
1205
1206    // ── DCD tests ────────────────────────────────────────────────────────
1207
1208    #[test]
1209    fn dcd_empty_writer() {
1210        let w = SimpleDcdWriter::new(4, 2.0);
1211        assert_eq!(w.frame_count(), 0);
1212        // Ensure to_bytes doesn't panic and produces valid header
1213        let bytes = w.to_bytes();
1214        assert!(!bytes.is_empty());
1215    }
1216
1217    #[test]
1218    fn dcd_round_trip_2frames_4atoms() {
1219        let mut writer = SimpleDcdWriter::new(4, 2.0);
1220
1221        let frame0: Vec<[f32; 3]> = vec![
1222            [1.0, 2.0, 3.0],
1223            [4.0, 5.0, 6.0],
1224            [7.0, 8.0, 9.0],
1225            [10.0, 11.0, 12.0],
1226        ];
1227        let frame1: Vec<[f32; 3]> = vec![
1228            [0.1, 0.2, 0.3],
1229            [0.4, 0.5, 0.6],
1230            [0.7, 0.8, 0.9],
1231            [1.0, 1.1, 1.2],
1232        ];
1233
1234        writer.add_frame(&frame0);
1235        writer.add_frame(&frame1);
1236
1237        assert_eq!(writer.frame_count(), 2);
1238        assert_eq!(writer.header.n_frames, 2);
1239
1240        let bytes = writer.to_bytes();
1241        let (header, frames) = SimpleDcdReader::from_bytes(&bytes).expect("DCD parse failed");
1242
1243        assert_eq!(header.n_atoms, 4);
1244        assert_eq!(header.n_frames, 2);
1245        assert!(approx_eq(header.timestep, 2.0));
1246        assert_eq!(frames.len(), 2);
1247
1248        // Verify X coordinates of frame 0
1249        let expected_x0: Vec<f32> = frame0.iter().map(|p| p[0]).collect();
1250        for (got, &exp) in frames[0].x.iter().zip(expected_x0.iter()) {
1251            assert!(approx_eq(*got, exp), "x mismatch: got {got} exp {exp}");
1252        }
1253
1254        // Verify all coords of frame 1
1255        for (i, pos) in frame1.iter().enumerate() {
1256            assert!(approx_eq(frames[1].x[i], pos[0]));
1257            assert!(approx_eq(frames[1].y[i], pos[1]));
1258            assert!(approx_eq(frames[1].z[i], pos[2]));
1259        }
1260    }
1261
1262    #[test]
1263    fn dcd_frame_count_check() {
1264        let mut w = SimpleDcdWriter::new(2, 1.0);
1265        for _ in 0..7 {
1266            w.add_frame(&[[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]);
1267        }
1268        assert_eq!(w.frame_count(), 7);
1269    }
1270
1271    // ── DCD frame methods ────────────────────────────────────────────────
1272
1273    #[test]
1274    fn dcd_frame_to_interleaved() {
1275        let frame = DcdFrame {
1276            x: vec![1.0, 4.0],
1277            y: vec![2.0, 5.0],
1278            z: vec![3.0, 6.0],
1279        };
1280        let interleaved = frame.to_interleaved();
1281        assert_eq!(interleaved.len(), 2);
1282        assert!(approx_eq(interleaved[0][0], 1.0));
1283        assert!(approx_eq(interleaved[1][2], 6.0));
1284    }
1285
1286    #[test]
1287    fn dcd_frame_center_of_geometry() {
1288        let frame = DcdFrame {
1289            x: vec![0.0, 2.0],
1290            y: vec![0.0, 4.0],
1291            z: vec![0.0, 6.0],
1292        };
1293        let cog = frame.center_of_geometry();
1294        assert!(approx_eq(cog[0], 1.0));
1295        assert!(approx_eq(cog[1], 2.0));
1296        assert!(approx_eq(cog[2], 3.0));
1297    }
1298
1299    // ── DCD custom title ─────────────────────────────────────────────────
1300
1301    #[test]
1302    fn dcd_custom_title() {
1303        let mut writer = SimpleDcdWriter::with_title(2, 1.0, "Custom Title");
1304        writer.add_frame(&[[0.0; 3], [1.0; 3]]);
1305
1306        let bytes = writer.to_bytes();
1307        let (header, _) = SimpleDcdReader::from_bytes(&bytes).unwrap();
1308        assert!(header.title.contains("Custom Title"));
1309    }
1310
1311    // ── DCD add_frame_xyz ────────────────────────────────────────────────
1312
1313    #[test]
1314    fn dcd_add_frame_xyz() {
1315        let mut writer = SimpleDcdWriter::new(3, 1.0);
1316        writer.add_frame_xyz(
1317            vec![1.0, 2.0, 3.0],
1318            vec![4.0, 5.0, 6.0],
1319            vec![7.0, 8.0, 9.0],
1320        );
1321        assert_eq!(writer.frame_count(), 1);
1322
1323        let bytes = writer.to_bytes();
1324        let (_, frames) = SimpleDcdReader::from_bytes(&bytes).unwrap();
1325        assert_eq!(frames[0].x, vec![1.0, 2.0, 3.0]);
1326    }
1327
1328    // ── DCD header duration ──────────────────────────────────────────────
1329
1330    #[test]
1331    fn dcd_header_duration() {
1332        let header = DcdHeader {
1333            n_atoms: 10,
1334            n_frames: 100,
1335            timestep: 2.0,
1336            title: String::new(),
1337        };
1338        assert!(approx_eq(header.duration(), 200.0));
1339    }
1340
1341    // ── DCD read_metadata ────────────────────────────────────────────────
1342
1343    #[test]
1344    fn dcd_read_metadata() {
1345        let mut writer = SimpleDcdWriter::new(5, 3.0);
1346        writer.add_frame(&[[0.0; 3]; 5]);
1347        writer.add_frame(&[[1.0; 3]; 5]);
1348
1349        let bytes = writer.to_bytes();
1350        let header = SimpleDcdReader::read_metadata(&bytes).unwrap();
1351        assert_eq!(header.n_atoms, 5);
1352        assert_eq!(header.n_frames, 2);
1353        assert!(approx_eq(header.timestep, 3.0));
1354    }
1355
1356    // ── DCD trajectory writing helpers ───────────────────────────────────
1357
1358    #[test]
1359    fn write_dcd_trajectory_basic() {
1360        let f0: Vec<[f32; 3]> = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
1361        let f1: Vec<[f32; 3]> = vec![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]];
1362        let bytes = write_dcd_trajectory(2, 2.0, &[f0, f1]);
1363        let (header, frames) = SimpleDcdReader::from_bytes(&bytes).unwrap();
1364        assert_eq!(header.n_atoms, 2);
1365        assert_eq!(header.n_frames, 2);
1366        assert!(approx_eq(frames[0].x[0], 1.0));
1367        assert!(approx_eq(frames[1].z[1], 12.0));
1368    }
1369
1370    #[test]
1371    fn write_dcd_trajectory_empty() {
1372        let bytes = write_dcd_trajectory(3, 1.0, &[]);
1373        let (header, frames) = SimpleDcdReader::from_bytes(&bytes).unwrap();
1374        assert_eq!(header.n_frames, 0);
1375        assert!(frames.is_empty());
1376    }
1377
1378    #[test]
1379    fn append_dcd_frame_basic() {
1380        let mut writer = SimpleDcdWriter::new(2, 1.0);
1381        writer.add_frame(&[[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]);
1382        let original = writer.to_bytes();
1383
1384        let new_frame: Vec<[f32; 3]> = vec![[2.0, 2.0, 2.0], [3.0, 3.0, 3.0]];
1385        let updated = append_dcd_frame(&original, &new_frame).unwrap();
1386
1387        let (header, frames) = SimpleDcdReader::from_bytes(&updated).unwrap();
1388        assert_eq!(header.n_frames, 2);
1389        assert_eq!(frames.len(), 2);
1390        assert!(approx_eq(frames[1].x[0], 2.0));
1391    }
1392
1393    // ── XTC precision control ─────────────────────────────────────────────
1394
1395    #[test]
1396    fn apply_xtc_precision_basic() {
1397        let mut writer = SimpleXtcWriter::new();
1398        writer.add_frame(0, 0.0, vec![[1.23456, 7.89012, -3.15625]]);
1399        apply_xtc_precision(&mut writer, 2);
1400        let p = &writer.frames[0].positions[0];
1401        assert!(approx_eq(p[0], 1.23) || (p[0] - 1.23).abs() < 0.005);
1402    }
1403
1404    #[test]
1405    fn xtc_recompress_roundtrip() {
1406        let mut writer = SimpleXtcWriter::new();
1407        writer.add_frame(0, 0.0, vec![[1.234, 5.678, 9.012]]);
1408        writer.add_frame(1, 1.0, vec![[0.111, 0.222, 0.333]]);
1409        let original_bytes = writer.to_bytes();
1410        let recompressed = xtc_recompress(&original_bytes, 3).unwrap();
1411        let frames = SimpleXtcReader::from_bytes(&recompressed).unwrap();
1412        assert_eq!(frames.len(), 2);
1413        // Values should be close but quantized
1414        assert!((frames[0].positions[0][0] - 1.234).abs() < 0.001);
1415    }
1416
1417    #[test]
1418    fn compute_rmsd_identical() {
1419        let positions = vec![[1.0f32, 0.0, 0.0], [0.0, 1.0, 0.0]];
1420        let rmsd = compute_rmsd(&positions, &positions);
1421        assert!(
1422            rmsd.abs() < 1e-6,
1423            "RMSD of identical sets should be 0, got {rmsd}"
1424        );
1425    }
1426
1427    #[test]
1428    fn compute_rmsd_known() {
1429        let a = vec![[0.0f32, 0.0, 0.0]];
1430        let b = vec![[3.0f32, 4.0, 0.0]]; // distance = 5
1431        let rmsd = compute_rmsd(&a, &b);
1432        assert!(approx_eq(rmsd, 5.0), "RMSD should be 5.0, got {rmsd}");
1433    }
1434
1435    #[test]
1436    fn compute_rmsd_different_lengths() {
1437        let a = vec![[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0]];
1438        let b = vec![[0.0f32, 0.0, 0.0]];
1439        let rmsd = compute_rmsd(&a, &b);
1440        assert!(approx_eq(rmsd, 0.0)); // returns 0 on mismatch
1441    }
1442
1443    // ── Format conversion (XTC ↔ DCD) ────────────────────────────────────
1444
1445    #[test]
1446    fn xtc_to_dcd_basic() {
1447        let mut writer = SimpleXtcWriter::new();
1448        writer.add_frame(0, 0.0, vec![[1.0, 2.0, 3.0]]);
1449        writer.add_frame(1, 1.0, vec![[4.0, 5.0, 6.0]]);
1450        let xtc_bytes = writer.to_bytes();
1451
1452        let dcd_bytes = xtc_to_dcd(&xtc_bytes).unwrap();
1453        let (header, frames) = SimpleDcdReader::from_bytes(&dcd_bytes).unwrap();
1454        assert_eq!(header.n_atoms, 1);
1455        assert_eq!(header.n_frames, 2);
1456        assert_eq!(frames.len(), 2);
1457        // XTC positions are in nm; DCD stores them as-is
1458        assert!(approx_eq(frames[0].x[0], 1.0));
1459        assert!(approx_eq(frames[1].y[0], 5.0));
1460    }
1461
1462    #[test]
1463    fn xtc_to_dcd_empty() {
1464        let writer = SimpleXtcWriter::new();
1465        let xtc_bytes = writer.to_bytes();
1466        let dcd_bytes = xtc_to_dcd(&xtc_bytes).unwrap();
1467        let (header, frames) = SimpleDcdReader::from_bytes(&dcd_bytes).unwrap();
1468        assert_eq!(header.n_frames, 0);
1469        assert!(frames.is_empty());
1470    }
1471
1472    #[test]
1473    fn dcd_to_xtc_basic() {
1474        let mut writer = SimpleDcdWriter::new(2, 1.0);
1475        writer.add_frame(&[[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]]);
1476        writer.add_frame(&[[11.0, 21.0, 31.0], [41.0, 51.0, 61.0]]);
1477        let dcd_bytes = writer.to_bytes();
1478
1479        let xtc_bytes = dcd_to_xtc(&dcd_bytes).unwrap();
1480        let frames = SimpleXtcReader::from_bytes(&xtc_bytes).unwrap();
1481        assert_eq!(frames.len(), 2);
1482        // DCD is Å, XTC should be nm = /10
1483        assert!(approx_eq(frames[0].positions[0][0], 1.0));
1484        assert!(approx_eq(frames[0].positions[1][2], 6.0));
1485        assert!(approx_eq(frames[1].positions[0][0], 1.1));
1486    }
1487
1488    #[test]
1489    fn xtc_dcd_xtc_roundtrip() {
1490        // XTC→DCD: positions are kept as-is (nm values stored in DCD as Å-labelled coords).
1491        // DCD→XTC: positions are divided by 10.
1492        // So after XTC→DCD→XTC: pos_out = pos_in / 10.
1493        // We verify this relationship holds exactly.
1494        let positions_nm = vec![[10.0f32, 20.0, 30.0], [40.0, 50.0, 60.0]];
1495        let mut xtc_writer = SimpleXtcWriter::new();
1496        xtc_writer.add_frame(0, 0.0, positions_nm.clone());
1497        let xtc_bytes = xtc_writer.to_bytes();
1498
1499        let dcd_bytes = xtc_to_dcd(&xtc_bytes).unwrap();
1500        let xtc2_bytes = dcd_to_xtc(&dcd_bytes).unwrap();
1501        let frames = SimpleXtcReader::from_bytes(&xtc2_bytes).unwrap();
1502        assert_eq!(frames.len(), 1);
1503        // After XTC→DCD→XTC, values are divided by 10
1504        assert!(
1505            approx_eq(frames[0].positions[0][0], 1.0),
1506            "expected 1.0, got {}",
1507            frames[0].positions[0][0]
1508        );
1509        assert!(
1510            approx_eq(frames[0].positions[1][0], 4.0),
1511            "expected 4.0, got {}",
1512            frames[0].positions[1][0]
1513        );
1514    }
1515
1516    // ── Frame time extraction ─────────────────────────────────────────────
1517
1518    #[test]
1519    fn xtc_extract_frame_times_basic() {
1520        let mut writer = SimpleXtcWriter::new();
1521        writer.add_frame(0, 0.0, vec![[0.0; 3]]);
1522        writer.add_frame(1, 2.5, vec![[0.0; 3]]);
1523        writer.add_frame(2, 5.0, vec![[0.0; 3]]);
1524        let bytes = writer.to_bytes();
1525        let times = xtc_extract_frame_times(&bytes).unwrap();
1526        assert_eq!(times.len(), 3);
1527        assert!(approx_eq(times[0], 0.0));
1528        assert!(approx_eq(times[1], 2.5));
1529        assert!(approx_eq(times[2], 5.0));
1530    }
1531
1532    #[test]
1533    fn xtc_extract_frame_steps_basic() {
1534        let mut writer = SimpleXtcWriter::new();
1535        writer.add_frame(10, 0.0, vec![[0.0; 3]]);
1536        writer.add_frame(20, 1.0, vec![[0.0; 3]]);
1537        let bytes = writer.to_bytes();
1538        let steps = xtc_extract_frame_steps(&bytes).unwrap();
1539        assert_eq!(steps, vec![10, 20]);
1540    }
1541
1542    #[test]
1543    fn xtc_frame_time_deltas_basic() {
1544        let mut writer = SimpleXtcWriter::new();
1545        writer.add_frame(0, 0.0, vec![[0.0; 3]]);
1546        writer.add_frame(1, 1.0, vec![[0.0; 3]]);
1547        writer.add_frame(2, 3.0, vec![[0.0; 3]]);
1548        let bytes = writer.to_bytes();
1549        let deltas = xtc_frame_time_deltas(&bytes).unwrap();
1550        assert_eq!(deltas.len(), 2);
1551        assert!(approx_eq(deltas[0], 1.0));
1552        assert!(approx_eq(deltas[1], 2.0));
1553    }
1554
1555    #[test]
1556    fn xtc_frame_time_deltas_single_frame() {
1557        let mut writer = SimpleXtcWriter::new();
1558        writer.add_frame(0, 0.0, vec![[0.0; 3]]);
1559        let bytes = writer.to_bytes();
1560        let deltas = xtc_frame_time_deltas(&bytes).unwrap();
1561        assert!(deltas.is_empty());
1562    }
1563
1564    // ── Velocity data support ─────────────────────────────────────────────
1565
1566    #[test]
1567    fn xtc_frame_with_velocity_kinetic_energy() {
1568        let frame = XtcFrame {
1569            step: 0,
1570            time: 0.0,
1571            box_matrix: [[1.0; 3]; 3],
1572            positions: vec![[0.0; 3]],
1573        };
1574        let velocities = vec![[3.0f32, 4.0, 0.0]]; // speed = 5, KE = 0.5 * 25 = 12.5
1575        let wv = XtcFrameWithVelocity::new(frame, velocities);
1576        assert!((wv.kinetic_energy_proxy() - 12.5).abs() < 1e-5);
1577    }
1578
1579    #[test]
1580    fn xtc_frame_with_velocity_rms_speed() {
1581        let frame = XtcFrame {
1582            step: 0,
1583            time: 0.0,
1584            box_matrix: [[1.0; 3]; 3],
1585            positions: vec![[0.0; 3]; 4],
1586        };
1587        // 4 atoms each moving at speed 2 in x
1588        let velocities = vec![[2.0f32, 0.0, 0.0]; 4];
1589        let wv = XtcFrameWithVelocity::new(frame, velocities);
1590        assert!(approx_eq(wv.rms_speed(), 2.0));
1591    }
1592
1593    #[test]
1594    fn xtc_frame_with_velocity_max_speed() {
1595        let frame = XtcFrame {
1596            step: 0,
1597            time: 0.0,
1598            box_matrix: [[1.0; 3]; 3],
1599            positions: vec![[0.0; 3]; 3],
1600        };
1601        let velocities = vec![[1.0f32, 0.0, 0.0], [3.0, 4.0, 0.0], [0.1, 0.0, 0.0]];
1602        let wv = XtcFrameWithVelocity::new(frame, velocities);
1603        assert!(approx_eq(wv.max_speed(), 5.0)); // sqrt(9+16) = 5
1604    }
1605
1606    #[test]
1607    fn xtc_velocity_writer_basic() {
1608        let mut writer = XtcVelocityWriter::new();
1609        let pos = vec![[1.0f32, 2.0, 3.0], [4.0, 5.0, 6.0]];
1610        let vel = vec![[0.1f32, 0.0, 0.0], [0.0, 0.2, 0.0]];
1611        writer.add_frame(0, 0.0, pos, vel);
1612        assert_eq!(writer.frame_count(), 1);
1613        let bytes = writer.to_bytes();
1614        assert!(!bytes.is_empty());
1615        // Bytes should contain the velocity magic
1616        let magic_pos = bytes.windows(4).position(|w| w == b"OXVL");
1617        assert!(magic_pos.is_some(), "should contain OXVL magic");
1618    }
1619
1620    #[test]
1621    fn xtc_velocity_writer_kinetic_energy() {
1622        let mut writer = XtcVelocityWriter::new();
1623        let pos = vec![[0.0f32; 3]];
1624        let vel = vec![[3.0f32, 4.0, 0.0]]; // KE = 0.5 * 25 = 12.5
1625        writer.add_frame(0, 0.0, pos, vel);
1626        let ke = writer.frame_kinetic_energy(0);
1627        assert!((ke - 12.5).abs() < 1e-5);
1628        // Out of range
1629        assert!(approx_eq(writer.frame_kinetic_energy(99), 0.0));
1630    }
1631
1632    #[test]
1633    fn compute_finite_difference_velocities_basic() {
1634        let frames = vec![
1635            XtcFrame {
1636                step: 0,
1637                time: 0.0,
1638                box_matrix: [[1.0; 3]; 3],
1639                positions: vec![[0.0, 0.0, 0.0]],
1640            },
1641            XtcFrame {
1642                step: 1,
1643                time: 1.0,
1644                box_matrix: [[1.0; 3]; 3],
1645                positions: vec![[2.0, 0.0, 0.0]],
1646            },
1647        ];
1648        let vels = compute_finite_difference_velocities(&frames, 1.0);
1649        assert_eq!(vels.len(), 1);
1650        assert!(approx_eq(vels[0][0][0], 2.0)); // (2-0)/1 = 2 nm/ps
1651    }
1652
1653    #[test]
1654    fn compute_finite_difference_velocities_three_frames() {
1655        let frames: Vec<XtcFrame> = (0..3)
1656            .map(|i| XtcFrame {
1657                step: i,
1658                time: i as f32,
1659                box_matrix: [[1.0; 3]; 3],
1660                positions: vec![[i as f32 * 3.0, 0.0, 0.0]],
1661            })
1662            .collect();
1663        let vels = compute_finite_difference_velocities(&frames, 1.0);
1664        // 3 frames → 2 velocity frames
1665        assert_eq!(vels.len(), 2);
1666        // Each frame: dx = 3 over dt = 1
1667        assert!(approx_eq(vels[0][0][0], 3.0));
1668        assert!(approx_eq(vels[1][0][0], 3.0));
1669    }
1670
1671    #[test]
1672    fn compute_finite_difference_velocities_single_frame() {
1673        let frames = vec![XtcFrame {
1674            step: 0,
1675            time: 0.0,
1676            box_matrix: [[1.0; 3]; 3],
1677            positions: vec![[0.0; 3]],
1678        }];
1679        let vels = compute_finite_difference_velocities(&frames, 1.0);
1680        assert!(vels.is_empty());
1681    }
1682}