Skip to main content

oxiphysics_io/
simulation_log.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Simulation logging, telemetry, and replay utilities.
5//!
6//! Provides structured logging of per-step simulation data (energy, timing,
7//! body counts), filtering, compression, metrics, snapshotting, and a
8//! ring-buffer streaming log.
9
10#![allow(dead_code)]
11
12use std::collections::HashMap;
13
14// ---------------------------------------------------------------------------
15// SimLogEntry
16// ---------------------------------------------------------------------------
17
18/// A single logged snapshot of simulation state at one timestep.
19#[derive(Debug, Clone, PartialEq)]
20pub struct SimLogEntry {
21    /// Simulation timestep index (0-based).
22    pub timestep: u64,
23    /// Simulation time in seconds.
24    pub sim_time: f64,
25    /// Wall-clock time in seconds since start.
26    pub wall_time: f64,
27    /// Total mechanical energy at this step.
28    pub energy: f64,
29    /// Number of bodies/particles active at this step.
30    pub n_bodies: usize,
31    /// Arbitrary additional fields (name → value).
32    pub custom: HashMap<String, f64>,
33}
34
35impl SimLogEntry {
36    /// Create a new log entry with no custom fields.
37    pub fn new(timestep: u64, sim_time: f64, wall_time: f64, energy: f64, n_bodies: usize) -> Self {
38        Self {
39            timestep,
40            sim_time,
41            wall_time,
42            energy,
43            n_bodies,
44            custom: HashMap::new(),
45        }
46    }
47
48    /// Insert a custom field value.
49    pub fn insert(&mut self, key: impl Into<String>, value: f64) {
50        self.custom.insert(key.into(), value);
51    }
52
53    /// Retrieve a custom field value by name.
54    pub fn get(&self, key: &str) -> Option<f64> {
55        self.custom.get(key).copied()
56    }
57
58    /// Serialise this entry to a CSV row (timestep, sim_time, wall_time,
59    /// energy, n_bodies, then sorted custom keys).
60    pub fn to_csv_row(&self, extra_keys: &[String]) -> String {
61        let mut parts = vec![
62            self.timestep.to_string(),
63            format!("{:.9}", self.sim_time),
64            format!("{:.9}", self.wall_time),
65            format!("{:.9}", self.energy),
66            self.n_bodies.to_string(),
67        ];
68        for key in extra_keys {
69            if let Some(v) = self.custom.get(key) {
70                parts.push(format!("{:.9}", v));
71            } else {
72                parts.push(String::new());
73            }
74        }
75        parts.join(",")
76    }
77}
78
79// ---------------------------------------------------------------------------
80// SimLogger
81// ---------------------------------------------------------------------------
82
83/// Append-only simulation logger with CSV and JSON serialisation.
84#[derive(Debug, Default, Clone)]
85pub struct SimLogger {
86    /// All logged entries in chronological order.
87    pub entries: Vec<SimLogEntry>,
88    /// How many entries to buffer before auto-flushing (0 = manual only).
89    pub flush_interval: usize,
90    /// Buffered output lines not yet "flushed" (written out).
91    buffer: Vec<String>,
92}
93
94impl SimLogger {
95    /// Create a new logger.
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Create a logger with an auto-flush interval.
101    pub fn with_flush_interval(flush_interval: usize) -> Self {
102        Self {
103            flush_interval,
104            ..Default::default()
105        }
106    }
107
108    /// Append a new entry.
109    pub fn append(&mut self, entry: SimLogEntry) {
110        self.entries.push(entry);
111    }
112
113    /// Return the number of logged entries.
114    pub fn len(&self) -> usize {
115        self.entries.len()
116    }
117
118    /// Returns `true` if no entries have been logged.
119    pub fn is_empty(&self) -> bool {
120        self.entries.is_empty()
121    }
122
123    /// Collect all unique custom field keys across all entries (sorted).
124    pub fn all_custom_keys(&self) -> Vec<String> {
125        let mut keys: Vec<String> = self
126            .entries
127            .iter()
128            .flat_map(|e| e.custom.keys().cloned())
129            .collect::<std::collections::HashSet<_>>()
130            .into_iter()
131            .collect();
132        keys.sort();
133        keys
134    }
135
136    /// Serialise all entries to a CSV string.
137    ///
138    /// The first line is a header row.  Custom field columns appear in sorted
139    /// key order.
140    pub fn to_csv(&self) -> String {
141        let keys = self.all_custom_keys();
142        let mut lines = Vec::with_capacity(self.entries.len() + 1);
143        let mut header = ["timestep", "sim_time", "wall_time", "energy", "n_bodies"]
144            .iter()
145            .map(|s| s.to_string())
146            .collect::<Vec<_>>();
147        header.extend(keys.iter().cloned());
148        lines.push(header.join(","));
149        for entry in &self.entries {
150            lines.push(entry.to_csv_row(&keys));
151        }
152        lines.join("\n")
153    }
154
155    /// Serialise all entries to a JSON array string.
156    pub fn to_json(&self) -> String {
157        let items: Vec<String> = self.entries.iter().map(entry_to_json).collect();
158        format!("[{}]", items.join(","))
159    }
160
161    /// Flush buffered lines and return them (clears the buffer).
162    pub fn flush(&mut self) -> Vec<String> {
163        std::mem::take(&mut self.buffer)
164    }
165}
166
167/// Serialise one `SimLogEntry` to a JSON object string.
168fn entry_to_json(e: &SimLogEntry) -> String {
169    let mut parts = vec![
170        format!("\"timestep\":{}", e.timestep),
171        format!("\"sim_time\":{:.9}", e.sim_time),
172        format!("\"wall_time\":{:.9}", e.wall_time),
173        format!("\"energy\":{:.9}", e.energy),
174        format!("\"n_bodies\":{}", e.n_bodies),
175    ];
176    let mut custom_pairs: Vec<(&String, &f64)> = e.custom.iter().collect();
177    custom_pairs.sort_by_key(|(k, _)| k.as_str());
178    for (k, v) in custom_pairs {
179        let esc = k.replace('"', "\\\"");
180        parts.push(format!("\"{}\":{:.9}", esc, v));
181    }
182    format!("{{{}}}", parts.join(","))
183}
184
185// ---------------------------------------------------------------------------
186// EnergyMonitor
187// ---------------------------------------------------------------------------
188
189/// Tracks kinetic and potential energy; detects anomalous jumps.
190#[derive(Debug, Clone, Default)]
191pub struct EnergyMonitor {
192    /// History of (kinetic, potential) energy pairs.
193    pub history: Vec<(f64, f64)>,
194    /// Relative jump threshold; a change > threshold * prev_energy triggers
195    /// an anomaly.
196    pub anomaly_threshold: f64,
197    /// Indices of steps where an anomaly was detected.
198    pub anomaly_steps: Vec<usize>,
199}
200
201impl EnergyMonitor {
202    /// Create a new energy monitor with the given relative threshold.
203    pub fn new(anomaly_threshold: f64) -> Self {
204        Self {
205            anomaly_threshold,
206            ..Default::default()
207        }
208    }
209
210    /// Record a new (kinetic, potential) energy measurement.
211    pub fn record(&mut self, kinetic: f64, potential: f64) {
212        let total = kinetic + potential;
213        if let Some(&(ke_prev, pe_prev)) = self.history.last() {
214            let prev_total = ke_prev + pe_prev;
215            if prev_total.abs() > f64::EPSILON {
216                let rel_change = (total - prev_total).abs() / prev_total.abs();
217                if rel_change > self.anomaly_threshold {
218                    self.anomaly_steps.push(self.history.len());
219                }
220            }
221        }
222        self.history.push((kinetic, potential));
223    }
224
225    /// Total energy at the most recent step.
226    pub fn latest_total(&self) -> Option<f64> {
227        self.history.last().map(|(ke, pe)| ke + pe)
228    }
229
230    /// Mean total energy over all recorded steps.
231    pub fn mean_total(&self) -> f64 {
232        if self.history.is_empty() {
233            return 0.0;
234        }
235        let sum: f64 = self.history.iter().map(|(ke, pe)| ke + pe).sum();
236        sum / self.history.len() as f64
237    }
238
239    /// Returns `true` if any anomaly was detected.
240    pub fn has_anomaly(&self) -> bool {
241        !self.anomaly_steps.is_empty()
242    }
243}
244
245// ---------------------------------------------------------------------------
246// PerformanceLog
247// ---------------------------------------------------------------------------
248
249/// Timing (in seconds) for the major phases of one simulation step.
250#[derive(Debug, Clone, Default, PartialEq)]
251pub struct StepTiming {
252    /// Time spent in broad-phase collision detection.
253    pub broadphase: f64,
254    /// Time spent in narrow-phase collision detection.
255    pub narrowphase: f64,
256    /// Time spent in constraint solve.
257    pub solve: f64,
258    /// Time spent integrating positions/velocities.
259    pub integrate: f64,
260}
261
262impl StepTiming {
263    /// Total time across all phases.
264    pub fn total(&self) -> f64 {
265        self.broadphase + self.narrowphase + self.solve + self.integrate
266    }
267}
268
269/// Accumulates per-step timing data.
270#[derive(Debug, Clone, Default)]
271pub struct PerformanceLog {
272    /// Recorded timings in chronological order.
273    pub timings: Vec<StepTiming>,
274}
275
276impl PerformanceLog {
277    /// Create a new performance log.
278    pub fn new() -> Self {
279        Self::default()
280    }
281
282    /// Append a timing record.
283    pub fn push(&mut self, timing: StepTiming) {
284        self.timings.push(timing);
285    }
286
287    /// Mean total step time across all recorded steps.
288    pub fn mean_total(&self) -> f64 {
289        if self.timings.is_empty() {
290            return 0.0;
291        }
292        let sum: f64 = self
293            .timings
294            .iter()
295            .map(|t| t.total())
296            .collect::<Vec<_>>()
297            .iter()
298            .sum();
299        sum / self.timings.len() as f64
300    }
301
302    /// Maximum total step time.
303    pub fn max_total(&self) -> f64 {
304        self.timings
305            .iter()
306            .map(|t| t.total())
307            .fold(f64::NEG_INFINITY, f64::max)
308    }
309
310    /// Minimum total step time.
311    pub fn min_total(&self) -> f64 {
312        self.timings
313            .iter()
314            .map(|t| t.total())
315            .fold(f64::INFINITY, f64::min)
316    }
317}
318
319// ---------------------------------------------------------------------------
320// LogFilter
321// ---------------------------------------------------------------------------
322
323/// Filters a slice of `SimLogEntry` by time range or custom field predicates.
324#[derive(Debug, Clone, Default)]
325pub struct LogFilter {
326    /// Optional minimum sim_time (inclusive).
327    pub sim_time_min: Option<f64>,
328    /// Optional maximum sim_time (inclusive).
329    pub sim_time_max: Option<f64>,
330    /// Optional minimum energy (inclusive).
331    pub energy_min: Option<f64>,
332    /// Optional maximum energy (inclusive).
333    pub energy_max: Option<f64>,
334    /// Optional minimum n_bodies.
335    pub n_bodies_min: Option<usize>,
336}
337
338impl LogFilter {
339    /// Create an empty filter (accepts everything).
340    pub fn new() -> Self {
341        Self::default()
342    }
343
344    /// Set the sim_time range.
345    pub fn sim_time_range(mut self, min: f64, max: f64) -> Self {
346        self.sim_time_min = Some(min);
347        self.sim_time_max = Some(max);
348        self
349    }
350
351    /// Set the energy range.
352    pub fn energy_range(mut self, min: f64, max: f64) -> Self {
353        self.energy_min = Some(min);
354        self.energy_max = Some(max);
355        self
356    }
357
358    /// Set minimum n_bodies filter.
359    pub fn min_bodies(mut self, n: usize) -> Self {
360        self.n_bodies_min = Some(n);
361        self
362    }
363
364    /// Return all entries from `log` that pass this filter.
365    pub fn apply<'a>(&self, log: &'a [SimLogEntry]) -> Vec<&'a SimLogEntry> {
366        log.iter()
367            .filter(|e| {
368                if let Some(min) = self.sim_time_min
369                    && e.sim_time < min
370                {
371                    return false;
372                }
373                if let Some(max) = self.sim_time_max
374                    && e.sim_time > max
375                {
376                    return false;
377                }
378                if let Some(min) = self.energy_min
379                    && e.energy < min
380                {
381                    return false;
382                }
383                if let Some(max) = self.energy_max
384                    && e.energy > max
385                {
386                    return false;
387                }
388                if let Some(n) = self.n_bodies_min
389                    && e.n_bodies < n
390                {
391                    return false;
392                }
393                true
394            })
395            .collect()
396    }
397}
398
399// ---------------------------------------------------------------------------
400// SimSnapshot
401// ---------------------------------------------------------------------------
402
403/// Version identifier for snapshot format.
404#[derive(Debug, Clone, PartialEq)]
405pub struct SnapshotVersion {
406    /// Major version number.
407    pub major: u32,
408    /// Minor version number.
409    pub minor: u32,
410}
411
412impl SnapshotVersion {
413    /// Current snapshot version.
414    pub const CURRENT: SnapshotVersion = SnapshotVersion { major: 1, minor: 0 };
415}
416
417impl std::fmt::Display for SnapshotVersion {
418    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419        write!(f, "{}.{}", self.major, self.minor)
420    }
421}
422
423/// A full serialisable snapshot of the simulation state.
424#[derive(Debug, Clone)]
425pub struct SimSnapshot {
426    /// Format version for forward-compatibility checks.
427    pub version: SnapshotVersion,
428    /// Simulation time of this snapshot.
429    pub sim_time: f64,
430    /// Positions of all bodies `[[x,y,z\], ...]`.
431    pub positions: Vec<[f64; 3]>,
432    /// Velocities of all bodies `[[vx,vy,vz\], ...]`.
433    pub velocities: Vec<[f64; 3]>,
434    /// Masses of all bodies.
435    pub masses: Vec<f64>,
436    /// Optional metadata key-value pairs.
437    pub metadata: HashMap<String, String>,
438}
439
440impl SimSnapshot {
441    /// Create a new snapshot.
442    pub fn new(
443        sim_time: f64,
444        positions: Vec<[f64; 3]>,
445        velocities: Vec<[f64; 3]>,
446        masses: Vec<f64>,
447    ) -> Self {
448        Self {
449            version: SnapshotVersion::CURRENT,
450            sim_time,
451            positions,
452            velocities,
453            masses,
454            metadata: HashMap::new(),
455        }
456    }
457
458    /// Number of bodies in this snapshot.
459    pub fn n_bodies(&self) -> usize {
460        self.positions.len()
461    }
462
463    /// Serialise to a JSON string.
464    pub fn to_json(&self) -> String {
465        let pos_json = array3_to_json_arr(&self.positions);
466        let vel_json = array3_to_json_arr(&self.velocities);
467        let mass_json = format!(
468            "[{}]",
469            self.masses
470                .iter()
471                .map(|m| format!("{:.9}", m))
472                .collect::<Vec<_>>()
473                .join(",")
474        );
475        let mut meta_parts: Vec<String> = self
476            .metadata
477            .iter()
478            .map(|(k, v)| {
479                let ek = k.replace('"', "\\\"");
480                let ev = v.replace('"', "\\\"");
481                format!("\"{}\":\"{}\"", ek, ev)
482            })
483            .collect();
484        meta_parts.sort();
485        let meta_json = format!("{{{}}}", meta_parts.join(","));
486        format!(
487            "{{\"version\":\"{}\",\"sim_time\":{:.9},\"n_bodies\":{},\"positions\":{},\"velocities\":{},\"masses\":{},\"metadata\":{}}}",
488            self.version,
489            self.sim_time,
490            self.n_bodies(),
491            pos_json,
492            vel_json,
493            mass_json,
494            meta_json,
495        )
496    }
497}
498
499fn array3_to_json_arr(data: &[[f64; 3]]) -> String {
500    let inner: Vec<String> = data
501        .iter()
502        .map(|v| format!("[{:.9},{:.9},{:.9}]", v[0], v[1], v[2]))
503        .collect();
504    format!("[{}]", inner.join(","))
505}
506
507// ---------------------------------------------------------------------------
508// TelemetryStream — ring-buffer streaming log
509// ---------------------------------------------------------------------------
510
511/// Streaming telemetry log that keeps the last `capacity` entries in memory
512/// while also accumulating all entries for bulk export.
513#[derive(Debug, Clone)]
514pub struct TelemetryStream {
515    /// Ring buffer holding the last `capacity` entries.
516    ring: Vec<Option<SimLogEntry>>,
517    /// Next write position in the ring buffer.
518    head: usize,
519    /// Ring buffer capacity.
520    capacity: usize,
521    /// Total number of entries ever appended.
522    total_count: u64,
523    /// All entries ever appended (for full export).
524    all_entries: Vec<SimLogEntry>,
525}
526
527impl TelemetryStream {
528    /// Create a stream with the given ring-buffer capacity.
529    pub fn new(capacity: usize) -> Self {
530        assert!(capacity > 0, "capacity must be > 0");
531        Self {
532            ring: vec![None; capacity],
533            head: 0,
534            capacity,
535            total_count: 0,
536            all_entries: Vec::new(),
537        }
538    }
539
540    /// Append a new entry to the stream.
541    pub fn push(&mut self, entry: SimLogEntry) {
542        self.all_entries.push(entry.clone());
543        self.ring[self.head] = Some(entry);
544        self.head = (self.head + 1) % self.capacity;
545        self.total_count += 1;
546    }
547
548    /// Total number of entries ever pushed.
549    pub fn total_count(&self) -> u64 {
550        self.total_count
551    }
552
553    /// Iterate over the entries currently in the ring buffer (may be < capacity).
554    pub fn ring_iter(&self) -> impl Iterator<Item = &SimLogEntry> {
555        self.ring.iter().filter_map(|e| e.as_ref())
556    }
557
558    /// Number of entries currently in the ring buffer.
559    pub fn ring_len(&self) -> usize {
560        self.ring.iter().filter(|e| e.is_some()).count()
561    }
562
563    /// Return all entries ever pushed.
564    pub fn all(&self) -> &[SimLogEntry] {
565        &self.all_entries
566    }
567}
568
569// ---------------------------------------------------------------------------
570// SimMetrics
571// ---------------------------------------------------------------------------
572
573/// Computes statistical summaries over a named field in a log.
574#[derive(Debug, Clone)]
575pub struct SimMetrics {
576    /// Collected values.
577    values: Vec<f64>,
578}
579
580impl SimMetrics {
581    /// Build metrics for the `energy` field of each entry.
582    pub fn from_energy(entries: &[SimLogEntry]) -> Self {
583        Self {
584            values: entries.iter().map(|e| e.energy).collect(),
585        }
586    }
587
588    /// Build metrics for a named custom field (entries missing the field are
589    /// skipped).
590    pub fn from_custom(entries: &[SimLogEntry], key: &str) -> Self {
591        Self {
592            values: entries.iter().filter_map(|e| e.get(key)).collect(),
593        }
594    }
595
596    /// Build metrics from a raw slice of values.
597    pub fn from_values(values: Vec<f64>) -> Self {
598        Self { values }
599    }
600
601    /// Number of data points.
602    pub fn count(&self) -> usize {
603        self.values.len()
604    }
605
606    /// Mean value (returns `0.0` for empty input).
607    pub fn mean(&self) -> f64 {
608        if self.values.is_empty() {
609            return 0.0;
610        }
611        self.values.iter().copied().sum::<f64>() / self.values.len() as f64
612    }
613
614    /// Population standard deviation.
615    pub fn std_dev(&self) -> f64 {
616        if self.values.len() < 2 {
617            return 0.0;
618        }
619        let m = self.mean();
620        let var =
621            self.values.iter().map(|v| (v - m).powi(2)).sum::<f64>() / self.values.len() as f64;
622        var.sqrt()
623    }
624
625    /// Minimum value.
626    pub fn min(&self) -> f64 {
627        self.values.iter().copied().fold(f64::INFINITY, f64::min)
628    }
629
630    /// Maximum value.
631    pub fn max(&self) -> f64 {
632        self.values
633            .iter()
634            .copied()
635            .fold(f64::NEG_INFINITY, f64::max)
636    }
637
638    /// Median value (returns `0.0` for empty input).
639    pub fn median(&self) -> f64 {
640        if self.values.is_empty() {
641            return 0.0;
642        }
643        let mut sorted = self.values.clone();
644        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
645        let n = sorted.len();
646        if n.is_multiple_of(2) {
647            (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
648        } else {
649            sorted[n / 2]
650        }
651    }
652}
653
654// ---------------------------------------------------------------------------
655// ReplayLog
656// ---------------------------------------------------------------------------
657
658/// Reads back a log and supports time-series reconstruction and interpolation.
659#[derive(Debug, Clone)]
660pub struct ReplayLog {
661    /// Entries sorted by sim_time.
662    entries: Vec<SimLogEntry>,
663}
664
665impl ReplayLog {
666    /// Build a replay log from a vector of entries.  Entries are sorted by
667    /// `sim_time`.
668    pub fn new(mut entries: Vec<SimLogEntry>) -> Self {
669        entries.sort_by(|a, b| {
670            a.sim_time
671                .partial_cmp(&b.sim_time)
672                .unwrap_or(std::cmp::Ordering::Equal)
673        });
674        Self { entries }
675    }
676
677    /// Number of entries.
678    pub fn len(&self) -> usize {
679        self.entries.len()
680    }
681
682    /// Returns `true` if no entries are stored.
683    pub fn is_empty(&self) -> bool {
684        self.entries.is_empty()
685    }
686
687    /// Return the entry at index `i`.
688    pub fn get(&self, i: usize) -> Option<&SimLogEntry> {
689        self.entries.get(i)
690    }
691
692    /// Extract the full time series for `energy`.
693    pub fn energy_series(&self) -> Vec<(f64, f64)> {
694        self.entries
695            .iter()
696            .map(|e| (e.sim_time, e.energy))
697            .collect()
698    }
699
700    /// Linearly interpolate the `energy` field at an arbitrary `t`.
701    ///
702    /// Returns `None` if the log is empty or `t` is outside the covered range.
703    pub fn interpolate_energy(&self, t: f64) -> Option<f64> {
704        if self.entries.is_empty() {
705            return None;
706        }
707        // Clamp to range
708        let first = self
709            .entries
710            .first()
711            .expect("collection should not be empty");
712        let last = self.entries.last().expect("collection should not be empty");
713        if t <= first.sim_time {
714            return Some(first.energy);
715        }
716        if t >= last.sim_time {
717            return Some(last.energy);
718        }
719        // Binary search for bracketing interval
720        let idx = self.entries.partition_point(|e| e.sim_time <= t);
721        let e0 = &self.entries[idx - 1];
722        let e1 = &self.entries[idx];
723        let dt = e1.sim_time - e0.sim_time;
724        if dt.abs() < f64::EPSILON {
725            return Some(e0.energy);
726        }
727        let frac = (t - e0.sim_time) / dt;
728        Some(e0.energy + frac * (e1.energy - e0.energy))
729    }
730
731    /// Iterate over all entries in sim_time order.
732    pub fn iter(&self) -> impl Iterator<Item = &SimLogEntry> {
733        self.entries.iter()
734    }
735}
736
737// ---------------------------------------------------------------------------
738// LogCompression
739// ---------------------------------------------------------------------------
740
741/// Run-length encoded representation of a sequence of f64 values.
742#[derive(Debug, Clone, PartialEq)]
743pub struct RleRun {
744    /// The repeated value.
745    pub value: f64,
746    /// How many times it repeats.
747    pub count: usize,
748}
749
750/// Delta-encoded timestamp sequence (stores differences between consecutive
751/// values rather than absolute values).
752#[derive(Debug, Clone)]
753pub struct DeltaTimestamps {
754    /// The first (absolute) timestamp.
755    pub first: f64,
756    /// Differences between consecutive timestamps.
757    pub deltas: Vec<f64>,
758}
759
760impl DeltaTimestamps {
761    /// Encode a slice of timestamps into delta form.
762    pub fn encode(timestamps: &[f64]) -> Self {
763        if timestamps.is_empty() {
764            return Self {
765                first: 0.0,
766                deltas: Vec::new(),
767            };
768        }
769        let first = timestamps[0];
770        let deltas = timestamps.windows(2).map(|w| w[1] - w[0]).collect();
771        Self { first, deltas }
772    }
773
774    /// Decode back to absolute timestamps.
775    pub fn decode(&self) -> Vec<f64> {
776        let mut out = Vec::with_capacity(self.deltas.len() + 1);
777        out.push(self.first);
778        let mut cur = self.first;
779        for &d in &self.deltas {
780            cur += d;
781            out.push(cur);
782        }
783        out
784    }
785
786    /// Number of timestamps represented.
787    pub fn len(&self) -> usize {
788        self.deltas.len() + 1
789    }
790
791    /// Returns `true` if empty (no timestamps).
792    pub fn is_empty(&self) -> bool {
793        self.deltas.is_empty() && self.first == 0.0
794    }
795}
796
797/// Run-length encode a sequence of f64 values.
798///
799/// Consecutive equal values (bitwise) are collapsed into a single `RleRun`.
800pub fn rle_encode(values: &[f64]) -> Vec<RleRun> {
801    let mut runs = Vec::new();
802    let mut iter = values.iter().copied();
803    let Some(first) = iter.next() else {
804        return runs;
805    };
806    let mut cur = first;
807    let mut count = 1usize;
808    for v in iter {
809        if v.to_bits() == cur.to_bits() {
810            count += 1;
811        } else {
812            runs.push(RleRun { value: cur, count });
813            cur = v;
814            count = 1;
815        }
816    }
817    runs.push(RleRun { value: cur, count });
818    runs
819}
820
821/// Decode a run-length encoded sequence back to flat values.
822pub fn rle_decode(runs: &[RleRun]) -> Vec<f64> {
823    runs.iter()
824        .flat_map(|r| std::iter::repeat_n(r.value, r.count))
825        .collect()
826}
827
828/// Compression statistics for a log field.
829#[derive(Debug, Clone)]
830pub struct LogCompression {
831    /// Original length.
832    pub original_len: usize,
833    /// Number of RLE runs.
834    pub rle_runs: usize,
835    /// Compression ratio (original / runs).
836    pub ratio: f64,
837}
838
839impl LogCompression {
840    /// Analyse compression potential for a sequence of values.
841    pub fn analyse(values: &[f64]) -> Self {
842        let runs = rle_encode(values);
843        let original_len = values.len();
844        let rle_runs = runs.len();
845        let ratio = if rle_runs == 0 {
846            1.0
847        } else {
848            original_len as f64 / rle_runs as f64
849        };
850        Self {
851            original_len,
852            rle_runs,
853            ratio,
854        }
855    }
856}
857
858// ---------------------------------------------------------------------------
859// Tests
860// ---------------------------------------------------------------------------
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    fn make_entry(step: u64, t: f64, energy: f64) -> SimLogEntry {
867        SimLogEntry::new(step, t, t * 0.001, energy, 100)
868    }
869
870    // -- SimLogEntry --
871
872    #[test]
873    fn test_entry_new_fields() {
874        let e = make_entry(5, 0.5, 42.0);
875        assert_eq!(e.timestep, 5);
876        assert!((e.sim_time - 0.5).abs() < 1e-12);
877        assert!((e.energy - 42.0).abs() < 1e-12);
878        assert_eq!(e.n_bodies, 100);
879        assert!(e.custom.is_empty());
880    }
881
882    #[test]
883    fn test_entry_insert_and_get() {
884        let mut e = make_entry(0, 0.0, 0.0);
885        e.insert("temp", 300.0);
886        assert!((e.get("temp").unwrap() - 300.0).abs() < 1e-12);
887        assert!(e.get("missing").is_none());
888    }
889
890    #[test]
891    fn test_entry_to_csv_row_no_custom() {
892        let e = SimLogEntry::new(1, 0.1, 0.0001, 50.0, 10);
893        let row = e.to_csv_row(&[]);
894        assert!(row.starts_with("1,"));
895        assert!(row.contains("50.0") || row.contains("50."));
896    }
897
898    #[test]
899    fn test_entry_to_csv_row_with_custom() {
900        let mut e = make_entry(2, 1.0, 10.0);
901        e.insert("foo", 3.125);
902        let keys = vec!["foo".to_string()];
903        let row = e.to_csv_row(&keys);
904        assert!(row.contains("3.125"));
905    }
906
907    #[test]
908    fn test_entry_to_csv_row_missing_custom_key() {
909        let e = make_entry(0, 0.0, 0.0);
910        let keys = vec!["absent".to_string()];
911        let row = e.to_csv_row(&keys);
912        // Last field should be empty
913        assert!(row.ends_with(','));
914    }
915
916    // -- SimLogger --
917
918    #[test]
919    fn test_logger_append_and_len() {
920        let mut logger = SimLogger::new();
921        assert!(logger.is_empty());
922        logger.append(make_entry(0, 0.0, 100.0));
923        assert_eq!(logger.len(), 1);
924        assert!(!logger.is_empty());
925    }
926
927    #[test]
928    fn test_logger_to_csv_header() {
929        let mut logger = SimLogger::new();
930        logger.append(make_entry(0, 0.0, 1.0));
931        let csv = logger.to_csv();
932        let first_line = csv.lines().next().unwrap();
933        assert!(first_line.contains("timestep"));
934        assert!(first_line.contains("energy"));
935    }
936
937    #[test]
938    fn test_logger_to_csv_row_count() {
939        let mut logger = SimLogger::new();
940        for i in 0..5 {
941            logger.append(make_entry(i, i as f64 * 0.1, 10.0));
942        }
943        let csv = logger.to_csv();
944        let lines: Vec<&str> = csv.lines().collect();
945        assert_eq!(lines.len(), 6); // 1 header + 5 data
946    }
947
948    #[test]
949    fn test_logger_to_json_is_array() {
950        let mut logger = SimLogger::new();
951        logger.append(make_entry(0, 0.0, 5.0));
952        let json = logger.to_json();
953        assert!(json.starts_with('['));
954        assert!(json.ends_with(']'));
955    }
956
957    #[test]
958    fn test_logger_all_custom_keys_sorted() {
959        let mut logger = SimLogger::new();
960        let mut e = make_entry(0, 0.0, 0.0);
961        e.insert("z_key", 1.0);
962        e.insert("a_key", 2.0);
963        logger.append(e);
964        let keys = logger.all_custom_keys();
965        assert_eq!(keys[0], "a_key");
966        assert_eq!(keys[1], "z_key");
967    }
968
969    #[test]
970    fn test_logger_flush_buffer() {
971        let mut logger = SimLogger::new();
972        let flushed = logger.flush();
973        assert!(flushed.is_empty());
974    }
975
976    // -- EnergyMonitor --
977
978    #[test]
979    fn test_energy_monitor_no_anomaly_steady() {
980        let mut mon = EnergyMonitor::new(0.1);
981        for _ in 0..10 {
982            mon.record(50.0, 50.0); // constant total = 100
983        }
984        assert!(!mon.has_anomaly());
985    }
986
987    #[test]
988    fn test_energy_monitor_detects_jump() {
989        let mut mon = EnergyMonitor::new(0.1); // 10% threshold
990        mon.record(50.0, 50.0); // total = 100
991        mon.record(70.0, 80.0); // total = 150 → +50% jump → anomaly
992        assert!(mon.has_anomaly());
993    }
994
995    #[test]
996    fn test_energy_monitor_latest_total() {
997        let mut mon = EnergyMonitor::new(1.0);
998        mon.record(30.0, 20.0);
999        assert!((mon.latest_total().unwrap() - 50.0).abs() < 1e-12);
1000    }
1001
1002    #[test]
1003    fn test_energy_monitor_mean_total() {
1004        let mut mon = EnergyMonitor::new(1.0);
1005        mon.record(10.0, 10.0); // 20
1006        mon.record(20.0, 20.0); // 40
1007        assert!((mon.mean_total() - 30.0).abs() < 1e-12);
1008    }
1009
1010    #[test]
1011    fn test_energy_monitor_empty() {
1012        let mon = EnergyMonitor::new(0.1);
1013        assert!(mon.latest_total().is_none());
1014        assert!((mon.mean_total()).abs() < 1e-12);
1015        assert!(!mon.has_anomaly());
1016    }
1017
1018    // -- PerformanceLog --
1019
1020    #[test]
1021    fn test_perf_log_step_timing_total() {
1022        let t = StepTiming {
1023            broadphase: 0.001,
1024            narrowphase: 0.002,
1025            solve: 0.003,
1026            integrate: 0.001,
1027        };
1028        assert!((t.total() - 0.007).abs() < 1e-12);
1029    }
1030
1031    #[test]
1032    fn test_perf_log_mean_total() {
1033        let mut log = PerformanceLog::new();
1034        log.push(StepTiming {
1035            broadphase: 0.01,
1036            narrowphase: 0.0,
1037            solve: 0.0,
1038            integrate: 0.0,
1039        });
1040        log.push(StepTiming {
1041            broadphase: 0.03,
1042            narrowphase: 0.0,
1043            solve: 0.0,
1044            integrate: 0.0,
1045        });
1046        assert!((log.mean_total() - 0.02).abs() < 1e-12);
1047    }
1048
1049    #[test]
1050    fn test_perf_log_max_min() {
1051        let mut log = PerformanceLog::new();
1052        log.push(StepTiming {
1053            broadphase: 0.005,
1054            narrowphase: 0.0,
1055            solve: 0.0,
1056            integrate: 0.0,
1057        });
1058        log.push(StepTiming {
1059            broadphase: 0.020,
1060            narrowphase: 0.0,
1061            solve: 0.0,
1062            integrate: 0.0,
1063        });
1064        assert!((log.max_total() - 0.020).abs() < 1e-12);
1065        assert!((log.min_total() - 0.005).abs() < 1e-12);
1066    }
1067
1068    #[test]
1069    fn test_perf_log_empty_mean() {
1070        let log = PerformanceLog::new();
1071        assert!((log.mean_total()).abs() < 1e-12);
1072    }
1073
1074    // -- LogFilter --
1075
1076    #[test]
1077    fn test_filter_sim_time_range() {
1078        let entries = vec![
1079            make_entry(0, 0.0, 10.0),
1080            make_entry(1, 1.0, 10.0),
1081            make_entry(2, 2.0, 10.0),
1082        ];
1083        let filter = LogFilter::new().sim_time_range(0.5, 1.5);
1084        let filtered = filter.apply(&entries);
1085        assert_eq!(filtered.len(), 1);
1086        assert!((filtered[0].sim_time - 1.0).abs() < 1e-12);
1087    }
1088
1089    #[test]
1090    fn test_filter_energy_range() {
1091        let entries = vec![
1092            make_entry(0, 0.0, 5.0),
1093            make_entry(1, 1.0, 50.0),
1094            make_entry(2, 2.0, 500.0),
1095        ];
1096        let filter = LogFilter::new().energy_range(10.0, 100.0);
1097        let filtered = filter.apply(&entries);
1098        assert_eq!(filtered.len(), 1);
1099        assert!((filtered[0].energy - 50.0).abs() < 1e-12);
1100    }
1101
1102    #[test]
1103    fn test_filter_min_bodies() {
1104        let mut entries = vec![make_entry(0, 0.0, 10.0)];
1105        entries[0].n_bodies = 5;
1106        let mut e2 = make_entry(1, 1.0, 10.0);
1107        e2.n_bodies = 200;
1108        entries.push(e2);
1109        let filter = LogFilter::new().min_bodies(100);
1110        let filtered = filter.apply(&entries);
1111        assert_eq!(filtered.len(), 1);
1112        assert_eq!(filtered[0].n_bodies, 200);
1113    }
1114
1115    #[test]
1116    fn test_filter_empty_accepts_all() {
1117        let entries: Vec<SimLogEntry> = (0..5).map(|i| make_entry(i, i as f64, 1.0)).collect();
1118        let filter = LogFilter::new();
1119        assert_eq!(filter.apply(&entries).len(), 5);
1120    }
1121
1122    // -- SimSnapshot --
1123
1124    #[test]
1125    fn test_snapshot_n_bodies() {
1126        let snap = SimSnapshot::new(
1127            1.0,
1128            vec![[0.0; 3], [1.0; 3]],
1129            vec![[0.0; 3], [0.0; 3]],
1130            vec![1.0, 2.0],
1131        );
1132        assert_eq!(snap.n_bodies(), 2);
1133    }
1134
1135    #[test]
1136    fn test_snapshot_to_json_contains_version() {
1137        let snap = SimSnapshot::new(0.5, vec![], vec![], vec![]);
1138        let json = snap.to_json();
1139        assert!(json.contains("\"version\""));
1140        assert!(json.contains("1.0"));
1141    }
1142
1143    #[test]
1144    fn test_snapshot_to_json_contains_n_bodies() {
1145        let snap = SimSnapshot::new(0.0, vec![[1.0, 2.0, 3.0]], vec![[0.1, 0.2, 0.3]], vec![5.0]);
1146        let json = snap.to_json();
1147        assert!(json.contains("\"n_bodies\":1"));
1148    }
1149
1150    #[test]
1151    fn test_snapshot_version_display() {
1152        assert_eq!(SnapshotVersion::CURRENT.to_string(), "1.0");
1153    }
1154
1155    // -- TelemetryStream --
1156
1157    #[test]
1158    fn test_telemetry_stream_basic_push() {
1159        let mut stream = TelemetryStream::new(3);
1160        stream.push(make_entry(0, 0.0, 1.0));
1161        assert_eq!(stream.total_count(), 1);
1162        assert_eq!(stream.ring_len(), 1);
1163    }
1164
1165    #[test]
1166    fn test_telemetry_stream_ring_wraps() {
1167        let mut stream = TelemetryStream::new(3);
1168        for i in 0..5u64 {
1169            stream.push(make_entry(i, i as f64, 1.0));
1170        }
1171        assert_eq!(stream.total_count(), 5);
1172        assert_eq!(stream.ring_len(), 3); // only 3 slots
1173    }
1174
1175    #[test]
1176    fn test_telemetry_stream_all_entries() {
1177        let mut stream = TelemetryStream::new(2);
1178        for i in 0..10u64 {
1179            stream.push(make_entry(i, i as f64, 0.0));
1180        }
1181        assert_eq!(stream.all().len(), 10);
1182    }
1183
1184    #[test]
1185    fn test_telemetry_stream_ring_iter() {
1186        let mut stream = TelemetryStream::new(5);
1187        stream.push(make_entry(0, 0.0, 42.0));
1188        let in_ring: Vec<_> = stream.ring_iter().collect();
1189        assert_eq!(in_ring.len(), 1);
1190        assert!((in_ring[0].energy - 42.0).abs() < 1e-12);
1191    }
1192
1193    // -- SimMetrics --
1194
1195    #[test]
1196    fn test_metrics_mean() {
1197        let m = SimMetrics::from_values(vec![1.0, 2.0, 3.0]);
1198        assert!((m.mean() - 2.0).abs() < 1e-12);
1199    }
1200
1201    #[test]
1202    fn test_metrics_std_dev() {
1203        let m = SimMetrics::from_values(vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]);
1204        // Population std ≈ 2.0
1205        assert!((m.std_dev() - 2.0).abs() < 1e-9);
1206    }
1207
1208    #[test]
1209    fn test_metrics_min_max() {
1210        let m = SimMetrics::from_values(vec![3.0, 1.0, 4.0, 1.0, 5.0, 9.0]);
1211        assert!((m.min() - 1.0).abs() < 1e-12);
1212        assert!((m.max() - 9.0).abs() < 1e-12);
1213    }
1214
1215    #[test]
1216    fn test_metrics_median_odd() {
1217        let m = SimMetrics::from_values(vec![5.0, 1.0, 3.0]);
1218        assert!((m.median() - 3.0).abs() < 1e-12);
1219    }
1220
1221    #[test]
1222    fn test_metrics_median_even() {
1223        let m = SimMetrics::from_values(vec![1.0, 2.0, 3.0, 4.0]);
1224        assert!((m.median() - 2.5).abs() < 1e-12);
1225    }
1226
1227    #[test]
1228    fn test_metrics_from_energy() {
1229        let entries: Vec<SimLogEntry> = vec![make_entry(0, 0.0, 10.0), make_entry(1, 1.0, 20.0)];
1230        let m = SimMetrics::from_energy(&entries);
1231        assert_eq!(m.count(), 2);
1232        assert!((m.mean() - 15.0).abs() < 1e-12);
1233    }
1234
1235    #[test]
1236    fn test_metrics_from_custom_key() {
1237        let mut e1 = make_entry(0, 0.0, 0.0);
1238        e1.insert("temp", 100.0);
1239        let mut e2 = make_entry(1, 1.0, 0.0);
1240        e2.insert("temp", 200.0);
1241        let entries = vec![e1, e2];
1242        let m = SimMetrics::from_custom(&entries, "temp");
1243        assert_eq!(m.count(), 2);
1244        assert!((m.mean() - 150.0).abs() < 1e-12);
1245    }
1246
1247    #[test]
1248    fn test_metrics_empty() {
1249        let m = SimMetrics::from_values(vec![]);
1250        assert_eq!(m.count(), 0);
1251        assert!((m.mean()).abs() < 1e-12);
1252        assert!((m.std_dev()).abs() < 1e-12);
1253        assert!((m.median()).abs() < 1e-12);
1254    }
1255
1256    // -- ReplayLog --
1257
1258    #[test]
1259    fn test_replay_sorted_on_construction() {
1260        let entries = vec![
1261            make_entry(2, 2.0, 20.0),
1262            make_entry(0, 0.0, 0.0),
1263            make_entry(1, 1.0, 10.0),
1264        ];
1265        let replay = ReplayLog::new(entries);
1266        assert!((replay.get(0).unwrap().sim_time - 0.0).abs() < 1e-12);
1267        assert!((replay.get(2).unwrap().sim_time - 2.0).abs() < 1e-12);
1268    }
1269
1270    #[test]
1271    fn test_replay_energy_series_len() {
1272        let entries: Vec<_> = (0..5)
1273            .map(|i| make_entry(i, i as f64, i as f64 * 10.0))
1274            .collect();
1275        let replay = ReplayLog::new(entries);
1276        assert_eq!(replay.energy_series().len(), 5);
1277    }
1278
1279    #[test]
1280    fn test_replay_interpolate_energy_midpoint() {
1281        let entries = vec![make_entry(0, 0.0, 0.0), make_entry(1, 2.0, 20.0)];
1282        let replay = ReplayLog::new(entries);
1283        let e = replay.interpolate_energy(1.0).unwrap();
1284        assert!((e - 10.0).abs() < 1e-9);
1285    }
1286
1287    #[test]
1288    fn test_replay_interpolate_clamp_before_start() {
1289        let entries = vec![make_entry(0, 1.0, 5.0), make_entry(1, 2.0, 10.0)];
1290        let replay = ReplayLog::new(entries);
1291        assert!((replay.interpolate_energy(0.0).unwrap() - 5.0).abs() < 1e-12);
1292    }
1293
1294    #[test]
1295    fn test_replay_interpolate_clamp_after_end() {
1296        let entries = vec![make_entry(0, 0.0, 5.0), make_entry(1, 1.0, 10.0)];
1297        let replay = ReplayLog::new(entries);
1298        assert!((replay.interpolate_energy(99.0).unwrap() - 10.0).abs() < 1e-12);
1299    }
1300
1301    #[test]
1302    fn test_replay_empty() {
1303        let replay = ReplayLog::new(vec![]);
1304        assert!(replay.is_empty());
1305        assert!(replay.interpolate_energy(0.5).is_none());
1306    }
1307
1308    // -- LogCompression / RLE --
1309
1310    #[test]
1311    fn test_rle_encode_constant() {
1312        let data = vec![3.125; 5];
1313        let runs = rle_encode(&data);
1314        assert_eq!(runs.len(), 1);
1315        assert_eq!(runs[0].count, 5);
1316        assert!((runs[0].value - 3.125).abs() < 1e-12);
1317    }
1318
1319    #[test]
1320    fn test_rle_decode_roundtrip() {
1321        let data = vec![1.0, 1.0, 2.0, 3.0, 3.0, 3.0];
1322        let runs = rle_encode(&data);
1323        let decoded = rle_decode(&runs);
1324        assert_eq!(decoded, data);
1325    }
1326
1327    #[test]
1328    fn test_rle_encode_all_unique() {
1329        let data = vec![1.0, 2.0, 3.0, 4.0];
1330        let runs = rle_encode(&data);
1331        assert_eq!(runs.len(), 4);
1332    }
1333
1334    #[test]
1335    fn test_rle_encode_empty() {
1336        let runs = rle_encode(&[]);
1337        assert!(runs.is_empty());
1338    }
1339
1340    #[test]
1341    fn test_log_compression_analyse_ratio() {
1342        let data = vec![42.0f64; 100];
1343        let comp = LogCompression::analyse(&data);
1344        assert_eq!(comp.original_len, 100);
1345        assert_eq!(comp.rle_runs, 1);
1346        assert!((comp.ratio - 100.0).abs() < 1e-9);
1347    }
1348
1349    // -- DeltaTimestamps --
1350
1351    #[test]
1352    fn test_delta_timestamps_encode_decode_roundtrip() {
1353        let ts = vec![0.0, 0.1, 0.2, 0.4, 0.7, 1.1];
1354        let enc = DeltaTimestamps::encode(&ts);
1355        let dec = enc.decode();
1356        for (a, b) in ts.iter().zip(dec.iter()) {
1357            assert!((a - b).abs() < 1e-10, "a={a} b={b}");
1358        }
1359    }
1360
1361    #[test]
1362    fn test_delta_timestamps_len() {
1363        let ts = vec![0.0, 1.0, 2.0, 3.0];
1364        let enc = DeltaTimestamps::encode(&ts);
1365        assert_eq!(enc.len(), 4);
1366    }
1367
1368    #[test]
1369    fn test_delta_timestamps_empty() {
1370        let enc = DeltaTimestamps::encode(&[]);
1371        assert_eq!(enc.len(), 1); // first=0, no deltas → decode gives [0.0]
1372    }
1373}