Skip to main content

simular/visualization/
mod.rs

1//! Visualization module for simular.
2//!
3//! Provides visualization capabilities:
4//! - Export Pipeline: JSON Lines, Parquet format, video frames
5//! - TUI Dashboard: Real-time terminal visualization (feature-gated)
6//! - Web Visualization: WebSocket streaming (feature-gated)
7//!
8//! # Example
9//!
10//! ```rust
11//! use simular::visualization::{SimMetrics, Exporter, ExportFormat};
12//!
13//! let metrics = SimMetrics::new();
14//! let exporter = Exporter::new();
15//! ```
16
17use std::collections::VecDeque;
18use std::fmt::Write as FmtWrite;
19use std::fs::File;
20use std::io::{self, BufWriter, Write as IoWrite};
21use std::path::Path;
22
23use serde::{Deserialize, Serialize};
24
25use crate::engine::{SimState, SimTime};
26use crate::error::{SimError, SimResult};
27
28// Re-export TUI module if feature enabled
29#[cfg(feature = "tui")]
30pub mod tui;
31
32#[cfg(feature = "tui")]
33pub use tui::SimularTui;
34
35// Re-export Web module if feature enabled
36#[cfg(feature = "web")]
37pub mod web;
38
39#[cfg(feature = "web")]
40pub use web::{WebPayload, WebVisualization};
41
42// ============================================================================
43// Simulation Metrics
44// ============================================================================
45
46/// Real-time simulation metrics for visualization.
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48pub struct SimMetrics {
49    /// Current simulation time.
50    pub time: f64,
51    /// Current step number.
52    pub step: u64,
53    /// Steps per second (throughput).
54    pub steps_per_second: f64,
55    /// Total energy (if applicable).
56    pub total_energy: Option<f64>,
57    /// Kinetic energy (if applicable).
58    pub kinetic_energy: Option<f64>,
59    /// Potential energy (if applicable).
60    pub potential_energy: Option<f64>,
61    /// Energy drift from initial (relative).
62    pub energy_drift: Option<f64>,
63    /// Number of bodies/particles.
64    pub body_count: usize,
65    /// Number of Jidoka warnings.
66    pub jidoka_warnings: u32,
67    /// Number of Jidoka errors.
68    pub jidoka_errors: u32,
69    /// Memory usage in bytes.
70    pub memory_bytes: usize,
71    /// Custom metrics.
72    pub custom: std::collections::HashMap<String, f64>,
73}
74
75impl SimMetrics {
76    /// Create new empty metrics.
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Update from simulation state.
83    pub fn update_from_state(&mut self, state: &SimState, time: SimTime) {
84        self.time = time.as_secs_f64();
85        self.body_count = state.num_bodies();
86        self.kinetic_energy = Some(state.kinetic_energy());
87        self.potential_energy = Some(state.potential_energy());
88        self.total_energy = Some(state.total_energy());
89    }
90
91    /// Set energy drift relative to initial energy.
92    pub fn set_energy_drift(&mut self, initial_energy: f64) {
93        if let Some(current) = self.total_energy {
94            if initial_energy.abs() > f64::EPSILON {
95                self.energy_drift = Some((current - initial_energy).abs() / initial_energy.abs());
96            }
97        }
98    }
99
100    /// Add custom metric.
101    pub fn add_custom(&mut self, name: impl Into<String>, value: f64) {
102        self.custom.insert(name.into(), value);
103    }
104
105    /// Get custom metric.
106    #[must_use]
107    pub fn get_custom(&self, name: &str) -> Option<f64> {
108        self.custom.get(name).copied()
109    }
110}
111
112/// Time-series data point for plotting.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct DataPoint {
115    /// Timestamp.
116    pub time: f64,
117    /// Value.
118    pub value: f64,
119}
120
121/// Rolling buffer for time-series data.
122#[derive(Debug, Clone)]
123pub struct TimeSeries {
124    /// Data points.
125    data: VecDeque<DataPoint>,
126    /// Maximum capacity.
127    capacity: usize,
128    /// Series name.
129    name: String,
130}
131
132impl TimeSeries {
133    /// Create new time series with capacity.
134    #[must_use]
135    pub fn new(name: impl Into<String>, capacity: usize) -> Self {
136        Self {
137            data: VecDeque::with_capacity(capacity),
138            capacity,
139            name: name.into(),
140        }
141    }
142
143    /// Push a new data point.
144    pub fn push(&mut self, time: f64, value: f64) {
145        if self.data.len() >= self.capacity {
146            self.data.pop_front();
147        }
148        self.data.push_back(DataPoint { time, value });
149    }
150
151    /// Get all data points.
152    #[must_use]
153    pub fn data(&self) -> &VecDeque<DataPoint> {
154        &self.data
155    }
156
157    /// Get series name.
158    #[must_use]
159    pub fn name(&self) -> &str {
160        &self.name
161    }
162
163    /// Get last value.
164    #[must_use]
165    pub fn last_value(&self) -> Option<f64> {
166        self.data.back().map(|p| p.value)
167    }
168
169    /// Get min value.
170    #[must_use]
171    pub fn min(&self) -> Option<f64> {
172        self.data
173            .iter()
174            .map(|p| p.value)
175            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
176    }
177
178    /// Get max value.
179    #[must_use]
180    pub fn max(&self) -> Option<f64> {
181        self.data
182            .iter()
183            .map(|p| p.value)
184            .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
185    }
186
187    /// Get time range.
188    #[must_use]
189    pub fn time_range(&self) -> Option<(f64, f64)> {
190        if self.data.is_empty() {
191            return None;
192        }
193        let first = self.data.front().map_or(0.0, |p| p.time);
194        let last = self.data.back().map_or(0.0, |p| p.time);
195        Some((first, last))
196    }
197
198    /// Clear all data.
199    pub fn clear(&mut self) {
200        self.data.clear();
201    }
202
203    /// Check if empty.
204    #[must_use]
205    pub fn is_empty(&self) -> bool {
206        self.data.is_empty()
207    }
208
209    /// Get number of points.
210    #[must_use]
211    pub fn len(&self) -> usize {
212        self.data.len()
213    }
214}
215
216// ============================================================================
217// Trajectory Frame
218// ============================================================================
219
220/// A single frame in a trajectory for visualization/export.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct TrajectoryFrame {
223    /// Frame timestamp.
224    pub time: f64,
225    /// Frame index.
226    pub index: u64,
227    /// Body positions (flattened: x0, y0, z0, x1, y1, z1, ...).
228    pub positions: Vec<f64>,
229    /// Body velocities (flattened).
230    pub velocities: Vec<f64>,
231    /// Metrics at this frame.
232    pub metrics: SimMetrics,
233}
234
235impl TrajectoryFrame {
236    /// Create frame from simulation state.
237    #[must_use]
238    pub fn from_state(state: &SimState, time: SimTime, index: u64) -> Self {
239        let positions: Vec<f64> = state
240            .positions()
241            .iter()
242            .flat_map(|p| [p.x, p.y, p.z])
243            .collect();
244
245        let velocities: Vec<f64> = state
246            .velocities()
247            .iter()
248            .flat_map(|v| [v.x, v.y, v.z])
249            .collect();
250
251        let mut metrics = SimMetrics::new();
252        metrics.update_from_state(state, time);
253
254        Self {
255            time: time.as_secs_f64(),
256            index,
257            positions,
258            velocities,
259            metrics,
260        }
261    }
262}
263
264/// Collection of trajectory frames.
265#[derive(Debug, Clone, Default, Serialize, Deserialize)]
266pub struct Trajectory {
267    /// Frames in the trajectory.
268    pub frames: Vec<TrajectoryFrame>,
269    /// Metadata.
270    pub metadata: TrajectoryMetadata,
271}
272
273/// Metadata for a trajectory.
274#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct TrajectoryMetadata {
276    /// Simulation name/description.
277    pub name: String,
278    /// Number of bodies.
279    pub body_count: usize,
280    /// Start time.
281    pub start_time: f64,
282    /// End time.
283    pub end_time: f64,
284    /// Time step.
285    pub timestep: f64,
286    /// RNG seed.
287    pub seed: u64,
288}
289
290impl Trajectory {
291    /// Create new empty trajectory.
292    #[must_use]
293    pub fn new() -> Self {
294        Self::default()
295    }
296
297    /// Create with metadata.
298    #[must_use]
299    pub fn with_metadata(metadata: TrajectoryMetadata) -> Self {
300        Self {
301            frames: Vec::new(),
302            metadata,
303        }
304    }
305
306    /// Add a frame.
307    pub fn add_frame(&mut self, frame: TrajectoryFrame) {
308        if let Some(last) = self.frames.last() {
309            self.metadata.end_time = frame.time.max(last.time);
310        } else {
311            self.metadata.start_time = frame.time;
312            self.metadata.end_time = frame.time;
313        }
314        self.frames.push(frame);
315    }
316
317    /// Get number of frames.
318    #[must_use]
319    pub fn len(&self) -> usize {
320        self.frames.len()
321    }
322
323    /// Check if empty.
324    #[must_use]
325    pub fn is_empty(&self) -> bool {
326        self.frames.is_empty()
327    }
328
329    /// Get frame at index.
330    #[must_use]
331    pub fn frame(&self, index: usize) -> Option<&TrajectoryFrame> {
332        self.frames.get(index)
333    }
334
335    /// Get frame closest to time.
336    #[must_use]
337    pub fn frame_at_time(&self, time: f64) -> Option<&TrajectoryFrame> {
338        self.frames.iter().min_by(|a, b| {
339            let diff_a = (a.time - time).abs();
340            let diff_b = (b.time - time).abs();
341            diff_a
342                .partial_cmp(&diff_b)
343                .unwrap_or(std::cmp::Ordering::Equal)
344        })
345    }
346
347    /// Get duration.
348    #[must_use]
349    pub fn duration(&self) -> f64 {
350        self.metadata.end_time - self.metadata.start_time
351    }
352}
353
354// ============================================================================
355// Export Pipeline
356// ============================================================================
357
358/// Video format options.
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
360pub enum VideoFormat {
361    /// `MP4` (H.264).
362    Mp4,
363    /// GIF animation.
364    Gif,
365    /// `WebM` (VP9).
366    WebM,
367}
368
369/// Parquet compression options.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371pub enum ParquetCompression {
372    /// No compression.
373    None,
374    /// Snappy compression.
375    Snappy,
376    /// Zstd compression.
377    Zstd,
378    /// LZ4 compression.
379    Lz4,
380}
381
382/// Export format options.
383#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384pub enum ExportFormat {
385    /// JSON Lines (streaming JSON).
386    #[default]
387    JsonLines,
388    /// Parquet columnar format.
389    Parquet {
390        /// Compression algorithm.
391        compression: ParquetCompression,
392    },
393    /// Video export.
394    Video {
395        /// Video format.
396        format: VideoFormat,
397        /// Frames per second.
398        fps: u32,
399    },
400    /// CSV format.
401    Csv,
402    /// Binary format (bincode).
403    Binary,
404}
405
406/// Export configuration.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct ExportConfig {
409    /// Output format.
410    pub format: ExportFormat,
411    /// Include velocities.
412    pub include_velocities: bool,
413    /// Include metrics.
414    pub include_metrics: bool,
415    /// Decimation factor (1 = every frame, 2 = every other, etc.).
416    pub decimation: usize,
417}
418
419impl Default for ExportConfig {
420    fn default() -> Self {
421        Self {
422            format: ExportFormat::JsonLines,
423            include_velocities: true,
424            include_metrics: true,
425            decimation: 1,
426        }
427    }
428}
429
430/// Exporter for simulation data.
431#[derive(Debug, Clone)]
432pub struct Exporter {
433    /// Export configuration.
434    config: ExportConfig,
435}
436
437impl Default for Exporter {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443impl Exporter {
444    /// Create new exporter with default config.
445    #[must_use]
446    pub fn new() -> Self {
447        Self {
448            config: ExportConfig::default(),
449        }
450    }
451
452    /// Create with custom config.
453    #[must_use]
454    pub fn with_config(config: ExportConfig) -> Self {
455        Self { config }
456    }
457
458    /// Export trajectory to JSON Lines format.
459    ///
460    /// # Errors
461    ///
462    /// Returns error if file operations fail.
463    pub fn to_json_lines(&self, trajectory: &Trajectory, path: &Path) -> SimResult<()> {
464        let file =
465            File::create(path).map_err(|e| SimError::io(format!("Failed to create file: {e}")))?;
466        let mut writer = BufWriter::new(file);
467
468        for (i, frame) in trajectory.frames.iter().enumerate() {
469            if i % self.config.decimation.max(1) != 0 {
470                continue;
471            }
472
473            let json = serde_json::to_string(frame)
474                .map_err(|e| SimError::serialization(format!("JSON serialization failed: {e}")))?;
475            writeln!(writer, "{json}").map_err(|e| SimError::io(format!("Write failed: {e}")))?;
476        }
477
478        writer
479            .flush()
480            .map_err(|e| SimError::io(format!("Flush failed: {e}")))?;
481
482        Ok(())
483    }
484
485    /// Export trajectory to CSV format.
486    ///
487    /// # Errors
488    ///
489    /// Returns error if file operations fail.
490    pub fn to_csv(&self, trajectory: &Trajectory, path: &Path) -> SimResult<()> {
491        let file =
492            File::create(path).map_err(|e| SimError::io(format!("Failed to create file: {e}")))?;
493        let mut writer = BufWriter::new(file);
494
495        // Write header
496        let mut header = String::from("time,index");
497        if !trajectory.frames.is_empty() {
498            let n_bodies = trajectory.frames[0].positions.len() / 3;
499            for i in 0..n_bodies {
500                let _ = write!(header, ",x{i},y{i},z{i}");
501                if self.config.include_velocities {
502                    let _ = write!(header, ",vx{i},vy{i},vz{i}");
503                }
504            }
505        }
506        if self.config.include_metrics {
507            header.push_str(",total_energy,kinetic_energy,potential_energy");
508        }
509        writeln!(writer, "{header}")
510            .map_err(|e| SimError::io(format!("Write header failed: {e}")))?;
511
512        // Write data
513        for (i, frame) in trajectory.frames.iter().enumerate() {
514            if i % self.config.decimation.max(1) != 0 {
515                continue;
516            }
517
518            let mut line = format!("{},{}", frame.time, frame.index);
519
520            for (j, pos) in frame.positions.chunks(3).enumerate() {
521                if pos.len() == 3 {
522                    let _ = write!(line, ",{},{},{}", pos[0], pos[1], pos[2]);
523                }
524                if self.config.include_velocities {
525                    if let Some(vel) = frame.velocities.chunks(3).nth(j) {
526                        if vel.len() == 3 {
527                            let _ = write!(line, ",{},{},{}", vel[0], vel[1], vel[2]);
528                        }
529                    }
530                }
531            }
532
533            if self.config.include_metrics {
534                let te = frame.metrics.total_energy.unwrap_or(0.0);
535                let ke = frame.metrics.kinetic_energy.unwrap_or(0.0);
536                let pe = frame.metrics.potential_energy.unwrap_or(0.0);
537                let _ = write!(line, ",{te},{ke},{pe}");
538            }
539
540            writeln!(writer, "{line}")
541                .map_err(|e| SimError::io(format!("Write data failed: {e}")))?;
542        }
543
544        writer
545            .flush()
546            .map_err(|e| SimError::io(format!("Flush failed: {e}")))?;
547
548        Ok(())
549    }
550
551    /// Export trajectory to binary format (bincode).
552    ///
553    /// # Errors
554    ///
555    /// Returns error if file operations fail.
556    pub fn to_binary(&self, trajectory: &Trajectory, path: &Path) -> SimResult<()> {
557        let file =
558            File::create(path).map_err(|e| SimError::io(format!("Failed to create file: {e}")))?;
559        let writer = BufWriter::new(file);
560
561        bincode::serialize_into(writer, trajectory)
562            .map_err(|e| SimError::serialization(format!("Binary serialization failed: {e}")))?;
563
564        Ok(())
565    }
566
567    /// Load trajectory from binary format.
568    ///
569    /// # Errors
570    ///
571    /// Returns error if file operations fail.
572    pub fn from_binary(path: &Path) -> SimResult<Trajectory> {
573        let file =
574            File::open(path).map_err(|e| SimError::io(format!("Failed to open file: {e}")))?;
575        let reader = io::BufReader::new(file);
576
577        bincode::deserialize_from(reader)
578            .map_err(|e| SimError::serialization(format!("Binary deserialization failed: {e}")))
579    }
580
581    /// Export using configured format.
582    ///
583    /// # Errors
584    ///
585    /// Returns error if export fails.
586    pub fn export(&self, trajectory: &Trajectory, path: &Path) -> SimResult<()> {
587        match &self.config.format {
588            ExportFormat::JsonLines => self.to_json_lines(trajectory, path),
589            ExportFormat::Csv => self.to_csv(trajectory, path),
590            ExportFormat::Binary => self.to_binary(trajectory, path),
591            ExportFormat::Parquet { .. } => Err(SimError::config(
592                "Parquet export requires alimentar integration".to_string(),
593            )),
594            ExportFormat::Video { .. } => Err(SimError::config(
595                "Video export requires ffmpeg integration".to_string(),
596            )),
597        }
598    }
599}
600
601/// Streaming exporter for real-time export.
602pub struct StreamingExporter {
603    /// Output writer.
604    writer: BufWriter<File>,
605    /// Frame count.
606    frame_count: u64,
607    /// Decimation counter.
608    decimation_count: usize,
609    /// Decimation factor.
610    decimation: usize,
611}
612
613impl StreamingExporter {
614    /// Create new streaming exporter.
615    ///
616    /// # Errors
617    ///
618    /// Returns error if file creation fails.
619    pub fn new(path: &Path, decimation: usize) -> SimResult<Self> {
620        let file =
621            File::create(path).map_err(|e| SimError::io(format!("Failed to create file: {e}")))?;
622        Ok(Self {
623            writer: BufWriter::new(file),
624            frame_count: 0,
625            decimation_count: 0,
626            decimation: decimation.max(1),
627        })
628    }
629
630    /// Write a frame to the stream.
631    ///
632    /// # Errors
633    ///
634    /// Returns error if write fails.
635    pub fn write_frame(&mut self, frame: &TrajectoryFrame) -> SimResult<()> {
636        self.decimation_count += 1;
637        if self.decimation_count < self.decimation {
638            return Ok(());
639        }
640        self.decimation_count = 0;
641
642        let json = serde_json::to_string(frame)
643            .map_err(|e| SimError::serialization(format!("JSON serialization failed: {e}")))?;
644        writeln!(self.writer, "{json}").map_err(|e| SimError::io(format!("Write failed: {e}")))?;
645
646        self.frame_count += 1;
647        Ok(())
648    }
649
650    /// Flush and close the stream.
651    ///
652    /// # Errors
653    ///
654    /// Returns error if flush fails.
655    pub fn finish(mut self) -> SimResult<u64> {
656        self.writer
657            .flush()
658            .map_err(|e| SimError::io(format!("Flush failed: {e}")))?;
659        Ok(self.frame_count)
660    }
661
662    /// Get current frame count.
663    #[must_use]
664    pub fn frame_count(&self) -> u64 {
665        self.frame_count
666    }
667}
668
669// ============================================================================
670// Tests
671// ============================================================================
672
673#[cfg(test)]
674#[allow(clippy::unwrap_used, clippy::expect_used)]
675mod tests {
676    use super::*;
677    use std::io::Read as IoRead;
678    use tempfile::tempdir;
679
680    #[test]
681    fn test_sim_metrics_new() {
682        let metrics = SimMetrics::new();
683        assert_eq!(metrics.step, 0);
684        assert_eq!(metrics.body_count, 0);
685    }
686
687    #[test]
688    fn test_sim_metrics_custom() {
689        let mut metrics = SimMetrics::new();
690        metrics.add_custom("test_metric", 42.0);
691        assert!((metrics.get_custom("test_metric").unwrap() - 42.0).abs() < f64::EPSILON);
692        assert!(metrics.get_custom("nonexistent").is_none());
693    }
694
695    #[test]
696    fn test_sim_metrics_default() {
697        let metrics: SimMetrics = Default::default();
698        assert_eq!(metrics.step, 0);
699        assert!(metrics.total_energy.is_none());
700    }
701
702    #[test]
703    fn test_sim_metrics_set_energy_drift() {
704        let mut metrics = SimMetrics::new();
705        metrics.total_energy = Some(10.5);
706        metrics.set_energy_drift(10.0);
707        assert!(metrics.energy_drift.is_some());
708        let drift = metrics.energy_drift.unwrap();
709        assert!((drift - 0.05).abs() < 1e-10);
710    }
711
712    #[test]
713    fn test_sim_metrics_set_energy_drift_zero_initial() {
714        let mut metrics = SimMetrics::new();
715        metrics.total_energy = Some(10.5);
716        metrics.set_energy_drift(0.0); // Zero initial energy
717        assert!(metrics.energy_drift.is_none());
718    }
719
720    #[test]
721    fn test_sim_metrics_set_energy_drift_no_total() {
722        let mut metrics = SimMetrics::new();
723        // No total_energy set
724        metrics.set_energy_drift(10.0);
725        assert!(metrics.energy_drift.is_none());
726    }
727
728    #[test]
729    fn test_sim_metrics_clone() {
730        let mut metrics = SimMetrics::new();
731        metrics.step = 42;
732        metrics.add_custom("key", 1.0);
733        let cloned = metrics.clone();
734        assert_eq!(cloned.step, 42);
735        assert!((cloned.get_custom("key").unwrap() - 1.0).abs() < f64::EPSILON);
736    }
737
738    #[test]
739    fn test_time_series_new() {
740        let series = TimeSeries::new("test", 100);
741        assert!(series.is_empty());
742        assert_eq!(series.name(), "test");
743    }
744
745    #[test]
746    fn test_time_series_push() {
747        let mut series = TimeSeries::new("test", 100);
748        series.push(0.0, 1.0);
749        series.push(1.0, 2.0);
750        series.push(2.0, 3.0);
751
752        assert_eq!(series.len(), 3);
753        assert!((series.last_value().unwrap() - 3.0).abs() < f64::EPSILON);
754    }
755
756    #[test]
757    fn test_time_series_capacity() {
758        let mut series = TimeSeries::new("test", 3);
759        series.push(0.0, 1.0);
760        series.push(1.0, 2.0);
761        series.push(2.0, 3.0);
762        series.push(3.0, 4.0); // Should evict first
763
764        assert_eq!(series.len(), 3);
765        assert!((series.data().front().unwrap().value - 2.0).abs() < f64::EPSILON);
766    }
767
768    #[test]
769    fn test_time_series_min_max() {
770        let mut series = TimeSeries::new("test", 100);
771        series.push(0.0, 5.0);
772        series.push(1.0, 2.0);
773        series.push(2.0, 8.0);
774
775        assert!((series.min().unwrap() - 2.0).abs() < f64::EPSILON);
776        assert!((series.max().unwrap() - 8.0).abs() < f64::EPSILON);
777    }
778
779    #[test]
780    fn test_time_series_range() {
781        let mut series = TimeSeries::new("test", 100);
782        series.push(1.0, 0.0);
783        series.push(5.0, 0.0);
784
785        let (start, end) = series.time_range().unwrap();
786        assert!((start - 1.0).abs() < f64::EPSILON);
787        assert!((end - 5.0).abs() < f64::EPSILON);
788    }
789
790    #[test]
791    fn test_time_series_empty_stats() {
792        let series = TimeSeries::new("test", 100);
793        assert!(series.min().is_none());
794        assert!(series.max().is_none());
795        assert!(series.last_value().is_none());
796        assert!(series.time_range().is_none());
797    }
798
799    #[test]
800    fn test_time_series_clear() {
801        let mut series = TimeSeries::new("test", 100);
802        series.push(0.0, 1.0);
803        series.push(1.0, 2.0);
804        assert!(!series.is_empty());
805
806        series.clear();
807        assert!(series.is_empty());
808        assert_eq!(series.len(), 0);
809    }
810
811    #[test]
812    fn test_time_series_clone() {
813        let mut series = TimeSeries::new("test", 100);
814        series.push(0.0, 1.0);
815        let cloned = series.clone();
816        assert_eq!(cloned.len(), 1);
817        assert_eq!(cloned.name(), "test");
818    }
819
820    #[test]
821    fn test_data_point_clone() {
822        let dp = DataPoint {
823            time: 1.0,
824            value: 2.0,
825        };
826        let cloned = dp.clone();
827        assert!((cloned.time - 1.0).abs() < f64::EPSILON);
828        assert!((cloned.value - 2.0).abs() < f64::EPSILON);
829    }
830
831    #[test]
832    fn test_trajectory_new() {
833        let traj = Trajectory::new();
834        assert!(traj.is_empty());
835        assert_eq!(traj.len(), 0);
836    }
837
838    #[test]
839    fn test_trajectory_with_metadata() {
840        let metadata = TrajectoryMetadata {
841            name: "test".to_string(),
842            body_count: 5,
843            start_time: 0.0,
844            end_time: 10.0,
845            timestep: 0.01,
846            seed: 42,
847        };
848        let traj = Trajectory::with_metadata(metadata);
849        assert!(traj.is_empty());
850        assert_eq!(traj.metadata.name, "test");
851        assert_eq!(traj.metadata.body_count, 5);
852    }
853
854    #[test]
855    fn test_trajectory_add_frame() {
856        let mut traj = Trajectory::new();
857        let frame = TrajectoryFrame {
858            time: 0.0,
859            index: 0,
860            positions: vec![1.0, 2.0, 3.0],
861            velocities: vec![0.1, 0.2, 0.3],
862            metrics: SimMetrics::new(),
863        };
864        traj.add_frame(frame);
865
866        assert_eq!(traj.len(), 1);
867        assert!((traj.metadata.start_time - 0.0).abs() < f64::EPSILON);
868    }
869
870    #[test]
871    fn test_trajectory_add_multiple_frames() {
872        let mut traj = Trajectory::new();
873        for i in 0..5 {
874            traj.add_frame(TrajectoryFrame {
875                time: i as f64,
876                index: i,
877                positions: vec![],
878                velocities: vec![],
879                metrics: SimMetrics::new(),
880            });
881        }
882        assert_eq!(traj.len(), 5);
883        assert!((traj.metadata.start_time - 0.0).abs() < f64::EPSILON);
884        assert!((traj.metadata.end_time - 4.0).abs() < f64::EPSILON);
885    }
886
887    #[test]
888    fn test_trajectory_duration() {
889        let mut traj = Trajectory::new();
890        traj.add_frame(TrajectoryFrame {
891            time: 0.0,
892            index: 0,
893            positions: vec![],
894            velocities: vec![],
895            metrics: SimMetrics::new(),
896        });
897        traj.add_frame(TrajectoryFrame {
898            time: 10.0,
899            index: 1,
900            positions: vec![],
901            velocities: vec![],
902            metrics: SimMetrics::new(),
903        });
904        assert!((traj.duration() - 10.0).abs() < f64::EPSILON);
905    }
906
907    #[test]
908    fn test_trajectory_frame() {
909        let mut traj = Trajectory::new();
910        traj.add_frame(TrajectoryFrame {
911            time: 1.0,
912            index: 0,
913            positions: vec![1.0, 2.0, 3.0],
914            velocities: vec![],
915            metrics: SimMetrics::new(),
916        });
917
918        let frame = traj.frame(0).unwrap();
919        assert_eq!(frame.index, 0);
920        assert!(traj.frame(1).is_none());
921    }
922
923    #[test]
924    fn test_trajectory_frame_at_time() {
925        let mut traj = Trajectory::new();
926        for i in 0..10 {
927            traj.add_frame(TrajectoryFrame {
928                time: i as f64,
929                index: i,
930                positions: vec![],
931                velocities: vec![],
932                metrics: SimMetrics::new(),
933            });
934        }
935
936        let frame = traj.frame_at_time(5.5).unwrap();
937        // Should return frame 5 or 6 (closest)
938        assert!(frame.index == 5 || frame.index == 6);
939    }
940
941    #[test]
942    fn test_trajectory_frame_at_time_empty() {
943        let traj = Trajectory::new();
944        assert!(traj.frame_at_time(5.0).is_none());
945    }
946
947    #[test]
948    fn test_trajectory_clone() {
949        let mut traj = Trajectory::new();
950        traj.add_frame(TrajectoryFrame {
951            time: 0.0,
952            index: 0,
953            positions: vec![1.0],
954            velocities: vec![],
955            metrics: SimMetrics::new(),
956        });
957        let cloned = traj.clone();
958        assert_eq!(cloned.len(), 1);
959    }
960
961    #[test]
962    fn test_trajectory_frame_clone() {
963        let frame = TrajectoryFrame {
964            time: 1.0,
965            index: 42,
966            positions: vec![1.0, 2.0],
967            velocities: vec![3.0, 4.0],
968            metrics: SimMetrics::new(),
969        };
970        let cloned = frame.clone();
971        assert_eq!(cloned.index, 42);
972        assert_eq!(cloned.positions.len(), 2);
973    }
974
975    #[test]
976    fn test_exporter_new() {
977        let exporter = Exporter::new();
978        assert!(matches!(exporter.config.format, ExportFormat::JsonLines));
979    }
980
981    #[test]
982    fn test_exporter_default() {
983        let exporter: Exporter = Default::default();
984        assert!(matches!(exporter.config.format, ExportFormat::JsonLines));
985    }
986
987    #[test]
988    fn test_exporter_with_config() {
989        let config = ExportConfig {
990            format: ExportFormat::Csv,
991            include_velocities: false,
992            include_metrics: false,
993            decimation: 2,
994        };
995        let exporter = Exporter::with_config(config);
996        assert!(matches!(exporter.config.format, ExportFormat::Csv));
997        assert!(!exporter.config.include_velocities);
998    }
999
1000    #[test]
1001    fn test_export_format_default() {
1002        let format = ExportFormat::default();
1003        assert!(matches!(format, ExportFormat::JsonLines));
1004    }
1005
1006    #[test]
1007    fn test_export_config_default() {
1008        let config = ExportConfig::default();
1009        assert!(config.include_velocities);
1010        assert!(config.include_metrics);
1011        assert_eq!(config.decimation, 1);
1012    }
1013
1014    #[test]
1015    fn test_export_to_json_lines() {
1016        let dir = tempdir().unwrap();
1017        let path = dir.path().join("test.jsonl");
1018
1019        let mut traj = Trajectory::new();
1020        traj.add_frame(TrajectoryFrame {
1021            time: 0.0,
1022            index: 0,
1023            positions: vec![1.0, 2.0, 3.0],
1024            velocities: vec![0.1, 0.2, 0.3],
1025            metrics: SimMetrics::new(),
1026        });
1027
1028        let exporter = Exporter::new();
1029        exporter.to_json_lines(&traj, &path).unwrap();
1030
1031        let mut content = String::new();
1032        File::open(&path)
1033            .unwrap()
1034            .read_to_string(&mut content)
1035            .unwrap();
1036        assert!(content.contains("\"time\":0.0"));
1037        assert!(content.contains("\"index\":0"));
1038    }
1039
1040    #[test]
1041    fn test_export_to_json_lines_decimation() {
1042        let dir = tempdir().unwrap();
1043        let path = dir.path().join("test.jsonl");
1044
1045        let mut traj = Trajectory::new();
1046        for i in 0..10 {
1047            traj.add_frame(TrajectoryFrame {
1048                time: i as f64,
1049                index: i,
1050                positions: vec![],
1051                velocities: vec![],
1052                metrics: SimMetrics::new(),
1053            });
1054        }
1055
1056        let config = ExportConfig {
1057            decimation: 2,
1058            ..Default::default()
1059        };
1060        let exporter = Exporter::with_config(config);
1061        exporter.to_json_lines(&traj, &path).unwrap();
1062
1063        let mut content = String::new();
1064        File::open(&path)
1065            .unwrap()
1066            .read_to_string(&mut content)
1067            .unwrap();
1068        // Should have frames 0, 2, 4, 6, 8
1069        let lines: Vec<_> = content.lines().collect();
1070        assert_eq!(lines.len(), 5);
1071    }
1072
1073    #[test]
1074    fn test_export_to_csv() {
1075        let dir = tempdir().unwrap();
1076        let path = dir.path().join("test.csv");
1077
1078        let mut traj = Trajectory::new();
1079        traj.add_frame(TrajectoryFrame {
1080            time: 0.0,
1081            index: 0,
1082            positions: vec![1.0, 2.0, 3.0],
1083            velocities: vec![0.1, 0.2, 0.3],
1084            metrics: SimMetrics::new(),
1085        });
1086
1087        let exporter = Exporter::new();
1088        exporter.to_csv(&traj, &path).unwrap();
1089
1090        let mut content = String::new();
1091        File::open(&path)
1092            .unwrap()
1093            .read_to_string(&mut content)
1094            .unwrap();
1095        assert!(content.contains("time,index"));
1096        assert!(content.contains("0,0"));
1097    }
1098
1099    #[test]
1100    fn test_export_to_csv_no_velocities() {
1101        let dir = tempdir().unwrap();
1102        let path = dir.path().join("test.csv");
1103
1104        let mut traj = Trajectory::new();
1105        traj.add_frame(TrajectoryFrame {
1106            time: 0.0,
1107            index: 0,
1108            positions: vec![1.0, 2.0, 3.0],
1109            velocities: vec![0.1, 0.2, 0.3],
1110            metrics: SimMetrics::new(),
1111        });
1112
1113        let config = ExportConfig {
1114            include_velocities: false,
1115            ..Default::default()
1116        };
1117        let exporter = Exporter::with_config(config);
1118        exporter.to_csv(&traj, &path).unwrap();
1119
1120        let mut content = String::new();
1121        File::open(&path)
1122            .unwrap()
1123            .read_to_string(&mut content)
1124            .unwrap();
1125        // Header shouldn't have vx, vy, vz
1126        assert!(!content.lines().next().unwrap().contains("vx"));
1127    }
1128
1129    #[test]
1130    fn test_export_to_csv_no_metrics() {
1131        let dir = tempdir().unwrap();
1132        let path = dir.path().join("test.csv");
1133
1134        let mut traj = Trajectory::new();
1135        traj.add_frame(TrajectoryFrame {
1136            time: 0.0,
1137            index: 0,
1138            positions: vec![1.0, 2.0, 3.0],
1139            velocities: vec![],
1140            metrics: SimMetrics::new(),
1141        });
1142
1143        let config = ExportConfig {
1144            include_metrics: false,
1145            ..Default::default()
1146        };
1147        let exporter = Exporter::with_config(config);
1148        exporter.to_csv(&traj, &path).unwrap();
1149
1150        let mut content = String::new();
1151        File::open(&path)
1152            .unwrap()
1153            .read_to_string(&mut content)
1154            .unwrap();
1155        assert!(!content.contains("total_energy"));
1156    }
1157
1158    #[test]
1159    fn test_export_to_csv_decimation() {
1160        let dir = tempdir().unwrap();
1161        let path = dir.path().join("test.csv");
1162
1163        let mut traj = Trajectory::new();
1164        for i in 0..10 {
1165            traj.add_frame(TrajectoryFrame {
1166                time: i as f64,
1167                index: i,
1168                positions: vec![],
1169                velocities: vec![],
1170                metrics: SimMetrics::new(),
1171            });
1172        }
1173
1174        let config = ExportConfig {
1175            decimation: 3,
1176            ..Default::default()
1177        };
1178        let exporter = Exporter::with_config(config);
1179        exporter.to_csv(&traj, &path).unwrap();
1180
1181        let mut content = String::new();
1182        File::open(&path)
1183            .unwrap()
1184            .read_to_string(&mut content)
1185            .unwrap();
1186        // Should have header + frames 0, 3, 6, 9
1187        let lines: Vec<_> = content.lines().collect();
1188        assert_eq!(lines.len(), 5); // header + 4 data lines
1189    }
1190
1191    #[test]
1192    fn test_export_to_binary() {
1193        let dir = tempdir().unwrap();
1194        let path = dir.path().join("test.bin");
1195
1196        let mut traj = Trajectory::new();
1197        traj.add_frame(TrajectoryFrame {
1198            time: 0.0,
1199            index: 0,
1200            positions: vec![1.0, 2.0, 3.0],
1201            velocities: vec![0.1, 0.2, 0.3],
1202            metrics: SimMetrics::new(),
1203        });
1204
1205        let exporter = Exporter::new();
1206        exporter.to_binary(&traj, &path).unwrap();
1207
1208        // Load it back
1209        let loaded = Exporter::from_binary(&path).unwrap();
1210        assert_eq!(loaded.len(), 1);
1211        assert!((loaded.frames[0].time - 0.0).abs() < f64::EPSILON);
1212    }
1213
1214    #[test]
1215    fn test_export_generic() {
1216        let dir = tempdir().unwrap();
1217        let path = dir.path().join("test.jsonl");
1218
1219        let mut traj = Trajectory::new();
1220        traj.add_frame(TrajectoryFrame {
1221            time: 0.0,
1222            index: 0,
1223            positions: vec![],
1224            velocities: vec![],
1225            metrics: SimMetrics::new(),
1226        });
1227
1228        let exporter = Exporter::new();
1229        exporter.export(&traj, &path).unwrap();
1230
1231        assert!(path.exists());
1232    }
1233
1234    #[test]
1235    fn test_export_parquet_unsupported() {
1236        let dir = tempdir().unwrap();
1237        let path = dir.path().join("test.parquet");
1238
1239        let traj = Trajectory::new();
1240        let config = ExportConfig {
1241            format: ExportFormat::Parquet {
1242                compression: ParquetCompression::Snappy,
1243            },
1244            ..Default::default()
1245        };
1246        let exporter = Exporter::with_config(config);
1247        let result = exporter.export(&traj, &path);
1248        assert!(result.is_err());
1249    }
1250
1251    #[test]
1252    fn test_export_video_unsupported() {
1253        let dir = tempdir().unwrap();
1254        let path = dir.path().join("test.mp4");
1255
1256        let traj = Trajectory::new();
1257        let config = ExportConfig {
1258            format: ExportFormat::Video {
1259                format: VideoFormat::Mp4,
1260                fps: 30,
1261            },
1262            ..Default::default()
1263        };
1264        let exporter = Exporter::with_config(config);
1265        let result = exporter.export(&traj, &path);
1266        assert!(result.is_err());
1267    }
1268
1269    #[test]
1270    fn test_streaming_exporter() {
1271        let dir = tempdir().unwrap();
1272        let path = dir.path().join("stream.jsonl");
1273
1274        let mut stream = StreamingExporter::new(&path, 1).unwrap();
1275        assert_eq!(stream.frame_count(), 0);
1276
1277        let frame = TrajectoryFrame {
1278            time: 0.0,
1279            index: 0,
1280            positions: vec![1.0, 2.0, 3.0],
1281            velocities: vec![],
1282            metrics: SimMetrics::new(),
1283        };
1284        stream.write_frame(&frame).unwrap();
1285        assert_eq!(stream.frame_count(), 1);
1286
1287        let count = stream.finish().unwrap();
1288        assert_eq!(count, 1);
1289    }
1290
1291    #[test]
1292    fn test_streaming_exporter_decimation() {
1293        let dir = tempdir().unwrap();
1294        let path = dir.path().join("stream.jsonl");
1295
1296        let mut stream = StreamingExporter::new(&path, 2).unwrap();
1297
1298        for i in 0..10 {
1299            let frame = TrajectoryFrame {
1300                time: i as f64,
1301                index: i,
1302                positions: vec![],
1303                velocities: vec![],
1304                metrics: SimMetrics::new(),
1305            };
1306            stream.write_frame(&frame).unwrap();
1307        }
1308
1309        let count = stream.finish().unwrap();
1310        assert_eq!(count, 5); // Every other frame
1311    }
1312
1313    #[test]
1314    fn test_video_format_eq() {
1315        assert_eq!(VideoFormat::Mp4, VideoFormat::Mp4);
1316        assert_ne!(VideoFormat::Mp4, VideoFormat::Gif);
1317        assert_ne!(VideoFormat::Gif, VideoFormat::WebM);
1318    }
1319
1320    #[test]
1321    fn test_parquet_compression_eq() {
1322        assert_eq!(ParquetCompression::None, ParquetCompression::None);
1323        assert_ne!(ParquetCompression::None, ParquetCompression::Snappy);
1324        assert_ne!(ParquetCompression::Zstd, ParquetCompression::Lz4);
1325    }
1326
1327    #[test]
1328    fn test_export_format_parquet_variants() {
1329        let _ = ExportFormat::Parquet {
1330            compression: ParquetCompression::None,
1331        };
1332        let _ = ExportFormat::Parquet {
1333            compression: ParquetCompression::Zstd,
1334        };
1335        let _ = ExportFormat::Parquet {
1336            compression: ParquetCompression::Lz4,
1337        };
1338    }
1339
1340    #[test]
1341    fn test_export_format_video_variants() {
1342        let _ = ExportFormat::Video {
1343            format: VideoFormat::Gif,
1344            fps: 24,
1345        };
1346        let _ = ExportFormat::Video {
1347            format: VideoFormat::WebM,
1348            fps: 60,
1349        };
1350    }
1351
1352    #[test]
1353    fn test_trajectory_metadata_default() {
1354        let meta: TrajectoryMetadata = Default::default();
1355        assert!(meta.name.is_empty());
1356        assert_eq!(meta.body_count, 0);
1357    }
1358
1359    // === Additional Coverage Tests ===
1360
1361    #[test]
1362    fn test_sim_metrics_update_from_state() {
1363        use crate::engine::state::Vec3;
1364        let mut state = SimState::new();
1365        state.add_body(1.0, Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.5, 0.0, 0.0));
1366        state.set_potential_energy(-10.0);
1367
1368        let mut metrics = SimMetrics::new();
1369        metrics.update_from_state(&state, crate::engine::SimTime::from_secs(1.5));
1370
1371        assert_eq!(metrics.body_count, 1);
1372        assert!((metrics.time - 1.5).abs() < f64::EPSILON);
1373        assert!(metrics.kinetic_energy.is_some());
1374        assert!(metrics.potential_energy.is_some());
1375        assert!(metrics.total_energy.is_some());
1376    }
1377
1378    #[test]
1379    fn test_sim_metrics_debug() {
1380        let metrics = SimMetrics::new();
1381        let debug = format!("{:?}", metrics);
1382        assert!(debug.contains("SimMetrics"));
1383    }
1384
1385    #[test]
1386    fn test_trajectory_frame_from_state() {
1387        use crate::engine::state::Vec3;
1388        let mut state = SimState::new();
1389        state.add_body(1.0, Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.5, 0.0, 0.0));
1390
1391        let frame = TrajectoryFrame::from_state(&state, crate::engine::SimTime::from_secs(1.0), 42);
1392
1393        assert!((frame.time - 1.0).abs() < f64::EPSILON);
1394        assert_eq!(frame.index, 42);
1395        assert_eq!(frame.positions.len(), 3); // x, y, z for 1 body
1396        assert_eq!(frame.velocities.len(), 3);
1397    }
1398
1399    #[test]
1400    fn test_trajectory_frame_debug() {
1401        let frame = TrajectoryFrame {
1402            time: 0.0,
1403            index: 0,
1404            positions: vec![],
1405            velocities: vec![],
1406            metrics: SimMetrics::new(),
1407        };
1408        let debug = format!("{:?}", frame);
1409        assert!(debug.contains("TrajectoryFrame"));
1410    }
1411
1412    #[test]
1413    fn test_trajectory_debug() {
1414        let traj = Trajectory::new();
1415        let debug = format!("{:?}", traj);
1416        assert!(debug.contains("Trajectory"));
1417    }
1418
1419    #[test]
1420    fn test_time_series_clear_with_capacity_check() {
1421        let mut series = TimeSeries::new("test", 100);
1422        series.push(0.0, 1.0);
1423        series.push(1.0, 2.0);
1424        assert!(!series.is_empty());
1425
1426        series.clear();
1427        assert!(series.is_empty());
1428        assert_eq!(series.len(), 0);
1429        // After clear, name should be unchanged
1430        assert_eq!(series.name(), "test");
1431    }
1432
1433    #[test]
1434    fn test_time_series_data_access() {
1435        let mut series = TimeSeries::new("test", 100);
1436        series.push(0.5, 1.0);
1437        series.push(1.5, 2.0);
1438        series.push(2.5, 3.0);
1439
1440        let data = series.data();
1441        assert_eq!(data.len(), 3);
1442        assert!((data.front().unwrap().time - 0.5).abs() < f64::EPSILON);
1443        assert!((data.back().unwrap().time - 2.5).abs() < f64::EPSILON);
1444    }
1445
1446    #[test]
1447    fn test_data_point_debug_clone() {
1448        let dp = DataPoint {
1449            time: 1.0,
1450            value: 2.0,
1451        };
1452        let cloned = dp.clone();
1453        assert!((cloned.time - 1.0).abs() < f64::EPSILON);
1454        assert!((cloned.value - 2.0).abs() < f64::EPSILON);
1455
1456        let debug = format!("{:?}", dp);
1457        assert!(debug.contains("DataPoint"));
1458    }
1459
1460    #[test]
1461    fn test_video_format_debug_clone() {
1462        let vf = VideoFormat::Mp4;
1463        let cloned = vf.clone();
1464        assert_eq!(cloned, VideoFormat::Mp4);
1465
1466        let debug = format!("{:?}", vf);
1467        assert!(debug.contains("Mp4"));
1468    }
1469
1470    #[test]
1471    fn test_parquet_compression_debug_clone() {
1472        let pc = ParquetCompression::Zstd;
1473        let cloned = pc.clone();
1474        assert_eq!(cloned, ParquetCompression::Zstd);
1475
1476        let debug = format!("{:?}", pc);
1477        assert!(debug.contains("Zstd"));
1478    }
1479
1480    #[test]
1481    fn test_export_format_debug_clone() {
1482        let ef = ExportFormat::Csv;
1483        let cloned = ef.clone();
1484        assert!(matches!(cloned, ExportFormat::Csv));
1485
1486        let debug = format!("{:?}", ef);
1487        assert!(debug.contains("Csv"));
1488    }
1489
1490    #[test]
1491    fn test_export_config_debug_clone() {
1492        let config = ExportConfig::default();
1493        let cloned = config.clone();
1494        assert_eq!(cloned.decimation, config.decimation);
1495
1496        let debug = format!("{:?}", config);
1497        assert!(debug.contains("ExportConfig"));
1498    }
1499
1500    #[test]
1501    fn test_exporter_debug() {
1502        let exporter = Exporter::new();
1503        let debug = format!("{:?}", exporter);
1504        assert!(debug.contains("Exporter"));
1505    }
1506
1507    #[test]
1508    fn test_trajectory_metadata_debug_clone() {
1509        let meta = TrajectoryMetadata {
1510            name: "test".to_string(),
1511            body_count: 5,
1512            start_time: 0.0,
1513            end_time: 10.0,
1514            timestep: 0.01,
1515            seed: 42,
1516        };
1517        let cloned = meta.clone();
1518        assert_eq!(cloned.name, "test");
1519
1520        let debug = format!("{:?}", meta);
1521        assert!(debug.contains("TrajectoryMetadata"));
1522    }
1523
1524    #[test]
1525    fn test_trajectory_clone_with_positions() {
1526        let mut traj = Trajectory::new();
1527        traj.add_frame(TrajectoryFrame {
1528            time: 0.0,
1529            index: 0,
1530            positions: vec![1.0, 2.0, 3.0],
1531            velocities: vec![],
1532            metrics: SimMetrics::new(),
1533        });
1534
1535        let cloned = traj.clone();
1536        assert_eq!(cloned.len(), 1);
1537        // Verify the positions were cloned correctly
1538        assert!(cloned.frame(0).is_some());
1539    }
1540
1541    #[test]
1542    fn test_time_series_debug_impl() {
1543        let series = TimeSeries::new("test", 10);
1544        let debug = format!("{:?}", series);
1545        assert!(debug.contains("TimeSeries"));
1546    }
1547
1548    #[test]
1549    fn test_time_series_clone_impl() {
1550        let mut series = TimeSeries::new("test", 10);
1551        series.push(1.0, 100.0);
1552        let cloned = series.clone();
1553        assert_eq!(cloned.len(), 1);
1554        assert_eq!(cloned.name(), "test");
1555    }
1556
1557    #[test]
1558    fn test_export_binary_format() {
1559        let dir = tempdir().unwrap();
1560        let path = dir.path().join("test.bin");
1561
1562        let mut traj = Trajectory::new();
1563        traj.add_frame(TrajectoryFrame {
1564            time: 0.0,
1565            index: 0,
1566            positions: vec![1.0],
1567            velocities: vec![0.1],
1568            metrics: SimMetrics::new(),
1569        });
1570
1571        let config = ExportConfig {
1572            format: ExportFormat::Binary,
1573            ..Default::default()
1574        };
1575        let exporter = Exporter::with_config(config);
1576        exporter.export(&traj, &path).unwrap();
1577
1578        assert!(path.exists());
1579    }
1580}