Skip to main content

oxiphysics_io/
lammps_dump.rs

1#![allow(clippy::should_implement_trait)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! LAMMPS dump format reader and writer (frame-oriented API).
6//!
7//! Supports both positions-only and positions+velocities frames,
8//! multi-frame reading, atom property extraction, triclinic box bounds,
9//! dump file writing with custom properties, and timestep extraction.
10//!
11//! Format:
12//! ```text
13//! ITEM: TIMESTEP
14//! 0
15//! ITEM: NUMBER OF ATOMS
16//! N
17//! ITEM: BOX BOUNDS pp pp pp
18//! xlo xhi
19//! ylo yhi
20//! zlo zhi
21//! ITEM: ATOMS id type x y z [vx vy vz]
22//! 1 1 x y z ...
23//! ```
24
25use std::io::{BufRead, Write};
26
27/// A single simulation frame from a LAMMPS dump file.
28#[derive(Debug, Clone)]
29pub struct LammpsDumpFrame {
30    /// Simulation timestep.
31    pub timestep: u64,
32    /// Number of atoms in this frame.
33    pub n_atoms: usize,
34    /// Box bounds: `[[xlo, xhi\], [ylo, yhi], [zlo, zhi]]`.
35    pub box_bounds: [[f64; 2]; 3],
36    /// Atom IDs.
37    pub atom_ids: Vec<u32>,
38    /// Atom type IDs.
39    pub types: Vec<u32>,
40    /// Atom positions.
41    pub positions: Vec<[f64; 3]>,
42    /// Atom velocities (present when the dump includes `vx vy vz` columns).
43    pub velocities: Option<Vec<[f64; 3]>>,
44    /// Triclinic tilt factors \[xy, xz, yz\], if present.
45    pub tilt_factors: Option<[f64; 3]>,
46    /// Custom per-atom properties (name -> values).
47    pub custom_properties: Vec<(String, Vec<f64>)>,
48}
49
50impl LammpsDumpFrame {
51    /// Extract positions for atoms of a given type.
52    #[allow(dead_code)]
53    pub fn positions_by_type(&self, atom_type: u32) -> Vec<[f64; 3]> {
54        self.types
55            .iter()
56            .enumerate()
57            .filter_map(|(i, &t)| {
58                if t == atom_type {
59                    Some(self.positions[i])
60                } else {
61                    None
62                }
63            })
64            .collect()
65    }
66
67    /// Count atoms of a given type.
68    #[allow(dead_code)]
69    pub fn count_by_type(&self, atom_type: u32) -> usize {
70        self.types.iter().filter(|&&t| t == atom_type).count()
71    }
72
73    /// Get the box dimensions \[lx, ly, lz\].
74    #[allow(dead_code)]
75    pub fn box_dimensions(&self) -> [f64; 3] {
76        [
77            self.box_bounds[0][1] - self.box_bounds[0][0],
78            self.box_bounds[1][1] - self.box_bounds[1][0],
79            self.box_bounds[2][1] - self.box_bounds[2][0],
80        ]
81    }
82
83    /// Get the box center \[cx, cy, cz\].
84    #[allow(dead_code)]
85    pub fn box_center(&self) -> [f64; 3] {
86        [
87            (self.box_bounds[0][0] + self.box_bounds[0][1]) * 0.5,
88            (self.box_bounds[1][0] + self.box_bounds[1][1]) * 0.5,
89            (self.box_bounds[2][0] + self.box_bounds[2][1]) * 0.5,
90        ]
91    }
92
93    /// Get box volume (orthogonal case).
94    #[allow(dead_code)]
95    pub fn box_volume(&self) -> f64 {
96        let dims = self.box_dimensions();
97        dims[0] * dims[1] * dims[2]
98    }
99
100    /// Compute the center of mass (equal-mass atoms).
101    #[allow(dead_code)]
102    pub fn center_of_mass(&self) -> [f64; 3] {
103        if self.positions.is_empty() {
104            return [0.0; 3];
105        }
106        let n = self.positions.len() as f64;
107        let mut com = [0.0; 3];
108        for p in &self.positions {
109            com[0] += p[0];
110            com[1] += p[1];
111            com[2] += p[2];
112        }
113        [com[0] / n, com[1] / n, com[2] / n]
114    }
115
116    /// Get a custom property by name.
117    #[allow(dead_code)]
118    pub fn get_custom_property(&self, name: &str) -> Option<&[f64]> {
119        self.custom_properties
120            .iter()
121            .find(|(n, _)| n == name)
122            .map(|(_, v)| v.as_slice())
123    }
124
125    /// Get all distinct atom types in this frame.
126    #[allow(dead_code)]
127    pub fn atom_types(&self) -> Vec<u32> {
128        let mut types: Vec<u32> = self.types.clone();
129        types.sort_unstable();
130        types.dedup();
131        types
132    }
133}
134
135/// Reader for LAMMPS dump files; reads one frame at a time.
136pub struct LammpsDumpReader;
137
138impl LammpsDumpReader {
139    /// Attempt to read one frame from the given `BufRead` source.
140    ///
141    /// Returns `None` when there is no more data, or
142    /// `Some(Err(...))` on a parse / format error.
143    pub fn read_frame(reader: impl BufRead) -> Option<Result<LammpsDumpFrame, String>> {
144        let mut lines = reader.lines().peekable();
145
146        // Drain any leading blank lines / EOF
147        loop {
148            match lines.peek() {
149                None => return None,
150                Some(Ok(l)) if l.trim().is_empty() => {
151                    lines.next();
152                }
153                Some(Err(_)) => {
154                    let e = lines
155                        .next()
156                        .expect("iterator should have elements")
157                        .expect_err("matched Err(_) arm so value must be Err");
158                    return Some(Err(e.to_string()));
159                }
160                _ => break,
161            }
162        }
163
164        // ---- helpers ---------------------------------------------------
165        macro_rules! next_line {
166            ($label:expr) => {{
167                match lines.next() {
168                    None => return Some(Err(format!("LAMMPS dump: unexpected EOF at {}", $label))),
169                    Some(Err(e)) => return Some(Err(e.to_string())),
170                    Some(Ok(l)) => l,
171                }
172            }};
173        }
174
175        macro_rules! expect_keyword {
176            ($kw:expr) => {{
177                let l = next_line!($kw);
178                if !l.trim().starts_with($kw) {
179                    return Some(Err(format!(
180                        "LAMMPS dump: expected '{}', got '{}'",
181                        $kw,
182                        l.trim()
183                    )));
184                }
185                l
186            }};
187        }
188        // ----------------------------------------------------------------
189
190        expect_keyword!("ITEM: TIMESTEP");
191        let ts_str = next_line!("timestep value");
192        let timestep: u64 = match ts_str.trim().parse() {
193            Ok(v) => v,
194            Err(e) => return Some(Err(format!("LAMMPS dump: bad timestep: {e}"))),
195        };
196
197        expect_keyword!("ITEM: NUMBER OF ATOMS");
198        let na_str = next_line!("n_atoms value");
199        let n_atoms: usize = match na_str.trim().parse() {
200            Ok(v) => v,
201            Err(e) => return Some(Err(format!("LAMMPS dump: bad n_atoms: {e}"))),
202        };
203
204        let box_header = expect_keyword!("ITEM: BOX BOUNDS");
205        let is_triclinic = box_header.contains("xy xz yz");
206
207        let mut box_bounds = [[0.0f64; 2]; 3];
208        let mut tilt_factors: Option<[f64; 3]> = None;
209
210        if is_triclinic {
211            let mut tilts = [0.0f64; 3];
212            for (i, row) in box_bounds.iter_mut().enumerate() {
213                let bl = next_line!(format!("box bound {i}"));
214                let parts: Vec<&str> = bl.split_whitespace().collect();
215                if parts.len() < 3 {
216                    return Some(Err(format!(
217                        "LAMMPS dump: triclinic box line {i} too short"
218                    )));
219                }
220                let lo: f64 = match parts[0].parse() {
221                    Ok(v) => v,
222                    Err(e) => return Some(Err(format!("LAMMPS dump: box lo[{i}]: {e}"))),
223                };
224                let hi: f64 = match parts[1].parse() {
225                    Ok(v) => v,
226                    Err(e) => return Some(Err(format!("LAMMPS dump: box hi[{i}]: {e}"))),
227                };
228                let tilt: f64 = match parts[2].parse() {
229                    Ok(v) => v,
230                    Err(e) => return Some(Err(format!("LAMMPS dump: tilt[{i}]: {e}"))),
231                };
232                *row = [lo, hi];
233                tilts[i] = tilt;
234            }
235            tilt_factors = Some(tilts);
236        } else {
237            for (i, row) in box_bounds.iter_mut().enumerate() {
238                let bl = next_line!(format!("box bound {i}"));
239                let parts: Vec<&str> = bl.split_whitespace().collect();
240                if parts.len() < 2 {
241                    return Some(Err(format!("LAMMPS dump: box line {i} too short")));
242                }
243                let lo: f64 = match parts[0].parse() {
244                    Ok(v) => v,
245                    Err(e) => return Some(Err(format!("LAMMPS dump: box lo[{i}]: {e}"))),
246                };
247                let hi: f64 = match parts[1].parse() {
248                    Ok(v) => v,
249                    Err(e) => return Some(Err(format!("LAMMPS dump: box hi[{i}]: {e}"))),
250                };
251                *row = [lo, hi];
252            }
253        }
254
255        // ITEM: ATOMS id type x y z [vx vy vz] [custom...]
256        let header_line = expect_keyword!("ITEM: ATOMS");
257        let has_velocities = header_line.contains("vx");
258
259        // Parse column names from the header
260        let col_names: Vec<&str> = header_line
261            .trim()
262            .strip_prefix("ITEM: ATOMS")
263            .unwrap_or("")
264            .split_whitespace()
265            .collect();
266
267        // Identify custom property columns (beyond id, type, x, y, z, vx, vy, vz)
268        let standard_cols = ["id", "type", "x", "y", "z", "vx", "vy", "vz"];
269        let custom_col_indices: Vec<(usize, String)> = col_names
270            .iter()
271            .enumerate()
272            .filter(|(_, name)| !standard_cols.contains(name))
273            .map(|(i, name)| (i, name.to_string()))
274            .collect();
275
276        let mut atom_ids = Vec::with_capacity(n_atoms);
277        let mut types = Vec::with_capacity(n_atoms);
278        let mut positions = Vec::with_capacity(n_atoms);
279        let mut velocities: Vec<[f64; 3]> = if has_velocities {
280            Vec::with_capacity(n_atoms)
281        } else {
282            Vec::new()
283        };
284        let mut custom_data: Vec<Vec<f64>> = custom_col_indices
285            .iter()
286            .map(|_| Vec::with_capacity(n_atoms))
287            .collect();
288
289        for i in 0..n_atoms {
290            let al = next_line!(format!("atom {i}"));
291            let parts: Vec<&str> = al.split_whitespace().collect();
292            let min_cols = if has_velocities { 8 } else { 5 };
293            if parts.len() < min_cols {
294                return Some(Err(format!(
295                    "LAMMPS dump: atom line {i} has {} cols, need {}",
296                    parts.len(),
297                    min_cols
298                )));
299            }
300            macro_rules! parse_col {
301                ($idx:expr, $ty:ty, $label:expr) => {
302                    match parts[$idx].parse::<$ty>() {
303                        Ok(v) => v,
304                        Err(e) => {
305                            return Some(Err(format!(
306                                "LAMMPS dump: atom {i} col {} ({}): {e}",
307                                $idx, $label
308                            )))
309                        }
310                    }
311                };
312            }
313            let id = parse_col!(0, u32, "id");
314            let ty = parse_col!(1, u32, "type");
315            let x = parse_col!(2, f64, "x");
316            let y = parse_col!(3, f64, "y");
317            let z = parse_col!(4, f64, "z");
318            atom_ids.push(id);
319            types.push(ty);
320            positions.push([x, y, z]);
321            if has_velocities {
322                let vx = parse_col!(5, f64, "vx");
323                let vy = parse_col!(6, f64, "vy");
324                let vz = parse_col!(7, f64, "vz");
325                velocities.push([vx, vy, vz]);
326            }
327
328            // Parse custom properties
329            for (ci, (col_idx, _)) in custom_col_indices.iter().enumerate() {
330                if let Some(val_str) = parts.get(*col_idx) {
331                    let val = val_str.parse::<f64>().unwrap_or(0.0);
332                    custom_data[ci].push(val);
333                }
334            }
335        }
336
337        let custom_properties: Vec<(String, Vec<f64>)> = custom_col_indices
338            .into_iter()
339            .zip(custom_data)
340            .map(|((_, name), data)| (name, data))
341            .collect();
342
343        Some(Ok(LammpsDumpFrame {
344            timestep,
345            n_atoms,
346            box_bounds,
347            atom_ids,
348            types,
349            positions,
350            velocities: if has_velocities {
351                Some(velocities)
352            } else {
353                None
354            },
355            tilt_factors,
356            custom_properties,
357        }))
358    }
359
360    /// Read all frames from a `BufRead` source.
361    #[allow(dead_code)]
362    pub fn read_all_frames(content: &str) -> Result<Vec<LammpsDumpFrame>, String> {
363        let mut frames = Vec::new();
364        // Split by "ITEM: TIMESTEP" to get individual frame chunks
365        let chunks: Vec<&str> = content
366            .split("ITEM: TIMESTEP\n")
367            .filter(|s| !s.trim().is_empty())
368            .collect();
369
370        for chunk in chunks {
371            let full = format!("ITEM: TIMESTEP\n{chunk}");
372            let cursor = std::io::Cursor::new(full.as_bytes());
373            match Self::read_frame(cursor) {
374                Some(Ok(frame)) => frames.push(frame),
375                Some(Err(e)) => return Err(e),
376                None => {}
377            }
378        }
379        Ok(frames)
380    }
381
382    /// Extract all timesteps from a dump content without fully parsing frames.
383    #[allow(dead_code)]
384    pub fn extract_timesteps(content: &str) -> Vec<u64> {
385        let mut timesteps = Vec::new();
386        let mut lines = content.lines();
387        while let Some(line) = lines.next() {
388            if line.trim() == "ITEM: TIMESTEP"
389                && let Some(ts_line) = lines.next()
390                && let Ok(ts) = ts_line.trim().parse::<u64>()
391            {
392                timesteps.push(ts);
393            }
394        }
395        timesteps
396    }
397}
398
399/// Writer for LAMMPS dump files.
400pub struct LammpsDumpWriter;
401
402impl LammpsDumpWriter {
403    /// Write one frame to any `Write` sink.
404    pub fn write_frame(mut writer: impl Write, frame: &LammpsDumpFrame) -> Result<(), String> {
405        writeln!(writer, "ITEM: TIMESTEP").map_err(|e| e.to_string())?;
406        writeln!(writer, "{}", frame.timestep).map_err(|e| e.to_string())?;
407        writeln!(writer, "ITEM: NUMBER OF ATOMS").map_err(|e| e.to_string())?;
408        writeln!(writer, "{}", frame.n_atoms).map_err(|e| e.to_string())?;
409
410        if let Some(tilts) = frame.tilt_factors {
411            writeln!(writer, "ITEM: BOX BOUNDS xy xz yz pp pp pp").map_err(|e| e.to_string())?;
412            for (i, b) in frame.box_bounds.iter().enumerate() {
413                writeln!(writer, "{} {} {}", b[0], b[1], tilts[i]).map_err(|e| e.to_string())?;
414            }
415        } else {
416            writeln!(writer, "ITEM: BOX BOUNDS pp pp pp").map_err(|e| e.to_string())?;
417            for b in &frame.box_bounds {
418                writeln!(writer, "{} {}", b[0], b[1]).map_err(|e| e.to_string())?;
419            }
420        }
421
422        let has_vel = frame.velocities.is_some();
423        let custom_names: Vec<&str> = frame
424            .custom_properties
425            .iter()
426            .map(|(n, _)| n.as_str())
427            .collect();
428
429        // Build header
430        let mut header = String::from("ITEM: ATOMS id type x y z");
431        if has_vel {
432            header.push_str(" vx vy vz");
433        }
434        for name in &custom_names {
435            header.push(' ');
436            header.push_str(name);
437        }
438        writeln!(writer, "{}", header).map_err(|e| e.to_string())?;
439
440        let vels = frame.velocities.as_deref();
441        for i in 0..frame.n_atoms {
442            let [x, y, z] = frame.positions[i];
443            let mut line = if has_vel {
444                let [vx, vy, vz] = vels.expect("value should be present")[i];
445                format!(
446                    "{} {} {} {} {} {} {} {}",
447                    frame.atom_ids[i], frame.types[i], x, y, z, vx, vy, vz,
448                )
449            } else {
450                format!("{} {} {} {} {}", frame.atom_ids[i], frame.types[i], x, y, z,)
451            };
452
453            for (_, values) in &frame.custom_properties {
454                if i < values.len() {
455                    line.push_str(&format!(" {}", values[i]));
456                }
457            }
458
459            writeln!(writer, "{}", line).map_err(|e| e.to_string())?;
460        }
461        Ok(())
462    }
463
464    /// Write multiple frames to a single `Write` sink.
465    #[allow(dead_code)]
466    pub fn write_frames(writer: impl Write, frames: &[LammpsDumpFrame]) -> Result<(), String> {
467        let mut writer = writer;
468        for frame in frames {
469            Self::write_frame(&mut writer, frame)?;
470        }
471        Ok(())
472    }
473}
474
475/// Compute the number density (atoms / volume) for a frame.
476#[allow(dead_code)]
477pub fn number_density(frame: &LammpsDumpFrame) -> f64 {
478    let vol = frame.box_volume();
479    if vol.abs() < 1e-30 {
480        return 0.0;
481    }
482    frame.n_atoms as f64 / vol
483}
484
485/// Compute mean kinetic energy per atom (assuming unit mass).
486/// KE = 0.5 * v^2 per atom.
487#[allow(dead_code)]
488pub fn mean_kinetic_energy(frame: &LammpsDumpFrame) -> Option<f64> {
489    let vels = frame.velocities.as_ref()?;
490    if vels.is_empty() {
491        return Some(0.0);
492    }
493    let total: f64 = vels
494        .iter()
495        .map(|v| 0.5 * (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]))
496        .sum();
497    Some(total / vels.len() as f64)
498}
499
500// ─── Per-atom stress extraction ───────────────────────────────────────────────
501
502/// Extract per-atom stress tensor components from custom properties.
503///
504/// LAMMPS may output the 6 independent Voigt components:
505/// `c_stress[1..6]` or `sxx syy szz sxy sxz syz`.
506#[allow(dead_code)]
507pub fn extract_stress_tensor(frame: &LammpsDumpFrame) -> Option<Vec<[f64; 6]>> {
508    // Try Voigt-named properties
509    let names = ["sxx", "syy", "szz", "sxy", "sxz", "syz"];
510    let props: Vec<Option<&[f64]>> = names.iter().map(|n| frame.get_custom_property(n)).collect();
511
512    if props.iter().all(|p| p.is_some()) {
513        let n = frame.n_atoms;
514        let result: Vec<[f64; 6]> = (0..n)
515            .map(|i| {
516                [
517                    props[0].expect("value should be present")[i],
518                    props[1].expect("value should be present")[i],
519                    props[2].expect("value should be present")[i],
520                    props[3].expect("value should be present")[i],
521                    props[4].expect("value should be present")[i],
522                    props[5].expect("value should be present")[i],
523                ]
524            })
525            .collect();
526        return Some(result);
527    }
528
529    None
530}
531
532/// Compute the von Mises stress from a Voigt stress tensor \[s11,s22,s33,s12,s13,s23\].
533#[allow(dead_code)]
534pub fn von_mises_stress(s: [f64; 6]) -> f64 {
535    let [s11, s22, s33, s12, s13, s23] = s;
536    (0.5 * ((s11 - s22).powi(2) + (s22 - s33).powi(2) + (s33 - s11).powi(2))
537        + 3.0 * (s12 * s12 + s13 * s13 + s23 * s23))
538        .sqrt()
539}
540
541/// Hydrostatic pressure from a Voigt stress tensor: p = -(s11 + s22 + s33) / 3.
542#[allow(dead_code)]
543pub fn hydrostatic_pressure(s: [f64; 6]) -> f64 {
544    -(s[0] + s[1] + s[2]) / 3.0
545}
546
547// ─── Dump frame time series ────────────────────────────────────────────────────
548
549/// Analysis utilities for a time series of LAMMPS dump frames.
550pub struct DumpTimeSeries {
551    /// All frames in the series.
552    pub frames: Vec<LammpsDumpFrame>,
553}
554
555impl DumpTimeSeries {
556    /// Construct from a vector of frames.
557    #[allow(dead_code)]
558    pub fn new(frames: Vec<LammpsDumpFrame>) -> Self {
559        Self { frames }
560    }
561
562    /// Parse from a multi-frame dump string.
563    #[allow(dead_code)]
564    pub fn from_str(content: &str) -> Result<Self, String> {
565        let frames = LammpsDumpReader::read_all_frames(content)?;
566        Ok(Self { frames })
567    }
568
569    /// Number of frames.
570    #[allow(dead_code)]
571    pub fn len(&self) -> usize {
572        self.frames.len()
573    }
574
575    /// Whether there are no frames.
576    #[allow(dead_code)]
577    pub fn is_empty(&self) -> bool {
578        self.frames.is_empty()
579    }
580
581    /// Extract all timesteps in order.
582    #[allow(dead_code)]
583    pub fn timesteps(&self) -> Vec<u64> {
584        self.frames.iter().map(|f| f.timestep).collect()
585    }
586
587    /// Extract mean kinetic energy per frame.
588    #[allow(dead_code)]
589    pub fn mean_ke_series(&self) -> Vec<Option<f64>> {
590        self.frames.iter().map(mean_kinetic_energy).collect()
591    }
592
593    /// Extract number density per frame.
594    #[allow(dead_code)]
595    pub fn number_density_series(&self) -> Vec<f64> {
596        self.frames.iter().map(number_density).collect()
597    }
598
599    /// Get the frame at a given timestep, if present.
600    #[allow(dead_code)]
601    pub fn frame_at_timestep(&self, ts: u64) -> Option<&LammpsDumpFrame> {
602        self.frames.iter().find(|f| f.timestep == ts)
603    }
604
605    /// Mean position of atom type `atom_type` averaged over all frames.
606    #[allow(dead_code)]
607    pub fn mean_position_by_type(&self, atom_type: u32) -> Option<[f64; 3]> {
608        let mut sum = [0.0f64; 3];
609        let mut count = 0usize;
610        for frame in &self.frames {
611            for pos in frame.positions_by_type(atom_type) {
612                sum[0] += pos[0];
613                sum[1] += pos[1];
614                sum[2] += pos[2];
615                count += 1;
616            }
617        }
618        if count == 0 {
619            return None;
620        }
621        Some([
622            sum[0] / count as f64,
623            sum[1] / count as f64,
624            sum[2] / count as f64,
625        ])
626    }
627}
628
629// ─── LAMMPS atom styles ────────────────────────────────────────────────────────
630
631/// Supported LAMMPS atom styles for dump parsing.
632#[derive(Debug, Clone, Copy, PartialEq)]
633#[allow(dead_code)]
634pub enum LammpsAtomStyle {
635    /// Positions only (atomic style).
636    Atomic,
637    /// Positions + molecule ID + charge.
638    Full,
639    /// Positions + molecule ID.
640    Molecular,
641    /// Custom (user-defined columns).
642    Custom,
643}
644
645/// Parse the atom style from the ITEM: ATOMS header line.
646#[allow(dead_code)]
647pub fn detect_atom_style(header: &str) -> LammpsAtomStyle {
648    let lower = header.to_lowercase();
649    let has_q = lower.contains(" q ");
650    let has_mol = lower.contains(" mol ") || lower.contains("mol\n");
651    match (has_mol, has_q) {
652        (true, true) => LammpsAtomStyle::Full,
653        (true, false) => LammpsAtomStyle::Molecular,
654        _ => LammpsAtomStyle::Atomic,
655    }
656}
657
658// ─── Radial distribution function (g(r)) ─────────────────────────────────────
659
660/// Compute the radial distribution function g(r) from a single frame.
661///
662/// Returns `(r_bins, g_of_r)` where `r_bins[i]` is the centre of bin `i`
663/// and `g_of_r[i]` is g(r) at that bin.
664///
665/// Only considers atoms of `type_a` (centre) and `type_b` (neighbours).
666/// Use `type_a = type_b = 0` to include all atoms.
667#[allow(dead_code)]
668pub fn radial_distribution_function(
669    frame: &LammpsDumpFrame,
670    type_a: u32,
671    type_b: u32,
672    r_max: f64,
673    n_bins: usize,
674) -> (Vec<f64>, Vec<f64>) {
675    let dr = r_max / n_bins as f64;
676    let mut hist = vec![0u64; n_bins];
677
678    let n = frame.n_atoms;
679    let mut n_a = 0usize;
680    let mut n_b = 0usize;
681
682    for i in 0..n {
683        let ti = frame.types[i];
684        if type_a != 0 && ti != type_a {
685            continue;
686        }
687        n_a += 1;
688        for j in 0..n {
689            if i == j {
690                continue;
691            }
692            let tj = frame.types[j];
693            if type_b != 0 && tj != type_b {
694                continue;
695            }
696            n_b += 1;
697            let pi = frame.positions[i];
698            let pj = frame.positions[j];
699            let dx = pi[0] - pj[0];
700            let dy = pi[1] - pj[1];
701            let dz = pi[2] - pj[2];
702            let r = (dx * dx + dy * dy + dz * dz).sqrt();
703            if r < r_max {
704                let bin = (r / dr) as usize;
705                if bin < n_bins {
706                    hist[bin] += 1;
707                }
708            }
709        }
710    }
711
712    let vol = frame.box_volume();
713    let rho = if n_b > 0 && vol > 0.0 {
714        n_b as f64 / vol
715    } else {
716        1.0
717    };
718
719    let r_bins: Vec<f64> = (0..n_bins).map(|i| (i as f64 + 0.5) * dr).collect();
720    let g_of_r: Vec<f64> = r_bins
721        .iter()
722        .zip(hist.iter())
723        .map(|(&r, &count)| {
724            let shell_vol = 4.0 * std::f64::consts::PI * r * r * dr;
725            let n_ideal = rho * shell_vol;
726            if n_ideal < 1e-30 || n_a == 0 {
727                return 0.0;
728            }
729            count as f64 / (n_a as f64 * n_ideal)
730        })
731        .collect();
732
733    (r_bins, g_of_r)
734}
735
736// ─── Per-atom force extraction ────────────────────────────────────────────────
737
738/// Extract per-atom force vectors from custom properties `fx`, `fy`, `fz`.
739#[allow(dead_code)]
740pub fn extract_forces(frame: &LammpsDumpFrame) -> Option<Vec<[f64; 3]>> {
741    let fx = frame.get_custom_property("fx")?;
742    let fy = frame.get_custom_property("fy")?;
743    let fz = frame.get_custom_property("fz")?;
744    let n = frame.n_atoms;
745    let forces: Vec<[f64; 3]> = (0..n).map(|i| [fx[i], fy[i], fz[i]]).collect();
746    Some(forces)
747}
748
749/// Mean force magnitude per atom.
750#[allow(dead_code)]
751pub fn mean_force_magnitude(frame: &LammpsDumpFrame) -> Option<f64> {
752    let forces = extract_forces(frame)?;
753    if forces.is_empty() {
754        return Some(0.0);
755    }
756    let total: f64 = forces
757        .iter()
758        .map(|f| (f[0] * f[0] + f[1] * f[1] + f[2] * f[2]).sqrt())
759        .sum();
760    Some(total / forces.len() as f64)
761}
762
763// ─── Triclinic box utilities ──────────────────────────────────────────────────
764
765/// Convert triclinic box bounds + tilt factors to orthogonal lattice vectors.
766///
767/// Returns `(a, b, c)` where each is a `[f64; 3]` Cartesian vector.
768///
769/// LAMMPS triclinic convention:
770/// a = \[lx,  0,  0\]
771/// b = \[xy, ly,  0\]
772/// c = \[xz, yz, lz\]
773#[allow(dead_code)]
774pub fn triclinic_lattice_vectors(
775    box_bounds: [[f64; 2]; 3],
776    tilts: [f64; 3],
777) -> ([f64; 3], [f64; 3], [f64; 3]) {
778    let lx = box_bounds[0][1] - box_bounds[0][0];
779    let ly = box_bounds[1][1] - box_bounds[1][0];
780    let lz = box_bounds[2][1] - box_bounds[2][0];
781    let [xy, xz, yz] = tilts;
782    let a = [lx, 0.0, 0.0];
783    let b = [xy, ly, 0.0];
784    let c = [xz, yz, lz];
785    (a, b, c)
786}
787
788/// Triclinic box volume from lattice vectors.
789#[allow(dead_code)]
790pub fn triclinic_volume(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> f64 {
791    // V = a · (b × c)
792    let bc = [
793        b[1] * c[2] - b[2] * c[1],
794        b[2] * c[0] - b[0] * c[2],
795        b[0] * c[1] - b[1] * c[0],
796    ];
797    a[0] * bc[0] + a[1] * bc[1] + a[2] * bc[2]
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use std::io::Cursor;
804
805    fn make_frame(timestep: u64, with_velocities: bool) -> LammpsDumpFrame {
806        let positions = vec![
807            [0.0, 0.0, 0.0],
808            [1.0, 0.0, 0.0],
809            [0.0, 1.0, 0.0],
810            [1.0, 1.0, 1.0],
811        ];
812        let velocities = if with_velocities {
813            Some(vec![
814                [0.1, 0.2, 0.3],
815                [-0.1, 0.0, 0.1],
816                [0.0, -0.2, 0.0],
817                [0.5, -0.5, 0.5],
818            ])
819        } else {
820            None
821        };
822        LammpsDumpFrame {
823            timestep,
824            n_atoms: 4,
825            box_bounds: [[0.0, 5.0], [0.0, 5.0], [0.0, 5.0]],
826            atom_ids: vec![1, 2, 3, 4],
827            types: vec![1, 1, 2, 2],
828            positions,
829            velocities,
830            tilt_factors: None,
831            custom_properties: Vec::new(),
832        }
833    }
834
835    #[test]
836    fn test_lammps_write_read_roundtrip() {
837        let frame = make_frame(100, false);
838        let mut buf: Vec<u8> = Vec::new();
839        LammpsDumpWriter::write_frame(&mut buf, &frame).expect("write failed");
840
841        let parsed = LammpsDumpReader::read_frame(Cursor::new(&buf))
842            .expect("no frame found")
843            .expect("parse error");
844
845        assert_eq!(parsed.timestep, 100);
846        assert_eq!(parsed.n_atoms, 4);
847        assert_eq!(parsed.atom_ids, vec![1, 2, 3, 4]);
848        assert_eq!(parsed.types, vec![1, 1, 2, 2]);
849        assert!((parsed.positions[0][0]).abs() < 1e-10);
850        assert!((parsed.positions[1][0] - 1.0).abs() < 1e-10);
851        assert!((parsed.positions[3][2] - 1.0).abs() < 1e-10);
852        assert!(parsed.velocities.is_none());
853    }
854
855    #[test]
856    fn test_lammps_multi_frame() {
857        let mut buf: Vec<u8> = Vec::new();
858        for ts in [0u64, 100, 200] {
859            let frame = make_frame(ts, false);
860            LammpsDumpWriter::write_frame(&mut buf, &frame).expect("write failed");
861        }
862
863        // Read all three frames from the combined buffer
864        let text = String::from_utf8(buf).expect("utf8");
865        let mut timesteps_found = Vec::new();
866
867        // Split by "ITEM: TIMESTEP" to get individual frame chunks
868        let frames_text: Vec<&str> = text
869            .split("ITEM: TIMESTEP\n")
870            .filter(|s| !s.trim().is_empty())
871            .collect();
872        assert_eq!(frames_text.len(), 3, "expected 3 frames");
873
874        for chunk in &frames_text {
875            let full = format!("ITEM: TIMESTEP\n{chunk}");
876            let result = LammpsDumpReader::read_frame(Cursor::new(full.as_bytes()))
877                .expect("no frame")
878                .expect("parse error");
879            timesteps_found.push(result.timestep);
880        }
881
882        assert_eq!(timesteps_found, vec![0, 100, 200]);
883    }
884
885    #[test]
886    fn test_lammps_box_bounds() {
887        let frame = LammpsDumpFrame {
888            timestep: 0,
889            n_atoms: 1,
890            box_bounds: [[-2.5, 2.5], [0.0, 10.0], [1.0, 9.0]],
891            atom_ids: vec![1],
892            types: vec![1],
893            positions: vec![[0.0, 5.0, 5.0]],
894            velocities: None,
895            tilt_factors: None,
896            custom_properties: Vec::new(),
897        };
898        let mut buf: Vec<u8> = Vec::new();
899        LammpsDumpWriter::write_frame(&mut buf, &frame).expect("write");
900
901        let parsed = LammpsDumpReader::read_frame(Cursor::new(&buf))
902            .expect("no frame")
903            .expect("parse");
904
905        assert!((parsed.box_bounds[0][0] - (-2.5)).abs() < 1e-10);
906        assert!((parsed.box_bounds[0][1] - 2.5).abs() < 1e-10);
907        assert!((parsed.box_bounds[1][0]).abs() < 1e-10);
908        assert!((parsed.box_bounds[1][1] - 10.0).abs() < 1e-10);
909        assert!((parsed.box_bounds[2][0] - 1.0).abs() < 1e-10);
910        assert!((parsed.box_bounds[2][1] - 9.0).abs() < 1e-10);
911    }
912
913    #[test]
914    fn test_lammps_with_velocities() {
915        let frame = make_frame(50, true);
916        let mut buf: Vec<u8> = Vec::new();
917        LammpsDumpWriter::write_frame(&mut buf, &frame).expect("write");
918
919        let parsed = LammpsDumpReader::read_frame(Cursor::new(&buf))
920            .expect("no frame")
921            .expect("parse");
922
923        assert_eq!(parsed.timestep, 50);
924        let vels = parsed.velocities.expect("velocities missing");
925        assert_eq!(vels.len(), 4);
926        assert!((vels[0][0] - 0.1).abs() < 1e-10);
927        assert!((vels[0][1] - 0.2).abs() < 1e-10);
928        assert!((vels[0][2] - 0.3).abs() < 1e-10);
929        assert!((vels[1][0] - (-0.1)).abs() < 1e-10);
930        assert!((vels[3][0] - 0.5).abs() < 1e-10);
931        assert!((vels[3][2] - 0.5).abs() < 1e-10);
932    }
933
934    // ─── New tests ────────────────────────────────────────────────────
935
936    #[test]
937    fn test_positions_by_type() {
938        let frame = make_frame(0, false);
939        let type1 = frame.positions_by_type(1);
940        assert_eq!(type1.len(), 2);
941        let type2 = frame.positions_by_type(2);
942        assert_eq!(type2.len(), 2);
943        let type3 = frame.positions_by_type(3);
944        assert!(type3.is_empty());
945    }
946
947    #[test]
948    fn test_count_by_type() {
949        let frame = make_frame(0, false);
950        assert_eq!(frame.count_by_type(1), 2);
951        assert_eq!(frame.count_by_type(2), 2);
952        assert_eq!(frame.count_by_type(99), 0);
953    }
954
955    #[test]
956    fn test_box_dimensions() {
957        let frame = make_frame(0, false);
958        let dims = frame.box_dimensions();
959        assert!((dims[0] - 5.0).abs() < 1e-12);
960        assert!((dims[1] - 5.0).abs() < 1e-12);
961        assert!((dims[2] - 5.0).abs() < 1e-12);
962    }
963
964    #[test]
965    fn test_box_center() {
966        let frame = make_frame(0, false);
967        let center = frame.box_center();
968        assert!((center[0] - 2.5).abs() < 1e-12);
969        assert!((center[1] - 2.5).abs() < 1e-12);
970        assert!((center[2] - 2.5).abs() < 1e-12);
971    }
972
973    #[test]
974    fn test_box_volume() {
975        let frame = make_frame(0, false);
976        assert!((frame.box_volume() - 125.0).abs() < 1e-10);
977    }
978
979    #[test]
980    fn test_center_of_mass() {
981        let frame = make_frame(0, false);
982        let com = frame.center_of_mass();
983        // Positions: [0,0,0], [1,0,0], [0,1,0], [1,1,1]
984        // Mean: [0.5, 0.5, 0.25]
985        assert!((com[0] - 0.5).abs() < 1e-12);
986        assert!((com[1] - 0.5).abs() < 1e-12);
987        assert!((com[2] - 0.25).abs() < 1e-12);
988    }
989
990    #[test]
991    fn test_center_of_mass_empty() {
992        let frame = LammpsDumpFrame {
993            timestep: 0,
994            n_atoms: 0,
995            box_bounds: [[0.0, 1.0]; 3],
996            atom_ids: vec![],
997            types: vec![],
998            positions: vec![],
999            velocities: None,
1000            tilt_factors: None,
1001            custom_properties: Vec::new(),
1002        };
1003        let com = frame.center_of_mass();
1004        assert_eq!(com, [0.0, 0.0, 0.0]);
1005    }
1006
1007    #[test]
1008    fn test_atom_types() {
1009        let frame = make_frame(0, false);
1010        let types = frame.atom_types();
1011        assert_eq!(types, vec![1, 2]);
1012    }
1013
1014    #[test]
1015    fn test_read_all_frames() {
1016        let mut buf: Vec<u8> = Vec::new();
1017        for ts in [0u64, 100, 200] {
1018            LammpsDumpWriter::write_frame(&mut buf, &make_frame(ts, false)).unwrap();
1019        }
1020        let text = String::from_utf8(buf).unwrap();
1021        let frames = LammpsDumpReader::read_all_frames(&text).unwrap();
1022        assert_eq!(frames.len(), 3);
1023        assert_eq!(frames[0].timestep, 0);
1024        assert_eq!(frames[1].timestep, 100);
1025        assert_eq!(frames[2].timestep, 200);
1026    }
1027
1028    #[test]
1029    fn test_extract_timesteps() {
1030        let mut buf: Vec<u8> = Vec::new();
1031        for ts in [10u64, 20, 30] {
1032            LammpsDumpWriter::write_frame(&mut buf, &make_frame(ts, false)).unwrap();
1033        }
1034        let text = String::from_utf8(buf).unwrap();
1035        let timesteps = LammpsDumpReader::extract_timesteps(&text);
1036        assert_eq!(timesteps, vec![10, 20, 30]);
1037    }
1038
1039    #[test]
1040    fn test_triclinic_write_read() {
1041        let frame = LammpsDumpFrame {
1042            timestep: 42,
1043            n_atoms: 2,
1044            box_bounds: [[0.0, 10.0], [0.0, 10.0], [0.0, 10.0]],
1045            atom_ids: vec![1, 2],
1046            types: vec![1, 1],
1047            positions: vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
1048            velocities: None,
1049            tilt_factors: Some([0.5, 0.0, -0.3]),
1050            custom_properties: Vec::new(),
1051        };
1052        let mut buf: Vec<u8> = Vec::new();
1053        LammpsDumpWriter::write_frame(&mut buf, &frame).unwrap();
1054
1055        let parsed = LammpsDumpReader::read_frame(Cursor::new(&buf))
1056            .unwrap()
1057            .unwrap();
1058
1059        assert_eq!(parsed.timestep, 42);
1060        let tilts = parsed.tilt_factors.expect("tilt factors missing");
1061        assert!((tilts[0] - 0.5).abs() < 1e-10);
1062        assert!((tilts[1] - 0.0).abs() < 1e-10);
1063        assert!((tilts[2] - (-0.3)).abs() < 1e-10);
1064    }
1065
1066    #[test]
1067    fn test_number_density() {
1068        let frame = make_frame(0, false);
1069        let rho = number_density(&frame);
1070        // 4 atoms / 125.0 volume = 0.032
1071        assert!((rho - 0.032).abs() < 1e-10);
1072    }
1073
1074    #[test]
1075    fn test_mean_kinetic_energy() {
1076        let frame = make_frame(0, true);
1077        let ke = mean_kinetic_energy(&frame).unwrap();
1078        // vels: [0.1,0.2,0.3], [-0.1,0.0,0.1], [0.0,-0.2,0.0], [0.5,-0.5,0.5]
1079        // KE per atom: 0.5*(0.01+0.04+0.09)=0.07, 0.5*(0.01+0+0.01)=0.01,
1080        //              0.5*(0+0.04+0)=0.02, 0.5*(0.25+0.25+0.25)=0.375
1081        // total = 0.475, mean = 0.475/4 = 0.11875
1082        assert!((ke - 0.11875).abs() < 1e-10);
1083    }
1084
1085    #[test]
1086    fn test_mean_kinetic_energy_no_velocities() {
1087        let frame = make_frame(0, false);
1088        assert!(mean_kinetic_energy(&frame).is_none());
1089    }
1090
1091    #[test]
1092    fn test_write_frames_multiple() {
1093        let frames: Vec<LammpsDumpFrame> = (0..3).map(|i| make_frame(i * 50, false)).collect();
1094        let mut buf: Vec<u8> = Vec::new();
1095        LammpsDumpWriter::write_frames(&mut buf, &frames).unwrap();
1096        let text = String::from_utf8(buf).unwrap();
1097        let timesteps = LammpsDumpReader::extract_timesteps(&text);
1098        assert_eq!(timesteps, vec![0, 50, 100]);
1099    }
1100
1101    #[test]
1102    fn test_custom_properties_roundtrip() {
1103        let frame = LammpsDumpFrame {
1104            timestep: 0,
1105            n_atoms: 2,
1106            box_bounds: [[0.0, 5.0]; 3],
1107            atom_ids: vec![1, 2],
1108            types: vec![1, 1],
1109            positions: vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
1110            velocities: None,
1111            tilt_factors: None,
1112            custom_properties: vec![("energy".to_string(), vec![10.5, 20.3])],
1113        };
1114        let mut buf: Vec<u8> = Vec::new();
1115        LammpsDumpWriter::write_frame(&mut buf, &frame).unwrap();
1116        let text = String::from_utf8(buf).unwrap();
1117        // Verify the header includes the custom property
1118        assert!(text.contains("energy"));
1119    }
1120
1121    #[test]
1122    fn test_get_custom_property() {
1123        let frame = LammpsDumpFrame {
1124            timestep: 0,
1125            n_atoms: 2,
1126            box_bounds: [[0.0, 1.0]; 3],
1127            atom_ids: vec![1, 2],
1128            types: vec![1, 1],
1129            positions: vec![[0.0; 3]; 2],
1130            velocities: None,
1131            tilt_factors: None,
1132            custom_properties: vec![("charge".to_string(), vec![1.0, -1.0])],
1133        };
1134        let charge = frame.get_custom_property("charge").unwrap();
1135        assert_eq!(charge.len(), 2);
1136        assert!((charge[0] - 1.0).abs() < 1e-12);
1137        assert!((charge[1] - (-1.0)).abs() < 1e-12);
1138        assert!(frame.get_custom_property("missing").is_none());
1139    }
1140
1141    #[test]
1142    fn test_box_dimensions_asymmetric() {
1143        let frame = LammpsDumpFrame {
1144            timestep: 0,
1145            n_atoms: 0,
1146            box_bounds: [[-5.0, 5.0], [0.0, 20.0], [10.0, 30.0]],
1147            atom_ids: vec![],
1148            types: vec![],
1149            positions: vec![],
1150            velocities: None,
1151            tilt_factors: None,
1152            custom_properties: Vec::new(),
1153        };
1154        let dims = frame.box_dimensions();
1155        assert!((dims[0] - 10.0).abs() < 1e-12);
1156        assert!((dims[1] - 20.0).abs() < 1e-12);
1157        assert!((dims[2] - 20.0).abs() < 1e-12);
1158    }
1159
1160    // ─── Tests for new functions ───────────────────────────────────────
1161
1162    fn make_frame_with_stress(timestep: u64) -> LammpsDumpFrame {
1163        LammpsDumpFrame {
1164            timestep,
1165            n_atoms: 2,
1166            box_bounds: [[0.0, 10.0]; 3],
1167            atom_ids: vec![1, 2],
1168            types: vec![1, 1],
1169            positions: vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
1170            velocities: None,
1171            tilt_factors: None,
1172            custom_properties: vec![
1173                ("sxx".to_string(), vec![1.0, 2.0]),
1174                ("syy".to_string(), vec![3.0, 4.0]),
1175                ("szz".to_string(), vec![5.0, 6.0]),
1176                ("sxy".to_string(), vec![0.5, 0.5]),
1177                ("sxz".to_string(), vec![0.2, 0.2]),
1178                ("syz".to_string(), vec![0.1, 0.1]),
1179            ],
1180        }
1181    }
1182
1183    #[test]
1184    fn test_extract_stress_tensor_present() {
1185        let frame = make_frame_with_stress(0);
1186        let tensors = extract_stress_tensor(&frame).expect("stress tensors should be present");
1187        assert_eq!(tensors.len(), 2);
1188        assert!((tensors[0][0] - 1.0).abs() < 1e-12); // sxx for atom 0
1189        assert!((tensors[1][0] - 2.0).abs() < 1e-12); // sxx for atom 1
1190        assert!((tensors[0][2] - 5.0).abs() < 1e-12); // szz for atom 0
1191    }
1192
1193    #[test]
1194    fn test_extract_stress_tensor_missing() {
1195        let frame = make_frame(0, false);
1196        assert!(extract_stress_tensor(&frame).is_none());
1197    }
1198
1199    #[test]
1200    fn test_von_mises_stress_isotropic() {
1201        // Pure hydrostatic: s11=s22=s33=p, shear=0 → von Mises = 0
1202        let s = [5.0, 5.0, 5.0, 0.0, 0.0, 0.0];
1203        let vm = von_mises_stress(s);
1204        assert!(vm.abs() < 1e-10);
1205    }
1206
1207    #[test]
1208    fn test_von_mises_stress_uniaxial() {
1209        // s11=1, rest=0 → VM = sqrt(0.5*((1-0)^2+(0-0)^2+(0-1)^2)) = sqrt(1) = 1
1210        let s = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1211        let vm = von_mises_stress(s);
1212        assert!((vm - 1.0).abs() < 1e-10);
1213    }
1214
1215    #[test]
1216    fn test_hydrostatic_pressure() {
1217        let s = [3.0, 3.0, 3.0, 0.0, 0.0, 0.0];
1218        let p = hydrostatic_pressure(s);
1219        assert!((p - (-3.0)).abs() < 1e-12);
1220    }
1221
1222    #[test]
1223    fn test_hydrostatic_pressure_compression() {
1224        // Negative diagonal = compression → positive pressure
1225        let s = [-10.0, -10.0, -10.0, 0.0, 0.0, 0.0];
1226        let p = hydrostatic_pressure(s);
1227        assert!((p - 10.0).abs() < 1e-12);
1228    }
1229
1230    #[test]
1231    fn test_dump_time_series_new_len() {
1232        let frames: Vec<LammpsDumpFrame> = (0..5).map(|i| make_frame(i * 100, false)).collect();
1233        let ts = DumpTimeSeries::new(frames);
1234        assert_eq!(ts.len(), 5);
1235        assert!(!ts.is_empty());
1236    }
1237
1238    #[test]
1239    fn test_dump_time_series_empty() {
1240        let ts = DumpTimeSeries::new(vec![]);
1241        assert_eq!(ts.len(), 0);
1242        assert!(ts.is_empty());
1243    }
1244
1245    #[test]
1246    fn test_dump_time_series_timesteps() {
1247        let frames: Vec<LammpsDumpFrame> = (0..3).map(|i| make_frame(i * 50, false)).collect();
1248        let ts = DumpTimeSeries::new(frames);
1249        assert_eq!(ts.timesteps(), vec![0, 50, 100]);
1250    }
1251
1252    #[test]
1253    fn test_dump_time_series_from_str() {
1254        let mut buf: Vec<u8> = Vec::new();
1255        for i in 0..3u64 {
1256            LammpsDumpWriter::write_frame(&mut buf, &make_frame(i * 10, false)).unwrap();
1257        }
1258        let content = String::from_utf8(buf).unwrap();
1259        let ts = DumpTimeSeries::from_str(&content).unwrap();
1260        assert_eq!(ts.len(), 3);
1261        assert_eq!(ts.timesteps(), vec![0, 10, 20]);
1262    }
1263
1264    #[test]
1265    fn test_dump_time_series_mean_ke_series() {
1266        let frames: Vec<LammpsDumpFrame> = vec![
1267            make_frame(0, true),
1268            make_frame(1, false),
1269            make_frame(2, true),
1270        ];
1271        let ts = DumpTimeSeries::new(frames);
1272        let ke_series = ts.mean_ke_series();
1273        assert_eq!(ke_series.len(), 3);
1274        assert!(ke_series[0].is_some());
1275        assert!(ke_series[1].is_none());
1276        assert!(ke_series[2].is_some());
1277    }
1278
1279    #[test]
1280    fn test_dump_time_series_number_density_series() {
1281        let frames: Vec<LammpsDumpFrame> = (0..3).map(|i| make_frame(i, false)).collect();
1282        let ts = DumpTimeSeries::new(frames);
1283        let rho_series = ts.number_density_series();
1284        // Each frame has 4 atoms in 5^3 = 125 volume
1285        assert_eq!(rho_series.len(), 3);
1286        for rho in &rho_series {
1287            assert!((rho - 0.032).abs() < 1e-10);
1288        }
1289    }
1290
1291    #[test]
1292    fn test_dump_time_series_frame_at_timestep() {
1293        let frames: Vec<LammpsDumpFrame> = (0..5).map(|i| make_frame(i * 100, false)).collect();
1294        let ts = DumpTimeSeries::new(frames);
1295        assert!(ts.frame_at_timestep(200).is_some());
1296        assert_eq!(ts.frame_at_timestep(200).unwrap().timestep, 200);
1297        assert!(ts.frame_at_timestep(999).is_none());
1298    }
1299
1300    #[test]
1301    fn test_dump_time_series_mean_position_by_type() {
1302        let frames: Vec<LammpsDumpFrame> = (0..2).map(|i| make_frame(i, false)).collect();
1303        let ts = DumpTimeSeries::new(frames);
1304        // type 1 positions across all frames: [0,0,0] and [1,0,0] repeated twice
1305        // mean = [0.5, 0, 0]
1306        let mean = ts
1307            .mean_position_by_type(1)
1308            .expect("type 1 should have positions");
1309        assert!((mean[0] - 0.5).abs() < 1e-12);
1310        assert!((mean[1] - 0.0).abs() < 1e-12);
1311        assert!((mean[2] - 0.0).abs() < 1e-12);
1312        assert!(ts.mean_position_by_type(99).is_none());
1313    }
1314
1315    #[test]
1316    fn test_detect_atom_style_atomic() {
1317        let style = detect_atom_style("ITEM: ATOMS id type x y z");
1318        assert_eq!(style, LammpsAtomStyle::Atomic);
1319    }
1320
1321    #[test]
1322    fn test_detect_atom_style_full() {
1323        let style = detect_atom_style("ITEM: ATOMS id mol type q x y z");
1324        assert_eq!(style, LammpsAtomStyle::Full);
1325    }
1326
1327    #[test]
1328    fn test_detect_atom_style_molecular() {
1329        let style = detect_atom_style("ITEM: ATOMS id mol type x y z");
1330        assert_eq!(style, LammpsAtomStyle::Molecular);
1331    }
1332
1333    #[test]
1334    fn test_rdf_length() {
1335        let frame = make_frame(0, false);
1336        let n_bins = 20;
1337        let (r_bins, g) = radial_distribution_function(&frame, 0, 0, 2.5, n_bins);
1338        assert_eq!(r_bins.len(), n_bins);
1339        assert_eq!(g.len(), n_bins);
1340    }
1341
1342    #[test]
1343    fn test_rdf_r_bins_positive() {
1344        let frame = make_frame(0, false);
1345        let (r_bins, _) = radial_distribution_function(&frame, 0, 0, 2.0, 10);
1346        for &r in &r_bins {
1347            assert!(r > 0.0);
1348        }
1349    }
1350
1351    #[test]
1352    fn test_rdf_nonnegative() {
1353        let frame = make_frame(0, false);
1354        let (_, g) = radial_distribution_function(&frame, 0, 0, 2.5, 20);
1355        for &v in &g {
1356            assert!(v >= 0.0);
1357        }
1358    }
1359
1360    #[test]
1361    fn test_extract_forces_present() {
1362        let frame = LammpsDumpFrame {
1363            timestep: 0,
1364            n_atoms: 2,
1365            box_bounds: [[0.0, 5.0]; 3],
1366            atom_ids: vec![1, 2],
1367            types: vec![1, 1],
1368            positions: vec![[0.0; 3]; 2],
1369            velocities: None,
1370            tilt_factors: None,
1371            custom_properties: vec![
1372                ("fx".to_string(), vec![1.0, -1.0]),
1373                ("fy".to_string(), vec![0.5, -0.5]),
1374                ("fz".to_string(), vec![0.0, 0.0]),
1375            ],
1376        };
1377        let forces = extract_forces(&frame).expect("forces should be present");
1378        assert_eq!(forces.len(), 2);
1379        assert!((forces[0][0] - 1.0).abs() < 1e-12);
1380        assert!((forces[1][0] - (-1.0)).abs() < 1e-12);
1381    }
1382
1383    #[test]
1384    fn test_extract_forces_missing() {
1385        let frame = make_frame(0, false);
1386        assert!(extract_forces(&frame).is_none());
1387    }
1388
1389    #[test]
1390    fn test_mean_force_magnitude() {
1391        let frame = LammpsDumpFrame {
1392            timestep: 0,
1393            n_atoms: 1,
1394            box_bounds: [[0.0, 5.0]; 3],
1395            atom_ids: vec![1],
1396            types: vec![1],
1397            positions: vec![[0.0; 3]],
1398            velocities: None,
1399            tilt_factors: None,
1400            custom_properties: vec![
1401                ("fx".to_string(), vec![3.0]),
1402                ("fy".to_string(), vec![4.0]),
1403                ("fz".to_string(), vec![0.0]),
1404            ],
1405        };
1406        let mean_f = mean_force_magnitude(&frame).expect("should have forces");
1407        assert!((mean_f - 5.0).abs() < 1e-10);
1408    }
1409
1410    #[test]
1411    fn test_triclinic_lattice_vectors_orthogonal() {
1412        let bounds = [[0.0, 10.0], [0.0, 8.0], [0.0, 6.0]];
1413        let tilts = [0.0, 0.0, 0.0];
1414        let (a, b, c) = triclinic_lattice_vectors(bounds, tilts);
1415        assert!((a[0] - 10.0).abs() < 1e-12);
1416        assert!((a[1]).abs() < 1e-12);
1417        assert!((b[0]).abs() < 1e-12);
1418        assert!((b[1] - 8.0).abs() < 1e-12);
1419        assert!((c[2] - 6.0).abs() < 1e-12);
1420    }
1421
1422    #[test]
1423    fn test_triclinic_lattice_vectors_tilted() {
1424        let bounds = [[0.0, 10.0], [0.0, 10.0], [0.0, 10.0]];
1425        let tilts = [2.0, 1.0, 0.5];
1426        let (a, b, c) = triclinic_lattice_vectors(bounds, tilts);
1427        assert!((a[0] - 10.0).abs() < 1e-12);
1428        assert!((b[0] - 2.0).abs() < 1e-12); // xy
1429        assert!((c[0] - 1.0).abs() < 1e-12); // xz
1430        assert!((c[1] - 0.5).abs() < 1e-12); // yz
1431    }
1432
1433    #[test]
1434    fn test_triclinic_volume_orthogonal() {
1435        let a = [10.0, 0.0, 0.0];
1436        let b = [0.0, 8.0, 0.0];
1437        let c = [0.0, 0.0, 6.0];
1438        let vol = triclinic_volume(a, b, c);
1439        assert!((vol - 480.0).abs() < 1e-10);
1440    }
1441
1442    #[test]
1443    fn test_triclinic_volume_tilted() {
1444        // Cube with tilt: volume should still be lx*ly*lz for small tilts
1445        let bounds = [[0.0, 10.0]; 3];
1446        let tilts = [1.0, 0.0, 0.0];
1447        let (a, b, c) = triclinic_lattice_vectors(bounds, tilts);
1448        let vol = triclinic_volume(a, b, c);
1449        // V = |a·(b×c)| = lx*ly*lz = 1000 regardless of xy tilt
1450        assert!((vol - 1000.0).abs() < 1e-8);
1451    }
1452}