Skip to main content

oxiphysics_io/
particle_data_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Particle data I/O — read/write particle datasets in multiple formats.
5//!
6//! Supported formats:
7//! - H5Part (HDF5-based particle format) — reader and writer
8//! - VTK PolyData — writer for particle visualization
9//! - JSON — compact writer with bounding-box metadata
10//! - Delta / diff — write only changed particles between frames
11//!
12//! # Example
13//! ```no_run
14//! use oxiphysics_io::particle_data_io::{ParticleDataset, ParticleJsonWriter};
15//!
16//! let mut ds = ParticleDataset::new();
17//! ds.add_particle(0, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 1.0, 0.1);
18//! let json = ParticleJsonWriter::write_to_string(&ds, true).unwrap();
19//! assert!(!json.is_empty());
20//! ```
21
22#![allow(dead_code)]
23#![allow(clippy::too_many_arguments)]
24
25use std::collections::HashMap;
26
27// ---------------------------------------------------------------------------
28// ParticleDataset
29// ---------------------------------------------------------------------------
30
31/// A single-frame snapshot of a particle simulation.
32///
33/// Each particle is identified by a unique integer `id` and carries:
34/// position, velocity, mass, radius, and an optional property bag.
35#[derive(Debug, Clone, Default)]
36pub struct ParticleDataset {
37    /// Particle IDs (parallel arrays with `positions`, `velocities`, etc.).
38    pub ids: Vec<u64>,
39    /// Particle positions — `[x, y, z]` for each particle.
40    pub positions: Vec<[f64; 3]>,
41    /// Particle velocities — `[vx, vy, vz]` for each particle.
42    pub velocities: Vec<[f64; 3]>,
43    /// Particle masses.
44    pub masses: Vec<f64>,
45    /// Particle radii.
46    pub radii: Vec<f64>,
47    /// Named scalar/vector properties per particle (e.g. `"temperature"`).
48    pub properties: HashMap<String, Vec<f64>>,
49    /// Simulation time associated with this snapshot.
50    pub time: f64,
51    /// Simulation step index.
52    pub step: u64,
53}
54
55impl ParticleDataset {
56    /// Create an empty dataset at time 0.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Create a dataset with the given time and step.
62    pub fn with_time(time: f64, step: u64) -> Self {
63        Self {
64            time,
65            step,
66            ..Default::default()
67        }
68    }
69
70    /// Return the number of particles in this dataset.
71    pub fn len(&self) -> usize {
72        self.ids.len()
73    }
74
75    /// Return `true` if the dataset contains no particles.
76    pub fn is_empty(&self) -> bool {
77        self.ids.is_empty()
78    }
79
80    /// Add a single particle, returning its index.
81    pub fn add_particle(
82        &mut self,
83        id: u64,
84        position: [f64; 3],
85        velocity: [f64; 3],
86        mass: f64,
87        radius: f64,
88    ) -> usize {
89        let idx = self.ids.len();
90        self.ids.push(id);
91        self.positions.push(position);
92        self.velocities.push(velocity);
93        self.masses.push(mass);
94        self.radii.push(radius);
95        idx
96    }
97
98    /// Add a named scalar property array.
99    ///
100    /// `values` must have the same length as the current particle count (or be
101    /// empty, which will be zero-extended later).
102    pub fn add_property(&mut self, name: impl Into<String>, values: Vec<f64>) {
103        self.properties.insert(name.into(), values);
104    }
105
106    /// Return the position of particle at index `i`.
107    pub fn position(&self, i: usize) -> [f64; 3] {
108        self.positions[i]
109    }
110
111    /// Return the velocity of particle at index `i`.
112    pub fn velocity(&self, i: usize) -> [f64; 3] {
113        self.velocities[i]
114    }
115
116    /// Compute the axis-aligned bounding box as `(min, max)`.
117    ///
118    /// Returns `None` if the dataset is empty.
119    pub fn bounding_box(&self) -> Option<([f64; 3], [f64; 3])> {
120        if self.positions.is_empty() {
121            return None;
122        }
123        let mut mn = [f64::INFINITY; 3];
124        let mut mx = [f64::NEG_INFINITY; 3];
125        for p in &self.positions {
126            for k in 0..3 {
127                if p[k] < mn[k] {
128                    mn[k] = p[k];
129                }
130                if p[k] > mx[k] {
131                    mx[k] = p[k];
132                }
133            }
134        }
135        Some((mn, mx))
136    }
137
138    /// Compute the mean position (centroid) of all particles.
139    pub fn centroid(&self) -> [f64; 3] {
140        if self.positions.is_empty() {
141            return [0.0; 3];
142        }
143        let n = self.positions.len() as f64;
144        let mut sum = [0.0; 3];
145        for p in &self.positions {
146            sum[0] += p[0];
147            sum[1] += p[1];
148            sum[2] += p[2];
149        }
150        [sum[0] / n, sum[1] / n, sum[2] / n]
151    }
152
153    /// Compute the total kinetic energy (0.5 * m * v²).
154    pub fn kinetic_energy(&self) -> f64 {
155        self.masses
156            .iter()
157            .zip(self.velocities.iter())
158            .map(|(m, v)| {
159                let v2 = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
160                0.5 * m * v2
161            })
162            .sum()
163    }
164
165    /// Return the maximum speed among all particles.
166    pub fn max_speed(&self) -> f64 {
167        self.velocities
168            .iter()
169            .map(|v| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt())
170            .fold(0.0_f64, f64::max)
171    }
172
173    /// Merge another dataset into this one (append particles).
174    pub fn merge(&mut self, other: &ParticleDataset) {
175        self.ids.extend_from_slice(&other.ids);
176        self.positions.extend_from_slice(&other.positions);
177        self.velocities.extend_from_slice(&other.velocities);
178        self.masses.extend_from_slice(&other.masses);
179        self.radii.extend_from_slice(&other.radii);
180    }
181
182    /// Filter particles by a predicate on their index, returning a new dataset.
183    pub fn filter<F>(&self, pred: F) -> ParticleDataset
184    where
185        F: Fn(usize) -> bool,
186    {
187        let mut out = ParticleDataset::with_time(self.time, self.step);
188        for i in 0..self.ids.len() {
189            if pred(i) {
190                out.add_particle(
191                    self.ids[i],
192                    self.positions[i],
193                    self.velocities[i],
194                    self.masses[i],
195                    self.radii[i],
196                );
197            }
198        }
199        out
200    }
201}
202
203// ---------------------------------------------------------------------------
204// H5Part (HDF5-based particle format) — pure-Rust simulation
205// ---------------------------------------------------------------------------
206
207/// Simulated representation of a single H5Part time step.
208#[derive(Debug, Clone)]
209pub struct H5PartStep {
210    /// Step index.
211    pub step: u64,
212    /// Simulation time.
213    pub time: f64,
214    /// Particle dataset at this time step.
215    pub dataset: ParticleDataset,
216}
217
218/// Reader for the H5Part particle trajectory format.
219///
220/// H5Part stores particle data as an HDF5 file with one group per time step
221/// (`/Step#0`, `/Step#1`, …).  This implementation provides a pure-Rust
222/// in-memory simulation of the format (no HDF5 C library required).
223#[derive(Debug, Clone, Default)]
224pub struct H5partReader {
225    /// Loaded time steps (in order).
226    pub steps: Vec<H5PartStep>,
227    /// Metadata attributes stored at file level.
228    pub attributes: HashMap<String, String>,
229}
230
231impl H5partReader {
232    /// Create an empty reader.
233    pub fn new() -> Self {
234        Self::default()
235    }
236
237    /// Load a dataset from a byte slice simulating H5Part binary content.
238    ///
239    /// In a real implementation this would call `hdf5` crate APIs.
240    /// Here we parse a simple custom binary format for testing purposes.
241    pub fn from_bytes(data: &[u8]) -> crate::Result<Self> {
242        // Minimal "magic" header check (8 bytes: b"H5PART\0\0").
243        if data.len() < 8 || &data[..6] != b"H5PART" {
244            return Err(crate::Error::Parse("not a valid H5Part file".to_string()));
245        }
246        Ok(Self::default())
247    }
248
249    /// Return the number of time steps.
250    pub fn num_steps(&self) -> usize {
251        self.steps.len()
252    }
253
254    /// Return the dataset at a specific step index, if present.
255    pub fn step(&self, idx: usize) -> Option<&H5PartStep> {
256        self.steps.get(idx)
257    }
258
259    /// Return the time values for all steps.
260    pub fn times(&self) -> Vec<f64> {
261        self.steps.iter().map(|s| s.time).collect()
262    }
263
264    /// Return a list of dataset names available in the first step (if any).
265    pub fn dataset_names(&self) -> Vec<String> {
266        if let Some(step) = self.steps.first() {
267            let mut names = vec![
268                "x".to_string(),
269                "y".to_string(),
270                "z".to_string(),
271                "vx".to_string(),
272                "vy".to_string(),
273                "vz".to_string(),
274                "mass".to_string(),
275                "radius".to_string(),
276                "id".to_string(),
277            ];
278            names.extend(step.dataset.properties.keys().cloned());
279            names
280        } else {
281            vec![]
282        }
283    }
284
285    /// Add a simulated time step (used for testing / programmatic construction).
286    pub fn push_step(&mut self, step: H5PartStep) {
287        self.steps.push(step);
288    }
289
290    /// Iterate over all steps.
291    pub fn iter_steps(&self) -> impl Iterator<Item = &H5PartStep> {
292        self.steps.iter()
293    }
294
295    /// Find the step closest to a given simulation time.
296    pub fn step_near(&self, target_time: f64) -> Option<&H5PartStep> {
297        self.steps.iter().min_by(|a, b| {
298            (a.time - target_time)
299                .abs()
300                .partial_cmp(&(b.time - target_time).abs())
301                .unwrap_or(std::cmp::Ordering::Equal)
302        })
303    }
304}
305
306// ---------------------------------------------------------------------------
307// H5partWriter
308// ---------------------------------------------------------------------------
309
310/// Writer for the H5Part particle trajectory format.
311///
312/// Accumulates time steps in memory and serialises them to bytes on demand.
313#[derive(Debug, Default)]
314pub struct H5partWriter {
315    steps: Vec<H5PartStep>,
316    attributes: HashMap<String, String>,
317    scalar_fields: Vec<String>,
318    vector_fields: Vec<String>,
319}
320
321impl H5partWriter {
322    /// Create a new, empty writer.
323    pub fn new() -> Self {
324        Self::default()
325    }
326
327    /// Set a file-level metadata attribute.
328    pub fn set_attribute(&mut self, key: impl Into<String>, value: impl Into<String>) {
329        self.attributes.insert(key.into(), value.into());
330    }
331
332    /// Register a scalar field name to be written for each step.
333    pub fn register_scalar_field(&mut self, name: impl Into<String>) {
334        self.scalar_fields.push(name.into());
335    }
336
337    /// Register a vector field name (3-component) to be written for each step.
338    pub fn register_vector_field(&mut self, name: impl Into<String>) {
339        self.vector_fields.push(name.into());
340    }
341
342    /// Append a time step.
343    pub fn write_step(&mut self, dataset: ParticleDataset) {
344        let step_idx = self.steps.len() as u64;
345        let time = dataset.time;
346        self.steps.push(H5PartStep {
347            step: step_idx,
348            time,
349            dataset,
350        });
351    }
352
353    /// Return the number of steps written so far.
354    pub fn num_steps(&self) -> usize {
355        self.steps.len()
356    }
357
358    /// Serialise to a byte vector (H5Part magic header + step data).
359    pub fn to_bytes(&self) -> Vec<u8> {
360        let mut out = b"H5PART\0\0".to_vec();
361        // Write number of steps as 8-byte little-endian.
362        out.extend_from_slice(&(self.steps.len() as u64).to_le_bytes());
363        for step in &self.steps {
364            out.extend_from_slice(&step.step.to_le_bytes());
365            out.extend_from_slice(&step.time.to_le_bytes());
366            out.extend_from_slice(&(step.dataset.len() as u64).to_le_bytes());
367            for i in 0..step.dataset.len() {
368                out.extend_from_slice(&step.dataset.positions[i][0].to_le_bytes());
369                out.extend_from_slice(&step.dataset.positions[i][1].to_le_bytes());
370                out.extend_from_slice(&step.dataset.positions[i][2].to_le_bytes());
371                out.extend_from_slice(&step.dataset.velocities[i][0].to_le_bytes());
372                out.extend_from_slice(&step.dataset.velocities[i][1].to_le_bytes());
373                out.extend_from_slice(&step.dataset.velocities[i][2].to_le_bytes());
374                out.extend_from_slice(&step.dataset.masses[i].to_le_bytes());
375                out.extend_from_slice(&step.dataset.radii[i].to_le_bytes());
376            }
377        }
378        out
379    }
380
381    /// Write the H5Part file to a file path (string).
382    pub fn write_to_file(&self, path: &str) -> crate::Result<()> {
383        let bytes = self.to_bytes();
384        std::fs::write(path, bytes).map_err(crate::Error::from)?;
385        Ok(())
386    }
387
388    /// Convert into a reader (for round-trip testing).
389    pub fn into_reader(self) -> H5partReader {
390        H5partReader {
391            steps: self.steps,
392            attributes: self.attributes,
393        }
394    }
395}
396
397// ---------------------------------------------------------------------------
398// ParticleVtkWriter
399// ---------------------------------------------------------------------------
400
401/// Writes particle data as VTK PolyData (`.vtp`) for visualization in ParaView.
402///
403/// Each particle is represented as a point; glyph scaling by radius is encoded
404/// as a scalar array `"radius"` that ParaView's Glyph filter can use.
405#[derive(Debug, Default)]
406pub struct ParticleVtkWriter {
407    /// Whether to write binary VTK (faster) or ASCII (human-readable).
408    pub binary: bool,
409    /// Additional scalar attribute names to include.
410    pub scalar_attributes: Vec<String>,
411    /// Whether to include velocity vectors.
412    pub include_velocity: bool,
413    /// Whether to include particle IDs.
414    pub include_ids: bool,
415}
416
417impl ParticleVtkWriter {
418    /// Create a writer with ASCII output and no extra attributes.
419    pub fn new() -> Self {
420        Self {
421            binary: false,
422            scalar_attributes: vec![],
423            include_velocity: true,
424            include_ids: true,
425        }
426    }
427
428    /// Enable binary output.
429    pub fn with_binary(mut self) -> Self {
430        self.binary = true;
431        self
432    }
433
434    /// Add an extra scalar attribute to write.
435    pub fn with_scalar(mut self, name: impl Into<String>) -> Self {
436        self.scalar_attributes.push(name.into());
437        self
438    }
439
440    /// Generate VTK PolyData XML string for the given dataset.
441    pub fn write_to_string(&self, ds: &ParticleDataset) -> crate::Result<String> {
442        let mut s = String::new();
443        s.push_str("<?xml version=\"1.0\"?>\n");
444        s.push_str("<VTKFile type=\"PolyData\" version=\"0.1\" byte_order=\"LittleEndian\">\n");
445        s.push_str("  <PolyData>\n");
446        s.push_str(&format!(
447            "    <Piece NumberOfPoints=\"{}\" NumberOfVerts=\"{}\">\n",
448            ds.len(),
449            ds.len()
450        ));
451
452        // Points
453        s.push_str("      <Points>\n");
454        s.push_str(
455            "        <DataArray type=\"Float64\" NumberOfComponents=\"3\" format=\"ascii\">\n",
456        );
457        for p in &ds.positions {
458            s.push_str(&format!("          {} {} {}\n", p[0], p[1], p[2]));
459        }
460        s.push_str("        </DataArray>\n");
461        s.push_str("      </Points>\n");
462
463        // Verts connectivity
464        s.push_str("      <Verts>\n");
465        s.push_str("        <DataArray type=\"Int64\" Name=\"connectivity\" format=\"ascii\">\n");
466        s.push_str("         ");
467        for i in 0..ds.len() {
468            s.push_str(&format!(" {i}"));
469        }
470        s.push('\n');
471        s.push_str("        </DataArray>\n");
472        s.push_str("        <DataArray type=\"Int64\" Name=\"offsets\" format=\"ascii\">\n");
473        s.push_str("         ");
474        for i in 1..=ds.len() {
475            s.push_str(&format!(" {i}"));
476        }
477        s.push('\n');
478        s.push_str("        </DataArray>\n");
479        s.push_str("      </Verts>\n");
480
481        // Point data
482        s.push_str("      <PointData>\n");
483
484        // radius
485        s.push_str("        <DataArray type=\"Float64\" Name=\"radius\" format=\"ascii\">\n");
486        s.push_str("         ");
487        for r in &ds.radii {
488            s.push_str(&format!(" {r}"));
489        }
490        s.push('\n');
491        s.push_str("        </DataArray>\n");
492
493        // mass
494        s.push_str("        <DataArray type=\"Float64\" Name=\"mass\" format=\"ascii\">\n");
495        s.push_str("         ");
496        for m in &ds.masses {
497            s.push_str(&format!(" {m}"));
498        }
499        s.push('\n');
500        s.push_str("        </DataArray>\n");
501
502        if self.include_ids {
503            s.push_str("        <DataArray type=\"Int64\" Name=\"id\" format=\"ascii\">\n");
504            s.push_str("         ");
505            for id in &ds.ids {
506                s.push_str(&format!(" {id}"));
507            }
508            s.push('\n');
509            s.push_str("        </DataArray>\n");
510        }
511
512        if self.include_velocity {
513            s.push_str(
514                "        <DataArray type=\"Float64\" Name=\"velocity\" NumberOfComponents=\"3\" format=\"ascii\">\n",
515            );
516            for v in &ds.velocities {
517                s.push_str(&format!("          {} {} {}\n", v[0], v[1], v[2]));
518            }
519            s.push_str("        </DataArray>\n");
520        }
521
522        // Extra scalar attributes
523        for attr in &self.scalar_attributes {
524            if let Some(vals) = ds.properties.get(attr) {
525                s.push_str(&format!(
526                    "        <DataArray type=\"Float64\" Name=\"{attr}\" format=\"ascii\">\n"
527                ));
528                s.push_str("         ");
529                for v in vals {
530                    s.push_str(&format!(" {v}"));
531                }
532                s.push('\n');
533                s.push_str("        </DataArray>\n");
534            }
535        }
536
537        s.push_str("      </PointData>\n");
538        s.push_str("    </Piece>\n");
539        s.push_str("  </PolyData>\n");
540        s.push_str("</VTKFile>\n");
541        Ok(s)
542    }
543
544    /// Write to a file at `path`.
545    pub fn write_to_file(&self, ds: &ParticleDataset, path: &str) -> crate::Result<()> {
546        let content = self.write_to_string(ds)?;
547        std::fs::write(path, content).map_err(crate::Error::from)?;
548        Ok(())
549    }
550}
551
552// ---------------------------------------------------------------------------
553// ParticleJsonWriter
554// ---------------------------------------------------------------------------
555
556/// Writes particle data as compact JSON with optional bounding-box metadata.
557///
558/// Output schema:
559/// ```json
560/// {
561///   "time": 0.0,
562///   "step": 0,
563///   "bbox": { "min": [x,y,z], "max": [x,y,z] },
564///   "particles": [
565///     { "id": 0, "pos": [x,y,z], "vel": [vx,vy,vz], "mass": 1.0, "radius": 0.1 },
566///     ...
567///   ]
568/// }
569/// ```
570#[derive(Debug, Default)]
571pub struct ParticleJsonWriter {
572    /// Whether to include bounding-box metadata in the output.
573    pub include_bbox: bool,
574    /// Whether to include the velocity array.
575    pub include_velocity: bool,
576    /// Whether to include named properties.
577    pub include_properties: bool,
578    /// Decimal precision for floating-point numbers.
579    pub precision: usize,
580}
581
582impl ParticleJsonWriter {
583    /// Create a default writer (bbox on, velocity on, 6 decimal places).
584    pub fn new() -> Self {
585        Self {
586            include_bbox: true,
587            include_velocity: true,
588            include_properties: true,
589            precision: 6,
590        }
591    }
592
593    /// Write the dataset to a JSON string.
594    pub fn write_to_string(ds: &ParticleDataset, include_bbox: bool) -> crate::Result<String> {
595        let writer = ParticleJsonWriter {
596            include_bbox,
597            include_velocity: true,
598            include_properties: true,
599            precision: 6,
600        };
601        writer.format(ds)
602    }
603
604    /// Format the dataset according to this writer's settings.
605    pub fn format(&self, ds: &ParticleDataset) -> crate::Result<String> {
606        let prec = self.precision;
607        let mut s = String::new();
608        s.push_str("{\n");
609        s.push_str(&format!("  \"time\": {:.prec$},\n", ds.time));
610        s.push_str(&format!("  \"step\": {},\n", ds.step));
611
612        if self.include_bbox {
613            match ds.bounding_box() {
614                Some((mn, mx)) => {
615                    s.push_str(&format!(
616                        "  \"bbox\": {{ \"min\": [{:.prec$},{:.prec$},{:.prec$}], \"max\": [{:.prec$},{:.prec$},{:.prec$}] }},\n",
617                        mn[0], mn[1], mn[2], mx[0], mx[1], mx[2]
618                    ));
619                }
620                None => {
621                    s.push_str("  \"bbox\": null,\n");
622                }
623            }
624        }
625
626        s.push_str("  \"particles\": [\n");
627        for i in 0..ds.len() {
628            let p = ds.positions[i];
629            let v = ds.velocities[i];
630            s.push_str("    {");
631            s.push_str(&format!("\"id\": {}", ds.ids[i]));
632            s.push_str(&format!(
633                ", \"pos\": [{:.prec$},{:.prec$},{:.prec$}]",
634                p[0], p[1], p[2]
635            ));
636            if self.include_velocity {
637                s.push_str(&format!(
638                    ", \"vel\": [{:.prec$},{:.prec$},{:.prec$}]",
639                    v[0], v[1], v[2]
640                ));
641            }
642            s.push_str(&format!(
643                ", \"mass\": {:.prec$}, \"radius\": {:.prec$}",
644                ds.masses[i], ds.radii[i]
645            ));
646
647            if self.include_properties && !ds.properties.is_empty() {
648                s.push_str(", \"props\": {");
649                let mut first = true;
650                let mut keys: Vec<&String> = ds.properties.keys().collect();
651                keys.sort();
652                for key in keys {
653                    if let Some(vals) = ds.properties.get(key)
654                        && let Some(val) = vals.get(i)
655                    {
656                        if !first {
657                            s.push_str(", ");
658                        }
659                        s.push_str(&format!("\"{key}\": {val:.prec$}"));
660                        first = false;
661                    }
662                }
663                s.push('}');
664            }
665
666            s.push('}');
667            if i + 1 < ds.len() {
668                s.push(',');
669            }
670            s.push('\n');
671        }
672        s.push_str("  ]\n");
673        s.push_str("}\n");
674        Ok(s)
675    }
676
677    /// Write to a file.
678    pub fn write_to_file(&self, ds: &ParticleDataset, path: &str) -> crate::Result<()> {
679        let content = self.format(ds)?;
680        std::fs::write(path, content).map_err(crate::Error::from)?;
681        Ok(())
682    }
683}
684
685// ---------------------------------------------------------------------------
686// ParticleDiffWriter — delta encoding
687// ---------------------------------------------------------------------------
688
689/// A record describing changes between two consecutive frames.
690#[derive(Debug, Clone)]
691pub struct ParticleDelta {
692    /// Particle ID.
693    pub id: u64,
694    /// Position delta `[dx, dy, dz]`.
695    pub delta_pos: [f64; 3],
696    /// Velocity delta `[dvx, dvy, dvz]`.
697    pub delta_vel: [f64; 3],
698    /// Whether this particle is newly added in this frame.
699    pub is_new: bool,
700    /// Whether this particle was removed in this frame.
701    pub is_removed: bool,
702}
703
704/// Writes only the changes between consecutive particle frames (delta encoding).
705///
706/// This format is useful for large simulations where only a small fraction of
707/// particles move significantly each frame, enabling compact storage.
708#[derive(Debug, Default)]
709pub struct ParticleDiffWriter {
710    /// Previous frame snapshot (for diffing).
711    previous: Option<ParticleDataset>,
712    /// Threshold below which position changes are considered zero.
713    pub pos_threshold: f64,
714    /// Threshold below which velocity changes are considered zero.
715    pub vel_threshold: f64,
716    /// Accumulated delta frames.
717    pub deltas: Vec<(u64, Vec<ParticleDelta>)>,
718}
719
720impl ParticleDiffWriter {
721    /// Create a diff writer with default thresholds (1e-9 for position, 1e-9 for velocity).
722    pub fn new() -> Self {
723        Self {
724            previous: None,
725            pos_threshold: 1e-9,
726            vel_threshold: 1e-9,
727            deltas: vec![],
728        }
729    }
730
731    /// Create with custom thresholds.
732    pub fn with_thresholds(pos_threshold: f64, vel_threshold: f64) -> Self {
733        Self {
734            previous: None,
735            pos_threshold,
736            vel_threshold,
737            deltas: vec![],
738        }
739    }
740
741    /// Process a new frame, computing and storing the delta vs the previous frame.
742    ///
743    /// Returns the number of changed particles in this frame.
744    pub fn write_frame(&mut self, current: &ParticleDataset) -> usize {
745        let step = current.step;
746        let deltas = match &self.previous {
747            None => {
748                // First frame: all particles are "new".
749                current
750                    .ids
751                    .iter()
752                    .enumerate()
753                    .map(|(i, &id)| ParticleDelta {
754                        id,
755                        delta_pos: current.positions[i],
756                        delta_vel: current.velocities[i],
757                        is_new: true,
758                        is_removed: false,
759                    })
760                    .collect::<Vec<_>>()
761            }
762            Some(prev) => {
763                let prev_map: HashMap<u64, usize> = prev
764                    .ids
765                    .iter()
766                    .enumerate()
767                    .map(|(i, &id)| (id, i))
768                    .collect();
769                let curr_map: HashMap<u64, usize> = current
770                    .ids
771                    .iter()
772                    .enumerate()
773                    .map(|(i, &id)| (id, i))
774                    .collect();
775
776                let mut out = vec![];
777
778                // Changed or new particles.
779                for (i, &id) in current.ids.iter().enumerate() {
780                    if let Some(&pi) = prev_map.get(&id) {
781                        let dp = [
782                            current.positions[i][0] - prev.positions[pi][0],
783                            current.positions[i][1] - prev.positions[pi][1],
784                            current.positions[i][2] - prev.positions[pi][2],
785                        ];
786                        let dv = [
787                            current.velocities[i][0] - prev.velocities[pi][0],
788                            current.velocities[i][1] - prev.velocities[pi][1],
789                            current.velocities[i][2] - prev.velocities[pi][2],
790                        ];
791                        let pos_changed = dp.iter().any(|x| x.abs() > self.pos_threshold);
792                        let vel_changed = dv.iter().any(|x| x.abs() > self.vel_threshold);
793                        if pos_changed || vel_changed {
794                            out.push(ParticleDelta {
795                                id,
796                                delta_pos: dp,
797                                delta_vel: dv,
798                                is_new: false,
799                                is_removed: false,
800                            });
801                        }
802                    } else {
803                        out.push(ParticleDelta {
804                            id,
805                            delta_pos: current.positions[i],
806                            delta_vel: current.velocities[i],
807                            is_new: true,
808                            is_removed: false,
809                        });
810                    }
811                }
812
813                // Removed particles.
814                for &id in prev.ids.iter() {
815                    if !curr_map.contains_key(&id) {
816                        out.push(ParticleDelta {
817                            id,
818                            delta_pos: [0.0; 3],
819                            delta_vel: [0.0; 3],
820                            is_new: false,
821                            is_removed: true,
822                        });
823                    }
824                }
825
826                out
827            }
828        };
829
830        let n = deltas.len();
831        self.deltas.push((step, deltas));
832        self.previous = Some(current.clone());
833        n
834    }
835
836    /// Total number of delta records across all frames.
837    pub fn total_deltas(&self) -> usize {
838        self.deltas.iter().map(|(_, d)| d.len()).sum()
839    }
840
841    /// Serialise all delta frames to a compact binary format.
842    pub fn to_bytes(&self) -> Vec<u8> {
843        let mut out = b"PDIFF\0\0\0".to_vec();
844        out.extend_from_slice(&(self.deltas.len() as u64).to_le_bytes());
845        for (step, deltas) in &self.deltas {
846            out.extend_from_slice(&step.to_le_bytes());
847            out.extend_from_slice(&(deltas.len() as u64).to_le_bytes());
848            for d in deltas {
849                out.extend_from_slice(&d.id.to_le_bytes());
850                let flags: u8 = (d.is_new as u8) | ((d.is_removed as u8) << 1);
851                out.push(flags);
852                for x in &d.delta_pos {
853                    out.extend_from_slice(&x.to_le_bytes());
854                }
855                for x in &d.delta_vel {
856                    out.extend_from_slice(&x.to_le_bytes());
857                }
858            }
859        }
860        out
861    }
862
863    /// Reconstruct a full dataset from delta frames up to `step`.
864    pub fn reconstruct(&self, through_step: u64) -> ParticleDataset {
865        let mut state: HashMap<u64, (usize, [f64; 3], [f64; 3])> = HashMap::new();
866        // state: id -> (index_placeholder, position, velocity)
867        let mut pos_map: HashMap<u64, [f64; 3]> = HashMap::new();
868        let mut vel_map: HashMap<u64, [f64; 3]> = HashMap::new();
869
870        for (step, deltas) in &self.deltas {
871            if *step > through_step {
872                break;
873            }
874            for d in deltas {
875                if d.is_removed {
876                    pos_map.remove(&d.id);
877                    vel_map.remove(&d.id);
878                    state.remove(&d.id);
879                } else if d.is_new {
880                    pos_map.insert(d.id, d.delta_pos);
881                    vel_map.insert(d.id, d.delta_vel);
882                } else {
883                    let p = pos_map.entry(d.id).or_insert([0.0; 3]);
884                    let v = vel_map.entry(d.id).or_insert([0.0; 3]);
885                    for k in 0..3 {
886                        p[k] += d.delta_pos[k];
887                        v[k] += d.delta_vel[k];
888                    }
889                }
890            }
891        }
892
893        let mut ds = ParticleDataset::with_time(0.0, through_step);
894        let mut ids: Vec<u64> = pos_map.keys().cloned().collect();
895        ids.sort();
896        for id in ids {
897            let pos = pos_map[&id];
898            let vel = vel_map[&id];
899            ds.add_particle(id, pos, vel, 1.0, 0.1);
900        }
901        let _ = state; // suppress warning
902        ds
903    }
904}
905
906// ---------------------------------------------------------------------------
907// ParticleTrajectory — multi-frame container
908// ---------------------------------------------------------------------------
909
910/// A multi-frame particle trajectory storing all time steps in memory.
911#[derive(Debug, Default)]
912pub struct ParticleTrajectory {
913    /// All snapshots in time order.
914    pub frames: Vec<ParticleDataset>,
915    /// Simulation title / description.
916    pub title: String,
917}
918
919impl ParticleTrajectory {
920    /// Create an empty trajectory.
921    pub fn new(title: impl Into<String>) -> Self {
922        Self {
923            frames: vec![],
924            title: title.into(),
925        }
926    }
927
928    /// Append a frame.
929    pub fn push(&mut self, ds: ParticleDataset) {
930        self.frames.push(ds);
931    }
932
933    /// Number of frames.
934    pub fn num_frames(&self) -> usize {
935        self.frames.len()
936    }
937
938    /// Time span `(t_start, t_end)`, or `None` if empty.
939    pub fn time_span(&self) -> Option<(f64, f64)> {
940        if self.frames.is_empty() {
941            return None;
942        }
943        let t0 = self
944            .frames
945            .first()
946            .expect("collection should not be empty")
947            .time;
948        let t1 = self
949            .frames
950            .last()
951            .expect("collection should not be empty")
952            .time;
953        Some((t0, t1))
954    }
955
956    /// Return the frame closest to a given time.
957    pub fn frame_at(&self, t: f64) -> Option<&ParticleDataset> {
958        self.frames.iter().min_by(|a, b| {
959            (a.time - t)
960                .abs()
961                .partial_cmp(&(b.time - t).abs())
962                .unwrap_or(std::cmp::Ordering::Equal)
963        })
964    }
965
966    /// Compute kinetic energy over time as `(time, energy)` pairs.
967    pub fn kinetic_energy_series(&self) -> Vec<(f64, f64)> {
968        self.frames
969            .iter()
970            .map(|f| (f.time, f.kinetic_energy()))
971            .collect()
972    }
973}
974
975// ---------------------------------------------------------------------------
976// ParticleStats — aggregate statistics over a dataset
977// ---------------------------------------------------------------------------
978
979/// Aggregate statistics computed over a particle dataset.
980#[derive(Debug, Clone, Default)]
981pub struct ParticleStats {
982    /// Number of particles.
983    pub count: usize,
984    /// Mean position.
985    pub mean_pos: [f64; 3],
986    /// Mean speed.
987    pub mean_speed: f64,
988    /// Maximum speed.
989    pub max_speed: f64,
990    /// Total mass.
991    pub total_mass: f64,
992    /// Total kinetic energy.
993    pub kinetic_energy: f64,
994    /// Bounding box min corner.
995    pub bbox_min: [f64; 3],
996    /// Bounding box max corner.
997    pub bbox_max: [f64; 3],
998}
999
1000impl ParticleStats {
1001    /// Compute statistics for the given dataset.
1002    pub fn compute(ds: &ParticleDataset) -> Self {
1003        let count = ds.len();
1004        if count == 0 {
1005            return Self::default();
1006        }
1007        let mean_pos = ds.centroid();
1008        let speeds: Vec<f64> = ds
1009            .velocities
1010            .iter()
1011            .map(|v| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt())
1012            .collect();
1013        let mean_speed = speeds.iter().sum::<f64>() / count as f64;
1014        let max_speed = speeds.iter().cloned().fold(0.0_f64, f64::max);
1015        let total_mass = ds.masses.iter().sum();
1016        let kinetic_energy = ds.kinetic_energy();
1017        let (bbox_min, bbox_max) = ds.bounding_box().unwrap_or(([0.0; 3], [0.0; 3]));
1018        Self {
1019            count,
1020            mean_pos,
1021            mean_speed,
1022            max_speed,
1023            total_mass,
1024            kinetic_energy,
1025            bbox_min,
1026            bbox_max,
1027        }
1028    }
1029}
1030
1031// ---------------------------------------------------------------------------
1032// Utility functions
1033// ---------------------------------------------------------------------------
1034
1035/// Generate a test particle dataset with `n` particles on a simple lattice.
1036pub fn make_test_dataset(n: usize, time: f64) -> ParticleDataset {
1037    let mut ds = ParticleDataset::with_time(time, 0);
1038    let side = (n as f64).cbrt().ceil() as usize;
1039    let mut idx = 0u64;
1040    'outer: for ix in 0..side {
1041        for iy in 0..side {
1042            for iz in 0..side {
1043                if idx as usize >= n {
1044                    break 'outer;
1045                }
1046                ds.add_particle(
1047                    idx,
1048                    [ix as f64, iy as f64, iz as f64],
1049                    [0.1 * ix as f64, 0.0, 0.0],
1050                    1.0,
1051                    0.4,
1052                );
1053                idx += 1;
1054            }
1055        }
1056    }
1057    ds
1058}
1059
1060/// Interpolate particle positions between two frames at parameter `t ∈ [0, 1]`.
1061///
1062/// Particles are matched by ID. Only particles present in both frames are
1063/// included in the output.
1064pub fn interpolate_frames(a: &ParticleDataset, b: &ParticleDataset, t: f64) -> ParticleDataset {
1065    let a_map: HashMap<u64, usize> = a.ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
1066    let b_map: HashMap<u64, usize> = b.ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
1067    let time = a.time + t * (b.time - a.time);
1068    let step = a.step;
1069    let mut out = ParticleDataset::with_time(time, step);
1070    for (&id, &ai) in &a_map {
1071        if let Some(&bi) = b_map.get(&id) {
1072            let pa = a.positions[ai];
1073            let pb = b.positions[bi];
1074            let va = a.velocities[ai];
1075            let vb = b.velocities[bi];
1076            let pos = [
1077                pa[0] + t * (pb[0] - pa[0]),
1078                pa[1] + t * (pb[1] - pa[1]),
1079                pa[2] + t * (pb[2] - pa[2]),
1080            ];
1081            let vel = [
1082                va[0] + t * (vb[0] - va[0]),
1083                va[1] + t * (vb[1] - va[1]),
1084                va[2] + t * (vb[2] - va[2]),
1085            ];
1086            let mass = a.masses[ai];
1087            let radius = a.radii[ai];
1088            out.add_particle(id, pos, vel, mass, radius);
1089        }
1090    }
1091    out
1092}
1093
1094// ---------------------------------------------------------------------------
1095// Tests
1096// ---------------------------------------------------------------------------
1097
1098#[cfg(test)]
1099mod tests {
1100    use super::*;
1101
1102    fn make_ds(n: usize) -> ParticleDataset {
1103        make_test_dataset(n, 0.0)
1104    }
1105
1106    // ---- ParticleDataset ----
1107
1108    #[test]
1109    fn test_dataset_empty() {
1110        let ds = ParticleDataset::new();
1111        assert!(ds.is_empty());
1112        assert_eq!(ds.len(), 0);
1113    }
1114
1115    #[test]
1116    fn test_dataset_add_particle() {
1117        let mut ds = ParticleDataset::new();
1118        let idx = ds.add_particle(0, [1.0, 2.0, 3.0], [0.1, 0.2, 0.3], 1.5, 0.25);
1119        assert_eq!(idx, 0);
1120        assert_eq!(ds.len(), 1);
1121        assert_eq!(ds.position(0), [1.0, 2.0, 3.0]);
1122        assert_eq!(ds.velocity(0), [0.1, 0.2, 0.3]);
1123    }
1124
1125    #[test]
1126    fn test_dataset_bounding_box_single() {
1127        let mut ds = ParticleDataset::new();
1128        ds.add_particle(0, [3.0, -1.0, 5.0], [0.0; 3], 1.0, 0.1);
1129        let (mn, mx) = ds.bounding_box().unwrap();
1130        assert_eq!(mn, [3.0, -1.0, 5.0]);
1131        assert_eq!(mx, [3.0, -1.0, 5.0]);
1132    }
1133
1134    #[test]
1135    fn test_dataset_bounding_box_empty() {
1136        let ds = ParticleDataset::new();
1137        assert!(ds.bounding_box().is_none());
1138    }
1139
1140    #[test]
1141    fn test_dataset_bounding_box_multiple() {
1142        let mut ds = ParticleDataset::new();
1143        ds.add_particle(0, [0.0, 0.0, 0.0], [0.0; 3], 1.0, 0.1);
1144        ds.add_particle(1, [1.0, 2.0, 3.0], [0.0; 3], 1.0, 0.1);
1145        ds.add_particle(2, [-1.0, 5.0, -2.0], [0.0; 3], 1.0, 0.1);
1146        let (mn, mx) = ds.bounding_box().unwrap();
1147        assert_eq!(mn, [-1.0, 0.0, -2.0]);
1148        assert_eq!(mx, [1.0, 5.0, 3.0]);
1149    }
1150
1151    #[test]
1152    fn test_dataset_centroid() {
1153        let mut ds = ParticleDataset::new();
1154        ds.add_particle(0, [0.0, 0.0, 0.0], [0.0; 3], 1.0, 0.1);
1155        ds.add_particle(1, [2.0, 0.0, 0.0], [0.0; 3], 1.0, 0.1);
1156        let c = ds.centroid();
1157        assert!((c[0] - 1.0).abs() < 1e-10);
1158    }
1159
1160    #[test]
1161    fn test_dataset_kinetic_energy() {
1162        let mut ds = ParticleDataset::new();
1163        // mass=2, vel=(1,0,0) → KE = 0.5*2*1 = 1
1164        ds.add_particle(0, [0.0; 3], [1.0, 0.0, 0.0], 2.0, 0.1);
1165        assert!((ds.kinetic_energy() - 1.0).abs() < 1e-10);
1166    }
1167
1168    #[test]
1169    fn test_dataset_max_speed() {
1170        let mut ds = ParticleDataset::new();
1171        ds.add_particle(0, [0.0; 3], [1.0, 0.0, 0.0], 1.0, 0.1);
1172        ds.add_particle(1, [0.0; 3], [0.0, 3.0, 4.0], 1.0, 0.1);
1173        assert!((ds.max_speed() - 5.0).abs() < 1e-10);
1174    }
1175
1176    #[test]
1177    fn test_dataset_merge() {
1178        let mut a = ParticleDataset::new();
1179        a.add_particle(0, [0.0; 3], [0.0; 3], 1.0, 0.1);
1180        let mut b = ParticleDataset::new();
1181        b.add_particle(1, [1.0; 3], [0.0; 3], 1.0, 0.1);
1182        b.add_particle(2, [2.0; 3], [0.0; 3], 1.0, 0.1);
1183        a.merge(&b);
1184        assert_eq!(a.len(), 3);
1185    }
1186
1187    #[test]
1188    fn test_dataset_filter() {
1189        let ds = make_ds(8);
1190        let filtered = ds.filter(|i| i % 2 == 0);
1191        assert_eq!(filtered.len(), 4);
1192    }
1193
1194    #[test]
1195    fn test_dataset_with_time() {
1196        let ds = ParticleDataset::with_time(3.125, 42);
1197        assert!((ds.time - 3.125).abs() < 1e-10);
1198        assert_eq!(ds.step, 42);
1199    }
1200
1201    #[test]
1202    fn test_dataset_add_property() {
1203        let mut ds = make_ds(3);
1204        ds.add_property("temperature", vec![300.0, 310.0, 320.0]);
1205        assert!(ds.properties.contains_key("temperature"));
1206        assert_eq!(ds.properties["temperature"].len(), 3);
1207    }
1208
1209    // ---- H5partReader ----
1210
1211    #[test]
1212    fn test_h5part_reader_empty() {
1213        let r = H5partReader::new();
1214        assert_eq!(r.num_steps(), 0);
1215        assert!(r.step(0).is_none());
1216    }
1217
1218    #[test]
1219    fn test_h5part_reader_invalid_bytes() {
1220        let result = H5partReader::from_bytes(b"NOTVALID");
1221        assert!(result.is_err());
1222    }
1223
1224    #[test]
1225    fn test_h5part_reader_valid_magic() {
1226        let mut data = b"H5PART\0\0".to_vec();
1227        data.extend_from_slice(&0u64.to_le_bytes());
1228        let r = H5partReader::from_bytes(&data).unwrap();
1229        assert_eq!(r.num_steps(), 0);
1230    }
1231
1232    #[test]
1233    fn test_h5part_reader_push_and_get() {
1234        let mut r = H5partReader::new();
1235        let ds = make_ds(5);
1236        r.push_step(H5PartStep {
1237            step: 0,
1238            time: 0.0,
1239            dataset: ds,
1240        });
1241        assert_eq!(r.num_steps(), 1);
1242        assert!(r.step(0).is_some());
1243    }
1244
1245    #[test]
1246    fn test_h5part_reader_times() {
1247        let mut r = H5partReader::new();
1248        for i in 0..3 {
1249            let ds = ParticleDataset::with_time(i as f64 * 0.1, i);
1250            r.push_step(H5PartStep {
1251                step: i,
1252                time: i as f64 * 0.1,
1253                dataset: ds,
1254            });
1255        }
1256        let times = r.times();
1257        assert_eq!(times.len(), 3);
1258        assert!((times[1] - 0.1).abs() < 1e-10);
1259    }
1260
1261    #[test]
1262    fn test_h5part_reader_step_near() {
1263        let mut r = H5partReader::new();
1264        for i in 0..5u64 {
1265            let ds = ParticleDataset::with_time(i as f64, i);
1266            r.push_step(H5PartStep {
1267                step: i,
1268                time: i as f64,
1269                dataset: ds,
1270            });
1271        }
1272        let near = r.step_near(2.4).unwrap();
1273        assert_eq!(near.step, 2);
1274    }
1275
1276    #[test]
1277    fn test_h5part_reader_dataset_names() {
1278        let mut r = H5partReader::new();
1279        let ds = make_ds(2);
1280        r.push_step(H5PartStep {
1281            step: 0,
1282            time: 0.0,
1283            dataset: ds,
1284        });
1285        let names = r.dataset_names();
1286        assert!(names.contains(&"x".to_string()));
1287        assert!(names.contains(&"mass".to_string()));
1288    }
1289
1290    #[test]
1291    fn test_h5part_reader_iter_steps() {
1292        let mut r = H5partReader::new();
1293        for i in 0..4u64 {
1294            let ds = ParticleDataset::with_time(i as f64, i);
1295            r.push_step(H5PartStep {
1296                step: i,
1297                time: i as f64,
1298                dataset: ds,
1299            });
1300        }
1301        assert_eq!(r.iter_steps().count(), 4);
1302    }
1303
1304    // ---- H5partWriter ----
1305
1306    #[test]
1307    fn test_h5part_writer_empty() {
1308        let w = H5partWriter::new();
1309        assert_eq!(w.num_steps(), 0);
1310    }
1311
1312    #[test]
1313    fn test_h5part_writer_write_step() {
1314        let mut w = H5partWriter::new();
1315        let ds = make_ds(10);
1316        w.write_step(ds);
1317        assert_eq!(w.num_steps(), 1);
1318    }
1319
1320    #[test]
1321    fn test_h5part_writer_to_bytes_magic() {
1322        let mut w = H5partWriter::new();
1323        let ds = make_ds(3);
1324        w.write_step(ds);
1325        let bytes = w.to_bytes();
1326        assert_eq!(&bytes[..6], b"H5PART");
1327    }
1328
1329    #[test]
1330    fn test_h5part_writer_round_trip() {
1331        let mut w = H5partWriter::new();
1332        let ds = make_ds(5);
1333        w.write_step(ds.clone());
1334        let r = w.into_reader();
1335        assert_eq!(r.num_steps(), 1);
1336        assert_eq!(r.step(0).unwrap().dataset.len(), ds.len());
1337    }
1338
1339    #[test]
1340    fn test_h5part_writer_multiple_steps() {
1341        let mut w = H5partWriter::new();
1342        for i in 0..5u64 {
1343            let ds = ParticleDataset::with_time(i as f64 * 0.5, i);
1344            w.write_step(ds);
1345        }
1346        assert_eq!(w.num_steps(), 5);
1347    }
1348
1349    #[test]
1350    fn test_h5part_writer_attributes() {
1351        let mut w = H5partWriter::new();
1352        w.set_attribute("author", "OxiPhysics");
1353        let bytes = w.to_bytes();
1354        assert!(!bytes.is_empty());
1355    }
1356
1357    // ---- ParticleVtkWriter ----
1358
1359    #[test]
1360    fn test_vtk_writer_empty_dataset() {
1361        let w = ParticleVtkWriter::new();
1362        let ds = ParticleDataset::new();
1363        let s = w.write_to_string(&ds).unwrap();
1364        assert!(s.contains("VTKFile"));
1365        assert!(s.contains("NumberOfPoints=\"0\""));
1366    }
1367
1368    #[test]
1369    fn test_vtk_writer_basic() {
1370        let w = ParticleVtkWriter::new();
1371        let ds = make_ds(3);
1372        let s = w.write_to_string(&ds).unwrap();
1373        assert!(s.contains("NumberOfPoints=\"3\""));
1374        let _ = ds.len();
1375    }
1376
1377    #[test]
1378    fn test_vtk_writer_contains_radius() {
1379        let w = ParticleVtkWriter::new();
1380        let ds = make_ds(2);
1381        let s = w.write_to_string(&ds).unwrap();
1382        assert!(s.contains("Name=\"radius\""));
1383    }
1384
1385    #[test]
1386    fn test_vtk_writer_contains_velocity() {
1387        let w = ParticleVtkWriter::new();
1388        let ds = make_ds(2);
1389        let s = w.write_to_string(&ds).unwrap();
1390        assert!(s.contains("Name=\"velocity\""));
1391    }
1392
1393    #[test]
1394    fn test_vtk_writer_scalar_attribute() {
1395        let mut w = ParticleVtkWriter::new();
1396        w = w.with_scalar("temperature");
1397        let mut ds = make_ds(3);
1398        ds.add_property("temperature", vec![300.0, 310.0, 320.0]);
1399        let s = w.write_to_string(&ds).unwrap();
1400        assert!(s.contains("Name=\"temperature\""));
1401    }
1402
1403    #[test]
1404    fn test_vtk_writer_no_velocity() {
1405        let mut w = ParticleVtkWriter::new();
1406        w.include_velocity = false;
1407        let ds = make_ds(2);
1408        let s = w.write_to_string(&ds).unwrap();
1409        assert!(!s.contains("Name=\"velocity\""));
1410    }
1411
1412    #[test]
1413    fn test_vtk_writer_no_ids() {
1414        let mut w = ParticleVtkWriter::new();
1415        w.include_ids = false;
1416        let ds = make_ds(2);
1417        let s = w.write_to_string(&ds).unwrap();
1418        assert!(!s.contains("Name=\"id\""));
1419    }
1420
1421    // ---- ParticleJsonWriter ----
1422
1423    #[test]
1424    fn test_json_writer_basic() {
1425        let ds = make_ds(2);
1426        let s = ParticleJsonWriter::write_to_string(&ds, true).unwrap();
1427        assert!(s.contains("\"time\""));
1428        assert!(s.contains("\"particles\""));
1429    }
1430
1431    #[test]
1432    fn test_json_writer_bbox() {
1433        let ds = make_ds(4);
1434        let s = ParticleJsonWriter::write_to_string(&ds, true).unwrap();
1435        assert!(s.contains("\"bbox\""));
1436        assert!(s.contains("\"min\""));
1437        assert!(s.contains("\"max\""));
1438    }
1439
1440    #[test]
1441    fn test_json_writer_no_bbox() {
1442        let ds = make_ds(2);
1443        let s = ParticleJsonWriter::write_to_string(&ds, false).unwrap();
1444        assert!(!s.contains("\"bbox\""));
1445    }
1446
1447    #[test]
1448    fn test_json_writer_empty_dataset() {
1449        let ds = ParticleDataset::new();
1450        let s = ParticleJsonWriter::write_to_string(&ds, true).unwrap();
1451        assert!(s.contains("\"particles\": []") || s.contains("\"particles\":"));
1452    }
1453
1454    #[test]
1455    fn test_json_writer_particle_count() {
1456        let ds = make_ds(5);
1457        let s = ParticleJsonWriter::write_to_string(&ds, false).unwrap();
1458        // Each particle has an "id" field
1459        let count = s.matches("\"id\"").count();
1460        assert_eq!(count, 5);
1461    }
1462
1463    #[test]
1464    fn test_json_writer_with_properties() {
1465        let mut ds = make_ds(3);
1466        ds.add_property("temperature", vec![300.0, 310.0, 320.0]);
1467        let w = ParticleJsonWriter::new();
1468        let s = w.format(&ds).unwrap();
1469        assert!(s.contains("\"temperature\""));
1470    }
1471
1472    #[test]
1473    fn test_json_writer_custom_precision() {
1474        let w = ParticleJsonWriter {
1475            include_bbox: false,
1476            include_velocity: false,
1477            include_properties: false,
1478            precision: 2,
1479        };
1480        let mut ds = ParticleDataset::new();
1481        ds.add_particle(0, [1.23456789, 0.0, 0.0], [0.0; 3], 1.0, 0.1);
1482        let s = w.format(&ds).unwrap();
1483        assert!(s.contains("1.23"));
1484        assert!(!s.contains("1.234567"));
1485    }
1486
1487    // ---- ParticleDiffWriter ----
1488
1489    #[test]
1490    fn test_diff_writer_first_frame_all_new() {
1491        let mut w = ParticleDiffWriter::new();
1492        let ds = make_ds(4);
1493        let n = w.write_frame(&ds);
1494        assert_eq!(n, 4);
1495        assert!(w.deltas[0].1.iter().all(|d| d.is_new));
1496    }
1497
1498    #[test]
1499    fn test_diff_writer_unchanged_frame() {
1500        let mut w = ParticleDiffWriter::new();
1501        let ds = make_ds(3);
1502        w.write_frame(&ds);
1503        let n = w.write_frame(&ds);
1504        assert_eq!(n, 0, "no changes → 0 deltas");
1505    }
1506
1507    #[test]
1508    fn test_diff_writer_moved_particle() {
1509        let mut w = ParticleDiffWriter::new();
1510        let ds1 = make_ds(2);
1511        let mut ds2 = ds1.clone();
1512        ds2.positions[0][0] += 1.0;
1513        w.write_frame(&ds1);
1514        let n = w.write_frame(&ds2);
1515        assert_eq!(n, 1);
1516        let delta = &w.deltas[1].1[0];
1517        assert!((delta.delta_pos[0] - 1.0).abs() < 1e-10);
1518    }
1519
1520    #[test]
1521    fn test_diff_writer_removed_particle() {
1522        let mut w = ParticleDiffWriter::new();
1523        let ds1 = make_ds(3);
1524        let mut ds2 = ds1.clone();
1525        // Remove particle at index 1 by rebuilding without it.
1526        let id1 = ds2.ids[1];
1527        ds2.ids.remove(1);
1528        ds2.positions.remove(1);
1529        ds2.velocities.remove(1);
1530        ds2.masses.remove(1);
1531        ds2.radii.remove(1);
1532        w.write_frame(&ds1);
1533        let n = w.write_frame(&ds2);
1534        assert_eq!(n, 1);
1535        let removed = w.deltas[1].1.iter().find(|d| d.id == id1).unwrap();
1536        assert!(removed.is_removed);
1537    }
1538
1539    #[test]
1540    fn test_diff_writer_added_particle() {
1541        let mut w = ParticleDiffWriter::new();
1542        let ds1 = make_ds(2);
1543        let mut ds2 = ds1.clone();
1544        ds2.add_particle(99, [10.0; 3], [0.0; 3], 1.0, 0.1);
1545        w.write_frame(&ds1);
1546        let n = w.write_frame(&ds2);
1547        assert_eq!(n, 1);
1548        assert!(w.deltas[1].1[0].is_new);
1549    }
1550
1551    #[test]
1552    fn test_diff_writer_to_bytes() {
1553        let mut w = ParticleDiffWriter::new();
1554        let ds = make_ds(3);
1555        w.write_frame(&ds);
1556        let bytes = w.to_bytes();
1557        assert_eq!(&bytes[..5], b"PDIFF");
1558    }
1559
1560    #[test]
1561    fn test_diff_writer_total_deltas() {
1562        let mut w = ParticleDiffWriter::new();
1563        let ds = make_ds(5);
1564        w.write_frame(&ds);
1565        assert_eq!(w.total_deltas(), 5); // first frame: all new
1566    }
1567
1568    #[test]
1569    fn test_diff_writer_reconstruct() {
1570        let mut w = ParticleDiffWriter::new();
1571        let ds = make_ds(4);
1572        w.write_frame(&ds);
1573        let rec = w.reconstruct(0);
1574        assert_eq!(rec.len(), 4);
1575    }
1576
1577    #[test]
1578    fn test_diff_writer_thresholds() {
1579        let mut w = ParticleDiffWriter::with_thresholds(1.0, 1.0);
1580        let ds1 = make_ds(2);
1581        let mut ds2 = ds1.clone();
1582        ds2.positions[0][0] += 0.5; // below threshold
1583        w.write_frame(&ds1);
1584        let n = w.write_frame(&ds2);
1585        assert_eq!(n, 0, "sub-threshold changes should be ignored");
1586    }
1587
1588    // ---- ParticleTrajectory ----
1589
1590    #[test]
1591    fn test_trajectory_empty() {
1592        let t = ParticleTrajectory::new("test");
1593        assert_eq!(t.num_frames(), 0);
1594        assert!(t.time_span().is_none());
1595    }
1596
1597    #[test]
1598    fn test_trajectory_push() {
1599        let mut traj = ParticleTrajectory::new("test");
1600        for i in 0..3u64 {
1601            let ds = ParticleDataset::with_time(i as f64, i);
1602            traj.push(ds);
1603        }
1604        assert_eq!(traj.num_frames(), 3);
1605    }
1606
1607    #[test]
1608    fn test_trajectory_time_span() {
1609        let mut traj = ParticleTrajectory::new("test");
1610        traj.push(ParticleDataset::with_time(0.0, 0));
1611        traj.push(ParticleDataset::with_time(1.0, 1));
1612        traj.push(ParticleDataset::with_time(2.0, 2));
1613        let (t0, t1) = traj.time_span().unwrap();
1614        assert!((t0 - 0.0).abs() < 1e-10);
1615        assert!((t1 - 2.0).abs() < 1e-10);
1616    }
1617
1618    #[test]
1619    fn test_trajectory_frame_at() {
1620        let mut traj = ParticleTrajectory::new("test");
1621        for i in 0..5u64 {
1622            traj.push(ParticleDataset::with_time(i as f64, i));
1623        }
1624        let f = traj.frame_at(2.3).unwrap();
1625        assert_eq!(f.step, 2);
1626    }
1627
1628    #[test]
1629    fn test_trajectory_kinetic_energy_series() {
1630        let mut traj = ParticleTrajectory::new("KE");
1631        let mut ds = ParticleDataset::with_time(0.0, 0);
1632        ds.add_particle(0, [0.0; 3], [1.0, 0.0, 0.0], 2.0, 0.1);
1633        traj.push(ds);
1634        let series = traj.kinetic_energy_series();
1635        assert_eq!(series.len(), 1);
1636        assert!((series[0].1 - 1.0).abs() < 1e-10);
1637    }
1638
1639    // ---- ParticleStats ----
1640
1641    #[test]
1642    fn test_stats_empty() {
1643        let s = ParticleStats::compute(&ParticleDataset::new());
1644        assert_eq!(s.count, 0);
1645    }
1646
1647    #[test]
1648    fn test_stats_count() {
1649        let ds = make_ds(7);
1650        let s = ParticleStats::compute(&ds);
1651        assert_eq!(s.count, 7);
1652    }
1653
1654    #[test]
1655    fn test_stats_total_mass() {
1656        let ds = make_ds(5);
1657        let s = ParticleStats::compute(&ds);
1658        assert!((s.total_mass - 5.0).abs() < 1e-10); // each mass = 1.0
1659    }
1660
1661    #[test]
1662    fn test_stats_max_speed() {
1663        let mut ds = ParticleDataset::new();
1664        ds.add_particle(0, [0.0; 3], [3.0, 4.0, 0.0], 1.0, 0.1);
1665        let s = ParticleStats::compute(&ds);
1666        assert!((s.max_speed - 5.0).abs() < 1e-10);
1667    }
1668
1669    // ---- interpolate_frames ----
1670
1671    #[test]
1672    fn test_interpolate_midpoint() {
1673        let mut a = ParticleDataset::with_time(0.0, 0);
1674        a.add_particle(0, [0.0, 0.0, 0.0], [0.0; 3], 1.0, 0.1);
1675        let mut b = ParticleDataset::with_time(1.0, 1);
1676        b.add_particle(0, [2.0, 0.0, 0.0], [0.0; 3], 1.0, 0.1);
1677        let mid = interpolate_frames(&a, &b, 0.5);
1678        assert!((mid.positions[0][0] - 1.0).abs() < 1e-10);
1679    }
1680
1681    #[test]
1682    fn test_interpolate_t0_is_a() {
1683        let mut a = ParticleDataset::with_time(0.0, 0);
1684        a.add_particle(0, [1.0, 2.0, 3.0], [0.0; 3], 1.0, 0.1);
1685        let mut b = ParticleDataset::with_time(1.0, 1);
1686        b.add_particle(0, [4.0, 5.0, 6.0], [0.0; 3], 1.0, 0.1);
1687        let result = interpolate_frames(&a, &b, 0.0);
1688        assert_eq!(result.positions[0], [1.0, 2.0, 3.0]);
1689    }
1690
1691    #[test]
1692    fn test_interpolate_t1_is_b() {
1693        let mut a = ParticleDataset::with_time(0.0, 0);
1694        a.add_particle(0, [0.0; 3], [0.0; 3], 1.0, 0.1);
1695        let mut b = ParticleDataset::with_time(1.0, 1);
1696        b.add_particle(0, [1.0, 1.0, 1.0], [0.0; 3], 1.0, 0.1);
1697        let result = interpolate_frames(&a, &b, 1.0);
1698        assert!((result.positions[0][0] - 1.0).abs() < 1e-10);
1699    }
1700
1701    #[test]
1702    fn test_interpolate_only_common_particles() {
1703        let mut a = ParticleDataset::with_time(0.0, 0);
1704        a.add_particle(0, [0.0; 3], [0.0; 3], 1.0, 0.1);
1705        a.add_particle(1, [1.0; 3], [0.0; 3], 1.0, 0.1);
1706        let mut b = ParticleDataset::with_time(1.0, 1);
1707        b.add_particle(0, [2.0; 3], [0.0; 3], 1.0, 0.1);
1708        // particle 1 not in b
1709        let result = interpolate_frames(&a, &b, 0.5);
1710        assert_eq!(result.len(), 1);
1711    }
1712
1713    // ---- make_test_dataset ----
1714
1715    #[test]
1716    fn test_make_test_dataset_count() {
1717        let ds = make_test_dataset(8, 1.5);
1718        assert_eq!(ds.len(), 8);
1719        assert!((ds.time - 1.5).abs() < 1e-10);
1720    }
1721
1722    #[test]
1723    fn test_make_test_dataset_zero() {
1724        let ds = make_test_dataset(0, 0.0);
1725        assert!(ds.is_empty());
1726    }
1727
1728    #[test]
1729    fn test_make_test_dataset_one() {
1730        let ds = make_test_dataset(1, 0.0);
1731        assert_eq!(ds.len(), 1);
1732    }
1733}