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