Skip to main content

oxiphysics_io/
experimental_data_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Experimental data I/O for the OxiPhysics engine.
5//!
6//! Provides readers and processors for common experimental data formats:
7//! - Time series with metadata (sensor, units, sampling rate), outlier detection
8//! - Digital Image Correlation (DIC) displacement/strain fields
9//! - Particle Image Velocimetry (PIV) velocity fields
10//! - Strain gauge (rosette) data and principal strains
11//! - Dynamic Mechanical Analysis (DMA) frequency sweeps and master curves
12//! - Simulation vs experiment comparison metrics
13
14#![allow(dead_code)]
15#![allow(clippy::too_many_arguments)]
16
17use std::collections::HashMap;
18use std::fmt;
19
20// ---------------------------------------------------------------------------
21// ExperimentalDataset — time series with metadata and outlier flags
22// ---------------------------------------------------------------------------
23
24/// Metadata associated with an experimental time series.
25#[derive(Debug, Clone)]
26pub struct SensorMetadata {
27    /// Sensor identifier string.
28    pub sensor_id: String,
29    /// Physical quantity measured (e.g., "displacement", "force").
30    pub quantity: String,
31    /// Physical unit of the measured values.
32    pub unit: String,
33    /// Nominal sampling rate in Hz.
34    pub sampling_rate_hz: f64,
35    /// Optional location description.
36    pub location: Option<String>,
37    /// Arbitrary key-value pairs for additional metadata.
38    pub extra: HashMap<String, String>,
39}
40
41impl SensorMetadata {
42    /// Create new sensor metadata.
43    pub fn new(
44        sensor_id: impl Into<String>,
45        quantity: impl Into<String>,
46        unit: impl Into<String>,
47        sampling_rate_hz: f64,
48    ) -> Self {
49        Self {
50            sensor_id: sensor_id.into(),
51            quantity: quantity.into(),
52            unit: unit.into(),
53            sampling_rate_hz,
54            location: None,
55            extra: HashMap::new(),
56        }
57    }
58
59    /// Set the location description.
60    pub fn with_location(mut self, location: impl Into<String>) -> Self {
61        self.location = Some(location.into());
62        self
63    }
64
65    /// Insert an extra key-value pair.
66    pub fn with_extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
67        self.extra.insert(key.into(), value.into());
68        self
69    }
70}
71
72/// A single time-series sample with an outlier flag.
73#[derive(Debug, Clone, PartialEq)]
74pub struct DataSample {
75    /// Time stamp in seconds.
76    pub time: f64,
77    /// Measured value.
78    pub value: f64,
79    /// Whether this sample has been flagged as an outlier.
80    pub is_outlier: bool,
81}
82
83impl DataSample {
84    /// Create a new sample (not an outlier).
85    pub fn new(time: f64, value: f64) -> Self {
86        Self {
87            time,
88            value,
89            is_outlier: false,
90        }
91    }
92}
93
94/// Experimental time series dataset.
95#[derive(Debug, Clone)]
96pub struct ExperimentalDataset {
97    /// Sensor metadata.
98    pub metadata: SensorMetadata,
99    /// Ordered samples.
100    pub samples: Vec<DataSample>,
101}
102
103impl ExperimentalDataset {
104    /// Create an empty dataset with the given metadata.
105    pub fn new(metadata: SensorMetadata) -> Self {
106        Self {
107            metadata,
108            samples: Vec::new(),
109        }
110    }
111
112    /// Append a sample.
113    pub fn push(&mut self, time: f64, value: f64) {
114        self.samples.push(DataSample::new(time, value));
115    }
116
117    /// Return the number of samples.
118    pub fn len(&self) -> usize {
119        self.samples.len()
120    }
121
122    /// Return `true` if there are no samples.
123    pub fn is_empty(&self) -> bool {
124        self.samples.is_empty()
125    }
126
127    /// Compute the mean of non-outlier values.
128    pub fn mean(&self) -> f64 {
129        let vals: Vec<f64> = self
130            .samples
131            .iter()
132            .filter(|s| !s.is_outlier)
133            .map(|s| s.value)
134            .collect();
135        if vals.is_empty() {
136            return 0.0;
137        }
138        vals.iter().sum::<f64>() / vals.len() as f64
139    }
140
141    /// Compute the standard deviation of non-outlier values.
142    pub fn std_dev(&self) -> f64 {
143        let vals: Vec<f64> = self
144            .samples
145            .iter()
146            .filter(|s| !s.is_outlier)
147            .map(|s| s.value)
148            .collect();
149        if vals.len() < 2 {
150            return 0.0;
151        }
152        let m = vals.iter().sum::<f64>() / vals.len() as f64;
153        let var = vals.iter().map(|v| (v - m).powi(2)).sum::<f64>() / (vals.len() - 1) as f64;
154        var.sqrt()
155    }
156
157    /// Flag outliers using the IQR method (fence factor `k`, typically 1.5).
158    pub fn flag_outliers_iqr(&mut self, k: f64) {
159        let mut vals: Vec<f64> = self.samples.iter().map(|s| s.value).collect();
160        vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
161        let n = vals.len();
162        if n < 4 {
163            return;
164        }
165        let q1 = percentile(&vals, 25.0);
166        let q3 = percentile(&vals, 75.0);
167        let iqr = q3 - q1;
168        let lo = q1 - k * iqr;
169        let hi = q3 + k * iqr;
170        for s in &mut self.samples {
171            s.is_outlier = s.value < lo || s.value > hi;
172        }
173    }
174
175    /// Flag outliers using the z-score method (threshold `z_thresh`, typically 3.0).
176    pub fn flag_outliers_zscore(&mut self, z_thresh: f64) {
177        let m = self.mean();
178        let sd = self.std_dev();
179        if sd == 0.0 {
180            return;
181        }
182        for s in &mut self.samples {
183            s.is_outlier = ((s.value - m) / sd).abs() > z_thresh;
184        }
185    }
186
187    /// Return only the outlier samples.
188    pub fn outliers(&self) -> Vec<&DataSample> {
189        self.samples.iter().filter(|s| s.is_outlier).collect()
190    }
191
192    /// Parse from CSV text: `time,value` rows.
193    pub fn from_csv(csv: &str, metadata: SensorMetadata) -> Result<Self, String> {
194        let mut ds = Self::new(metadata);
195        for (i, line) in csv.lines().enumerate() {
196            let line = line.trim();
197            if line.is_empty() || line.starts_with('#') {
198                continue;
199            }
200            let parts: Vec<&str> = line.split(',').collect();
201            if parts.len() < 2 {
202                return Err(format!("line {}: expected 2 columns", i + 1));
203            }
204            let t = parts[0]
205                .trim()
206                .parse::<f64>()
207                .map_err(|e| format!("line {}: time parse error: {}", i + 1, e))?;
208            let v = parts[1]
209                .trim()
210                .parse::<f64>()
211                .map_err(|e| format!("line {}: value parse error: {}", i + 1, e))?;
212            ds.push(t, v);
213        }
214        Ok(ds)
215    }
216
217    /// Serialize to CSV text.
218    pub fn to_csv(&self) -> String {
219        let mut out = String::from("# time,value,is_outlier\n");
220        for s in &self.samples {
221            out.push_str(&format!("{},{},{}\n", s.time, s.value, s.is_outlier as u8));
222        }
223        out
224    }
225
226    /// Return min and max of non-outlier values, or `None` if empty.
227    pub fn range(&self) -> Option<(f64, f64)> {
228        let vals: Vec<f64> = self
229            .samples
230            .iter()
231            .filter(|s| !s.is_outlier)
232            .map(|s| s.value)
233            .collect();
234        if vals.is_empty() {
235            return None;
236        }
237        let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
238        let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
239        Some((min, max))
240    }
241
242    /// Downsample by keeping every `n`-th sample.
243    pub fn downsample(&self, n: usize) -> Self {
244        let samples = self
245            .samples
246            .iter()
247            .enumerate()
248            .filter(|(i, _)| i % n.max(1) == 0)
249            .map(|(_, s)| s.clone())
250            .collect();
251        Self {
252            metadata: self.metadata.clone(),
253            samples,
254        }
255    }
256}
257
258/// Compute percentile (0–100) of a sorted slice.
259fn percentile(sorted: &[f64], p: f64) -> f64 {
260    if sorted.is_empty() {
261        return 0.0;
262    }
263    let idx = (p / 100.0 * (sorted.len() - 1) as f64).clamp(0.0, (sorted.len() - 1) as f64);
264    let lo = idx.floor() as usize;
265    let hi = idx.ceil() as usize;
266    let frac = idx - lo as f64;
267    sorted[lo] * (1.0 - frac) + sorted[hi] * frac
268}
269
270// ---------------------------------------------------------------------------
271// DigitalImageCorrelation — DIC displacement/strain field
272// ---------------------------------------------------------------------------
273
274/// A 2-D point in a DIC field.
275#[derive(Debug, Clone, PartialEq)]
276pub struct DicPoint {
277    /// Reference x coordinate (pixels or mm).
278    pub x: f64,
279    /// Reference y coordinate.
280    pub y: f64,
281    /// Displacement in x.
282    pub u: f64,
283    /// Displacement in y.
284    pub v: f64,
285    /// Correlation coefficient (quality metric, 0–1).
286    pub correlation: f64,
287}
288
289impl DicPoint {
290    /// Create a new DIC point.
291    pub fn new(x: f64, y: f64, u: f64, v: f64, correlation: f64) -> Self {
292        Self {
293            x,
294            y,
295            u,
296            v,
297            correlation,
298        }
299    }
300
301    /// Displacement magnitude.
302    pub fn displacement_magnitude(&self) -> f64 {
303        (self.u * self.u + self.v * self.v).sqrt()
304    }
305}
306
307/// Computed strain components at a point.
308#[derive(Debug, Clone, PartialEq)]
309pub struct StrainTensor2D {
310    /// Normal strain in x direction (εxx).
311    pub exx: f64,
312    /// Normal strain in y direction (εyy).
313    pub eyy: f64,
314    /// Shear strain (εxy = γxy / 2).
315    pub exy: f64,
316}
317
318impl StrainTensor2D {
319    /// Principal strains (ε1, ε2) and principal angle θ in radians.
320    pub fn principal_strains(&self) -> (f64, f64, f64) {
321        let avg = (self.exx + self.eyy) / 2.0;
322        let r = ((self.exx - self.eyy).powi(2) / 4.0 + self.exy.powi(2)).sqrt();
323        let e1 = avg + r;
324        let e2 = avg - r;
325        let theta = 0.5 * (2.0 * self.exy).atan2(self.exx - self.eyy);
326        (e1, e2, theta)
327    }
328
329    /// Von Mises equivalent strain.
330    pub fn von_mises(&self) -> f64 {
331        let (e1, e2, _) = self.principal_strains();
332        ((e1 - e2).powi(2) + e2.powi(2) + e1.powi(2)).sqrt() / std::f64::consts::SQRT_2
333    }
334}
335
336/// Digital Image Correlation displacement field reader and processor.
337#[derive(Debug, Clone)]
338pub struct DigitalImageCorrelation {
339    /// DIC data points.
340    pub points: Vec<DicPoint>,
341    /// Minimum accepted correlation coefficient.
342    pub min_correlation: f64,
343}
344
345impl DigitalImageCorrelation {
346    /// Create an empty DIC reader with a given minimum correlation threshold.
347    pub fn new(min_correlation: f64) -> Self {
348        Self {
349            points: Vec::new(),
350            min_correlation,
351        }
352    }
353
354    /// Parse from CSV: `x,y,u,v,correlation` rows.
355    pub fn from_csv(csv: &str, min_correlation: f64) -> Result<Self, String> {
356        let mut dic = Self::new(min_correlation);
357        for (i, line) in csv.lines().enumerate() {
358            let line = line.trim();
359            if line.is_empty() || line.starts_with('#') {
360                continue;
361            }
362            let p: Vec<&str> = line.split(',').collect();
363            if p.len() < 5 {
364                return Err(format!("line {}: expected 5 columns", i + 1));
365            }
366            let parse = |s: &str| s.trim().parse::<f64>().map_err(|e| format!("parse: {}", e));
367            let x = parse(p[0])?;
368            let y = parse(p[1])?;
369            let u = parse(p[2])?;
370            let v = parse(p[3])?;
371            let c = parse(p[4])?;
372            dic.points.push(DicPoint::new(x, y, u, v, c));
373        }
374        Ok(dic)
375    }
376
377    /// Filter out points below the minimum correlation threshold.
378    pub fn filter_by_correlation(&mut self) {
379        self.points
380            .retain(|p| p.correlation >= self.min_correlation);
381    }
382
383    /// Compute strain field using finite differences on a regular grid.
384    ///
385    /// Returns a vector of `(x, y, StrainTensor2D)` for interior points.
386    pub fn compute_strain_field(&self, grid_spacing: f64) -> Vec<(f64, f64, StrainTensor2D)> {
387        if self.points.len() < 4 || grid_spacing <= 0.0 {
388            return Vec::new();
389        }
390        // Build lookup by grid indices (approximate)
391        let mut map: HashMap<(i64, i64), &DicPoint> = HashMap::new();
392        for p in &self.points {
393            let ix = (p.x / grid_spacing).round() as i64;
394            let iy = (p.y / grid_spacing).round() as i64;
395            map.insert((ix, iy), p);
396        }
397        let mut result = Vec::new();
398        for (&(ix, iy), &pt) in &map {
399            let r = map.get(&(ix + 1, iy));
400            let l = map.get(&(ix - 1, iy));
401            let u = map.get(&(ix, iy + 1));
402            let d = map.get(&(ix, iy - 1));
403            let (du_dx, dv_dx) = match (r, l) {
404                (Some(pr), Some(pl)) => (
405                    (pr.u - pl.u) / (2.0 * grid_spacing),
406                    (pr.v - pl.v) / (2.0 * grid_spacing),
407                ),
408                (Some(pr), None) => ((pr.u - pt.u) / grid_spacing, (pr.v - pt.v) / grid_spacing),
409                (None, Some(pl)) => ((pt.u - pl.u) / grid_spacing, (pt.v - pl.v) / grid_spacing),
410                _ => continue,
411            };
412            let (du_dy, dv_dy) = match (u, d) {
413                (Some(pu), Some(pd)) => (
414                    (pu.u - pd.u) / (2.0 * grid_spacing),
415                    (pu.v - pd.v) / (2.0 * grid_spacing),
416                ),
417                (Some(pu), None) => ((pu.u - pt.u) / grid_spacing, (pu.v - pt.v) / grid_spacing),
418                (None, Some(pd)) => ((pt.u - pd.u) / grid_spacing, (pt.v - pd.v) / grid_spacing),
419                _ => continue,
420            };
421            let strain = StrainTensor2D {
422                exx: du_dx,
423                eyy: dv_dy,
424                exy: 0.5 * (du_dy + dv_dx),
425            };
426            result.push((pt.x, pt.y, strain));
427        }
428        result
429    }
430
431    /// Average displacement magnitude across all (valid) points.
432    pub fn mean_displacement(&self) -> f64 {
433        if self.points.is_empty() {
434            return 0.0;
435        }
436        let sum: f64 = self.points.iter().map(|p| p.displacement_magnitude()).sum();
437        sum / self.points.len() as f64
438    }
439
440    /// Maximum displacement magnitude.
441    pub fn max_displacement(&self) -> f64 {
442        self.points
443            .iter()
444            .map(|p| p.displacement_magnitude())
445            .fold(0.0_f64, f64::max)
446    }
447}
448
449// ---------------------------------------------------------------------------
450// PivDataReader — PIV velocity field import
451// ---------------------------------------------------------------------------
452
453/// A single velocity vector in a PIV field.
454#[derive(Debug, Clone, PartialEq)]
455pub struct PivVector {
456    /// x position.
457    pub x: f64,
458    /// y position.
459    pub y: f64,
460    /// x velocity component.
461    pub u: f64,
462    /// v velocity component.
463    pub v: f64,
464    /// Signal-to-noise ratio.
465    pub snr: f64,
466}
467
468impl PivVector {
469    /// Create a new PIV vector.
470    pub fn new(x: f64, y: f64, u: f64, v: f64, snr: f64) -> Self {
471        Self { x, y, u, v, snr }
472    }
473
474    /// Velocity magnitude.
475    pub fn speed(&self) -> f64 {
476        (self.u * self.u + self.v * self.v).sqrt()
477    }
478
479    /// Vorticity (scalar, 2-D: ∂v/∂x − ∂u/∂y) requires neighbors.
480    /// This method returns `(u, v)` as a convenience.
481    pub fn components(&self) -> (f64, f64) {
482        (self.u, self.v)
483    }
484}
485
486/// Vector field statistics for PIV data.
487#[derive(Debug, Clone)]
488pub struct PivStatistics {
489    /// Mean velocity magnitude.
490    pub mean_speed: f64,
491    /// RMS of the fluctuating velocity u'.
492    pub u_rms: f64,
493    /// RMS of the fluctuating velocity v'.
494    pub v_rms: f64,
495    /// Turbulence intensity (TI = sqrt((u_rms²+v_rms²)/2) / mean_speed).
496    pub turbulence_intensity: f64,
497    /// Mean u component.
498    pub mean_u: f64,
499    /// Mean v component.
500    pub mean_v: f64,
501}
502
503/// PIV velocity field reader.
504#[derive(Debug, Clone)]
505pub struct PivDataReader {
506    /// All velocity vectors.
507    pub vectors: Vec<PivVector>,
508    /// Minimum accepted SNR.
509    pub min_snr: f64,
510}
511
512impl PivDataReader {
513    /// Create an empty PIV reader.
514    pub fn new(min_snr: f64) -> Self {
515        Self {
516            vectors: Vec::new(),
517            min_snr,
518        }
519    }
520
521    /// Parse from CSV: `x,y,u,v,snr` rows.
522    pub fn from_csv(csv: &str, min_snr: f64) -> Result<Self, String> {
523        let mut reader = Self::new(min_snr);
524        for (i, line) in csv.lines().enumerate() {
525            let line = line.trim();
526            if line.is_empty() || line.starts_with('#') {
527                continue;
528            }
529            let p: Vec<&str> = line.split(',').collect();
530            if p.len() < 5 {
531                return Err(format!("line {}: expected 5 columns", i + 1));
532            }
533            let parse = |s: &str| s.trim().parse::<f64>().map_err(|e| format!("{}", e));
534            let x = parse(p[0])?;
535            let y = parse(p[1])?;
536            let u = parse(p[2])?;
537            let v = parse(p[3])?;
538            let snr = parse(p[4])?;
539            reader.vectors.push(PivVector::new(x, y, u, v, snr));
540        }
541        Ok(reader)
542    }
543
544    /// Remove vectors with SNR below the threshold.
545    pub fn filter_by_snr(&mut self) {
546        self.vectors.retain(|v| v.snr >= self.min_snr);
547    }
548
549    /// Compute vector field statistics.
550    pub fn statistics(&self) -> PivStatistics {
551        let n = self.vectors.len();
552        if n == 0 {
553            return PivStatistics {
554                mean_speed: 0.0,
555                u_rms: 0.0,
556                v_rms: 0.0,
557                turbulence_intensity: 0.0,
558                mean_u: 0.0,
559                mean_v: 0.0,
560            };
561        }
562        let nf = n as f64;
563        let mean_u = self.vectors.iter().map(|v| v.u).sum::<f64>() / nf;
564        let mean_v = self.vectors.iter().map(|v| v.v).sum::<f64>() / nf;
565        let mean_speed = self.vectors.iter().map(|v| v.speed()).sum::<f64>() / nf;
566        let u_rms = (self
567            .vectors
568            .iter()
569            .map(|v| (v.u - mean_u).powi(2))
570            .sum::<f64>()
571            / nf)
572            .sqrt();
573        let v_rms = (self
574            .vectors
575            .iter()
576            .map(|v| (v.v - mean_v).powi(2))
577            .sum::<f64>()
578            / nf)
579            .sqrt();
580        let ti = if mean_speed > 1e-12 {
581            ((u_rms * u_rms + v_rms * v_rms) / 2.0).sqrt() / mean_speed
582        } else {
583            0.0
584        };
585        PivStatistics {
586            mean_speed,
587            u_rms,
588            v_rms,
589            turbulence_intensity: ti,
590            mean_u,
591            mean_v,
592        }
593    }
594
595    /// Compute vorticity field using central differences on a regular grid.
596    ///
597    /// Returns a vector of `(x, y, vorticity)`.
598    pub fn vorticity_field(&self, grid_spacing: f64) -> Vec<(f64, f64, f64)> {
599        if self.vectors.len() < 4 || grid_spacing <= 0.0 {
600            return Vec::new();
601        }
602        let mut map: HashMap<(i64, i64), &PivVector> = HashMap::new();
603        for vec in &self.vectors {
604            let ix = (vec.x / grid_spacing).round() as i64;
605            let iy = (vec.y / grid_spacing).round() as i64;
606            map.insert((ix, iy), vec);
607        }
608        let mut result = Vec::new();
609        for (&(ix, iy), &pt) in &map {
610            let r = map.get(&(ix + 1, iy));
611            let l = map.get(&(ix - 1, iy));
612            let u = map.get(&(ix, iy + 1));
613            let d = map.get(&(ix, iy - 1));
614            let dv_dx = match (r, l) {
615                (Some(pr), Some(pl)) => (pr.v - pl.v) / (2.0 * grid_spacing),
616                (Some(pr), None) => (pr.v - pt.v) / grid_spacing,
617                (None, Some(pl)) => (pt.v - pl.v) / grid_spacing,
618                _ => continue,
619            };
620            let du_dy = match (u, d) {
621                (Some(pu), Some(pd)) => (pu.u - pd.u) / (2.0 * grid_spacing),
622                (Some(pu), None) => (pu.u - pt.u) / grid_spacing,
623                (None, Some(pd)) => (pt.u - pd.u) / grid_spacing,
624                _ => continue,
625            };
626            result.push((pt.x, pt.y, dv_dx - du_dy));
627        }
628        result
629    }
630}
631
632// ---------------------------------------------------------------------------
633// StrainGaugeReader — rosette gauge data and principal strains
634// ---------------------------------------------------------------------------
635
636/// Rosette strain gauge reading: three gauges at 0°, 45°, 90°.
637#[derive(Debug, Clone, PartialEq)]
638pub struct RosetteReading {
639    /// Strain at 0° gauge (micro-strain).
640    pub e0: f64,
641    /// Strain at 45° gauge (micro-strain).
642    pub e45: f64,
643    /// Strain at 90° gauge (micro-strain).
644    pub e90: f64,
645    /// Timestamp in seconds.
646    pub time: f64,
647}
648
649impl RosetteReading {
650    /// Create a new rosette reading.
651    pub fn new(time: f64, e0: f64, e45: f64, e90: f64) -> Self {
652        Self { e0, e45, e90, time }
653    }
654
655    /// Compute Cartesian strains (εxx, εyy, γxy) from rosette gauges.
656    pub fn cartesian_strains(&self) -> (f64, f64, f64) {
657        let exx = self.e0;
658        let eyy = self.e90;
659        let gamma_xy = 2.0 * self.e45 - self.e0 - self.e90;
660        (exx, eyy, gamma_xy)
661    }
662
663    /// Compute principal strains (ε1, ε2) and principal angle θ (radians).
664    pub fn principal_strains(&self) -> (f64, f64, f64) {
665        let (exx, eyy, gamma_xy) = self.cartesian_strains();
666        let avg = (exx + eyy) / 2.0;
667        let r = ((exx - eyy).powi(2) / 4.0 + (gamma_xy / 2.0).powi(2)).sqrt();
668        let e1 = avg + r;
669        let e2 = avg - r;
670        let theta = 0.5 * gamma_xy.atan2(exx - eyy);
671        (e1, e2, theta)
672    }
673
674    /// Mohr circle: center and radius.
675    pub fn mohr_circle(&self) -> (f64, f64) {
676        let (exx, eyy, gamma_xy) = self.cartesian_strains();
677        let center = (exx + eyy) / 2.0;
678        let radius = ((exx - eyy).powi(2) / 4.0 + (gamma_xy / 2.0).powi(2)).sqrt();
679        (center, radius)
680    }
681
682    /// Von Mises strain.
683    pub fn von_mises(&self) -> f64 {
684        let (e1, e2, _) = self.principal_strains();
685        ((e1.powi(2) - e1 * e2 + e2.powi(2)) * 2.0 / 3.0).sqrt()
686    }
687}
688
689/// Reader for strain gauge (rosette) data.
690#[derive(Debug, Clone)]
691pub struct StrainGaugeReader {
692    /// All rosette readings in time order.
693    pub readings: Vec<RosetteReading>,
694    /// Gauge factor (default 2.0).
695    pub gauge_factor: f64,
696}
697
698impl StrainGaugeReader {
699    /// Create an empty reader.
700    pub fn new(gauge_factor: f64) -> Self {
701        Self {
702            readings: Vec::new(),
703            gauge_factor,
704        }
705    }
706
707    /// Append a reading.
708    pub fn push(&mut self, time: f64, e0: f64, e45: f64, e90: f64) {
709        self.readings.push(RosetteReading::new(time, e0, e45, e90));
710    }
711
712    /// Parse from CSV: `time,e0,e45,e90` rows.
713    pub fn from_csv(csv: &str, gauge_factor: f64) -> Result<Self, String> {
714        let mut reader = Self::new(gauge_factor);
715        for (i, line) in csv.lines().enumerate() {
716            let line = line.trim();
717            if line.is_empty() || line.starts_with('#') {
718                continue;
719            }
720            let p: Vec<&str> = line.split(',').collect();
721            if p.len() < 4 {
722                return Err(format!("line {}: need 4 columns", i + 1));
723            }
724            let parse = |s: &str| s.trim().parse::<f64>().map_err(|e| format!("{}", e));
725            let t = parse(p[0])?;
726            let e0 = parse(p[1])?;
727            let e45 = parse(p[2])?;
728            let e90 = parse(p[3])?;
729            reader.push(t, e0, e45, e90);
730        }
731        Ok(reader)
732    }
733
734    /// Compute peak principal strain across all readings.
735    pub fn peak_principal_strain(&self) -> f64 {
736        self.readings
737            .iter()
738            .map(|r| {
739                let (e1, e2, _) = r.principal_strains();
740                e1.abs().max(e2.abs())
741            })
742            .fold(0.0_f64, f64::max)
743    }
744
745    /// Mean von Mises strain.
746    pub fn mean_von_mises(&self) -> f64 {
747        if self.readings.is_empty() {
748            return 0.0;
749        }
750        self.readings.iter().map(|r| r.von_mises()).sum::<f64>() / self.readings.len() as f64
751    }
752
753    /// Serialize to CSV.
754    pub fn to_csv(&self) -> String {
755        let mut out = String::from("# time,e0,e45,e90\n");
756        for r in &self.readings {
757            out.push_str(&format!("{},{},{},{}\n", r.time, r.e0, r.e45, r.e90));
758        }
759        out
760    }
761}
762
763// ---------------------------------------------------------------------------
764// DmaDataReader — DMA frequency sweep
765// ---------------------------------------------------------------------------
766
767/// A single DMA data point at a given frequency.
768#[derive(Debug, Clone, PartialEq)]
769pub struct DmaPoint {
770    /// Frequency in Hz.
771    pub frequency_hz: f64,
772    /// Storage modulus E' (Pa).
773    pub storage_modulus: f64,
774    /// Loss modulus E'' (Pa).
775    pub loss_modulus: f64,
776    /// Temperature in °C.
777    pub temperature_c: f64,
778}
779
780impl DmaPoint {
781    /// Create a DMA point.
782    pub fn new(
783        frequency_hz: f64,
784        storage_modulus: f64,
785        loss_modulus: f64,
786        temperature_c: f64,
787    ) -> Self {
788        Self {
789            frequency_hz,
790            storage_modulus,
791            loss_modulus,
792            temperature_c,
793        }
794    }
795
796    /// Loss tangent (tan δ = E'' / E').
797    pub fn tan_delta(&self) -> f64 {
798        if self.storage_modulus.abs() < 1e-30 {
799            return 0.0;
800        }
801        self.loss_modulus / self.storage_modulus
802    }
803
804    /// Complex modulus magnitude |E*| = √(E'² + E''²).
805    pub fn complex_modulus_magnitude(&self) -> f64 {
806        (self.storage_modulus.powi(2) + self.loss_modulus.powi(2)).sqrt()
807    }
808}
809
810/// Master curve shift factor for time-temperature superposition.
811#[derive(Debug, Clone)]
812pub struct ShiftFactor {
813    /// Temperature in °C.
814    pub temperature_c: f64,
815    /// Log₁₀ horizontal shift factor aT.
816    pub log_at: f64,
817}
818
819/// DMA frequency sweep reader.
820#[derive(Debug, Clone)]
821pub struct DmaDataReader {
822    /// DMA data points.
823    pub points: Vec<DmaPoint>,
824    /// Reference temperature for master curve (°C).
825    pub reference_temperature_c: f64,
826}
827
828impl DmaDataReader {
829    /// Create an empty DMA reader.
830    pub fn new(reference_temperature_c: f64) -> Self {
831        Self {
832            points: Vec::new(),
833            reference_temperature_c,
834        }
835    }
836
837    /// Append a data point.
838    pub fn push(
839        &mut self,
840        frequency_hz: f64,
841        storage_modulus: f64,
842        loss_modulus: f64,
843        temperature_c: f64,
844    ) {
845        self.points.push(DmaPoint::new(
846            frequency_hz,
847            storage_modulus,
848            loss_modulus,
849            temperature_c,
850        ));
851    }
852
853    /// Parse from CSV: `frequency_hz,storage_modulus,loss_modulus,temperature_c` rows.
854    pub fn from_csv(csv: &str, reference_temperature_c: f64) -> Result<Self, String> {
855        let mut reader = Self::new(reference_temperature_c);
856        for (i, line) in csv.lines().enumerate() {
857            let line = line.trim();
858            if line.is_empty() || line.starts_with('#') {
859                continue;
860            }
861            let p: Vec<&str> = line.split(',').collect();
862            if p.len() < 4 {
863                return Err(format!("line {}: need 4 columns", i + 1));
864            }
865            let parse = |s: &str| s.trim().parse::<f64>().map_err(|e| format!("{}", e));
866            let f = parse(p[0])?;
867            let ep = parse(p[1])?;
868            let epp = parse(p[2])?;
869            let t = parse(p[3])?;
870            reader.push(f, ep, epp, t);
871        }
872        Ok(reader)
873    }
874
875    /// Mean tan δ across all points.
876    pub fn mean_tan_delta(&self) -> f64 {
877        if self.points.is_empty() {
878            return 0.0;
879        }
880        self.points.iter().map(|p| p.tan_delta()).sum::<f64>() / self.points.len() as f64
881    }
882
883    /// Maximum storage modulus.
884    pub fn max_storage_modulus(&self) -> f64 {
885        self.points
886            .iter()
887            .map(|p| p.storage_modulus)
888            .fold(0.0_f64, f64::max)
889    }
890
891    /// Compute WLF shift factors for master curve construction.
892    ///
893    /// Uses the WLF equation: log(aT) = -C1*(T - Tref) / (C2 + T - Tref).
894    pub fn wlf_shift_factors(&self, c1: f64, c2: f64) -> Vec<ShiftFactor> {
895        let tref = self.reference_temperature_c;
896        let temps: Vec<f64> = {
897            let mut ts: Vec<f64> = self.points.iter().map(|p| p.temperature_c).collect();
898            ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
899            ts.dedup_by(|a, b| (*a - *b).abs() < 0.01);
900            ts
901        };
902        temps
903            .iter()
904            .map(|&t| {
905                let dt = t - tref;
906                let log_at = if (c2 + dt).abs() > 1e-12 {
907                    -c1 * dt / (c2 + dt)
908                } else {
909                    0.0
910                };
911                ShiftFactor {
912                    temperature_c: t,
913                    log_at,
914                }
915            })
916            .collect()
917    }
918
919    /// Build master curve: returns `(log10(reduced_freq), E')` pairs.
920    pub fn master_curve_storage(&self, c1: f64, c2: f64) -> Vec<(f64, f64)> {
921        let shifts = self.wlf_shift_factors(c1, c2);
922        let shift_map: HashMap<i64, f64> = shifts
923            .iter()
924            .map(|sf| {
925                let key = (sf.temperature_c * 100.0).round() as i64;
926                (key, sf.log_at)
927            })
928            .collect();
929        let mut curve: Vec<(f64, f64)> = self
930            .points
931            .iter()
932            .map(|p| {
933                let key = (p.temperature_c * 100.0).round() as i64;
934                let log_at = shift_map.get(&key).copied().unwrap_or(0.0);
935                let log_f_reduced = p.frequency_hz.log10() + log_at;
936                (log_f_reduced, p.storage_modulus)
937            })
938            .collect();
939        curve.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
940        curve
941    }
942
943    /// Build master curve: returns `(log10(reduced_freq), tan_delta)` pairs.
944    pub fn master_curve_tan_delta(&self, c1: f64, c2: f64) -> Vec<(f64, f64)> {
945        let shifts = self.wlf_shift_factors(c1, c2);
946        let shift_map: HashMap<i64, f64> = shifts
947            .iter()
948            .map(|sf| {
949                let key = (sf.temperature_c * 100.0).round() as i64;
950                (key, sf.log_at)
951            })
952            .collect();
953        let mut curve: Vec<(f64, f64)> = self
954            .points
955            .iter()
956            .map(|p| {
957                let key = (p.temperature_c * 100.0).round() as i64;
958                let log_at = shift_map.get(&key).copied().unwrap_or(0.0);
959                let log_f_reduced = p.frequency_hz.log10() + log_at;
960                (log_f_reduced, p.tan_delta())
961            })
962            .collect();
963        curve.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
964        curve
965    }
966
967    /// Filter points by temperature range \[t_min, t_max\].
968    pub fn filter_by_temperature(&self, t_min: f64, t_max: f64) -> Vec<&DmaPoint> {
969        self.points
970            .iter()
971            .filter(|p| p.temperature_c >= t_min && p.temperature_c <= t_max)
972            .collect()
973    }
974}
975
976// ---------------------------------------------------------------------------
977// ExperimentalComparison — simulation vs experiment metrics
978// ---------------------------------------------------------------------------
979
980/// Comparison error type.
981#[derive(Debug, Clone, Copy, PartialEq, Eq)]
982pub enum ComparisonError {
983    /// Input slices have different lengths.
984    LengthMismatch,
985    /// Input slices are empty.
986    EmptyData,
987    /// Division by zero in normalization.
988    ZeroDenominator,
989}
990
991impl fmt::Display for ComparisonError {
992    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
993        match self {
994            Self::LengthMismatch => write!(f, "slice lengths do not match"),
995            Self::EmptyData => write!(f, "empty data"),
996            Self::ZeroDenominator => write!(f, "zero denominator in normalization"),
997        }
998    }
999}
1000
1001/// Collection of comparison metrics between a simulation and experiment.
1002#[derive(Debug, Clone)]
1003pub struct ComparisonMetrics {
1004    /// Root mean square error.
1005    pub rmse: f64,
1006    /// Normalized RMSE (RMSE / range of experimental data).
1007    pub nrmse: f64,
1008    /// Pearson correlation coefficient.
1009    pub correlation: f64,
1010    /// Mean absolute error.
1011    pub mae: f64,
1012    /// Maximum absolute error.
1013    pub max_error: f64,
1014    /// R² (coefficient of determination).
1015    pub r_squared: f64,
1016}
1017
1018/// Simulation vs experiment comparison tool.
1019#[derive(Debug, Clone)]
1020pub struct ExperimentalComparison {
1021    /// Experimental (reference) values.
1022    pub experimental: Vec<f64>,
1023    /// Simulation (predicted) values.
1024    pub simulation: Vec<f64>,
1025}
1026
1027impl ExperimentalComparison {
1028    /// Create a new comparison.
1029    pub fn new(experimental: Vec<f64>, simulation: Vec<f64>) -> Self {
1030        Self {
1031            experimental,
1032            simulation,
1033        }
1034    }
1035
1036    /// Compute RMSE.
1037    pub fn rmse(&self) -> Result<f64, ComparisonError> {
1038        let (exp, sim) = self.check_lengths()?;
1039        let mse = exp
1040            .iter()
1041            .zip(sim)
1042            .map(|(e, s)| (e - s).powi(2))
1043            .sum::<f64>()
1044            / exp.len() as f64;
1045        Ok(mse.sqrt())
1046    }
1047
1048    /// Compute normalized RMSE (divided by the experimental data range).
1049    pub fn nrmse(&self) -> Result<f64, ComparisonError> {
1050        let rmse = self.rmse()?;
1051        let min = self
1052            .experimental
1053            .iter()
1054            .cloned()
1055            .fold(f64::INFINITY, f64::min);
1056        let max = self
1057            .experimental
1058            .iter()
1059            .cloned()
1060            .fold(f64::NEG_INFINITY, f64::max);
1061        let range = max - min;
1062        if range.abs() < 1e-30 {
1063            return Err(ComparisonError::ZeroDenominator);
1064        }
1065        Ok(rmse / range)
1066    }
1067
1068    /// Compute Pearson correlation coefficient.
1069    pub fn correlation(&self) -> Result<f64, ComparisonError> {
1070        let (exp, sim) = self.check_lengths()?;
1071        let n = exp.len() as f64;
1072        let me = exp.iter().sum::<f64>() / n;
1073        let ms = sim.iter().sum::<f64>() / n;
1074        let cov = exp
1075            .iter()
1076            .zip(sim)
1077            .map(|(e, s)| (e - me) * (s - ms))
1078            .sum::<f64>()
1079            / n;
1080        let std_e = (exp.iter().map(|e| (e - me).powi(2)).sum::<f64>() / n).sqrt();
1081        let std_s = (sim.iter().map(|s| (s - ms).powi(2)).sum::<f64>() / n).sqrt();
1082        if std_e < 1e-30 || std_s < 1e-30 {
1083            return Err(ComparisonError::ZeroDenominator);
1084        }
1085        Ok(cov / (std_e * std_s))
1086    }
1087
1088    /// Compute mean absolute error.
1089    pub fn mae(&self) -> Result<f64, ComparisonError> {
1090        let (exp, sim) = self.check_lengths()?;
1091        Ok(exp.iter().zip(sim).map(|(e, s)| (e - s).abs()).sum::<f64>() / exp.len() as f64)
1092    }
1093
1094    /// Compute maximum absolute error.
1095    pub fn max_error(&self) -> Result<f64, ComparisonError> {
1096        let (exp, sim) = self.check_lengths()?;
1097        Ok(exp
1098            .iter()
1099            .zip(sim)
1100            .map(|(e, s)| (e - s).abs())
1101            .fold(0.0_f64, f64::max))
1102    }
1103
1104    /// Compute R² (coefficient of determination).
1105    pub fn r_squared(&self) -> Result<f64, ComparisonError> {
1106        let (exp, sim) = self.check_lengths()?;
1107        let n = exp.len() as f64;
1108        let me = exp.iter().sum::<f64>() / n;
1109        let ss_tot = exp.iter().map(|e| (e - me).powi(2)).sum::<f64>();
1110        let ss_res = exp
1111            .iter()
1112            .zip(sim)
1113            .map(|(e, s)| (e - s).powi(2))
1114            .sum::<f64>();
1115        if ss_tot.abs() < 1e-30 {
1116            return Err(ComparisonError::ZeroDenominator);
1117        }
1118        Ok(1.0 - ss_res / ss_tot)
1119    }
1120
1121    /// Compute all metrics at once.
1122    pub fn all_metrics(&self) -> Result<ComparisonMetrics, ComparisonError> {
1123        Ok(ComparisonMetrics {
1124            rmse: self.rmse()?,
1125            nrmse: self.nrmse().unwrap_or(0.0),
1126            correlation: self.correlation().unwrap_or(0.0),
1127            mae: self.mae()?,
1128            max_error: self.max_error()?,
1129            r_squared: self.r_squared().unwrap_or(0.0),
1130        })
1131    }
1132
1133    fn check_lengths(&self) -> Result<(&[f64], &[f64]), ComparisonError> {
1134        if self.experimental.is_empty() || self.simulation.is_empty() {
1135            return Err(ComparisonError::EmptyData);
1136        }
1137        if self.experimental.len() != self.simulation.len() {
1138            return Err(ComparisonError::LengthMismatch);
1139        }
1140        Ok((&self.experimental, &self.simulation))
1141    }
1142
1143    /// Bias (mean of simulation - experiment).
1144    pub fn bias(&self) -> Result<f64, ComparisonError> {
1145        let (exp, sim) = self.check_lengths()?;
1146        let n = exp.len() as f64;
1147        Ok(sim.iter().zip(exp).map(|(s, e)| s - e).sum::<f64>() / n)
1148    }
1149
1150    /// Relative error for each point: (sim - exp) / exp.
1151    pub fn relative_errors(&self) -> Result<Vec<f64>, ComparisonError> {
1152        let (exp, sim) = self.check_lengths()?;
1153        let v: Vec<f64> = exp
1154            .iter()
1155            .zip(sim)
1156            .map(|(e, s)| if e.abs() < 1e-30 { 0.0 } else { (s - e) / e })
1157            .collect();
1158        Ok(v)
1159    }
1160}
1161
1162// ---------------------------------------------------------------------------
1163// Tests
1164// ---------------------------------------------------------------------------
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169
1170    // --- ExperimentalDataset ---
1171
1172    #[test]
1173    fn test_dataset_push_and_len() {
1174        let meta = SensorMetadata::new("S1", "force", "N", 100.0);
1175        let mut ds = ExperimentalDataset::new(meta);
1176        ds.push(0.0, 1.0);
1177        ds.push(0.01, 2.0);
1178        assert_eq!(ds.len(), 2);
1179        assert!(!ds.is_empty());
1180    }
1181
1182    #[test]
1183    fn test_dataset_mean() {
1184        let meta = SensorMetadata::new("S1", "force", "N", 100.0);
1185        let mut ds = ExperimentalDataset::new(meta);
1186        ds.push(0.0, 1.0);
1187        ds.push(0.1, 3.0);
1188        assert!((ds.mean() - 2.0).abs() < 1e-10);
1189    }
1190
1191    #[test]
1192    fn test_dataset_std_dev() {
1193        let meta = SensorMetadata::new("S1", "displacement", "mm", 50.0);
1194        let mut ds = ExperimentalDataset::new(meta);
1195        ds.push(0.0, 2.0);
1196        ds.push(0.1, 4.0);
1197        let sd = ds.std_dev();
1198        assert!((sd - std::f64::consts::SQRT_2).abs() < 1e-10);
1199    }
1200
1201    #[test]
1202    fn test_dataset_flag_outliers_zscore() {
1203        let meta = SensorMetadata::new("S1", "temp", "C", 1.0);
1204        let mut ds = ExperimentalDataset::new(meta);
1205        for i in 0..20 {
1206            ds.push(i as f64, i as f64);
1207        }
1208        ds.push(20.0, 1000.0); // outlier
1209        ds.flag_outliers_zscore(2.5);
1210        let outliers = ds.outliers();
1211        assert!(!outliers.is_empty());
1212    }
1213
1214    #[test]
1215    fn test_dataset_flag_outliers_iqr() {
1216        let meta = SensorMetadata::new("S1", "pressure", "Pa", 10.0);
1217        let mut ds = ExperimentalDataset::new(meta);
1218        for i in 0..20 {
1219            ds.push(i as f64, i as f64);
1220        }
1221        ds.push(20.0, 9999.0);
1222        ds.flag_outliers_iqr(1.5);
1223        assert!(!ds.outliers().is_empty());
1224    }
1225
1226    #[test]
1227    fn test_dataset_from_csv() {
1228        let csv = "0.0,1.0\n0.1,2.0\n0.2,3.0\n";
1229        let meta = SensorMetadata::new("S1", "x", "m", 10.0);
1230        let ds = ExperimentalDataset::from_csv(csv, meta).unwrap();
1231        assert_eq!(ds.len(), 3);
1232        assert!((ds.mean() - 2.0).abs() < 1e-10);
1233    }
1234
1235    #[test]
1236    fn test_dataset_to_csv_roundtrip() {
1237        let meta = SensorMetadata::new("S1", "x", "m", 10.0);
1238        let mut ds = ExperimentalDataset::new(meta.clone());
1239        ds.push(0.0, 1.5);
1240        ds.push(1.0, 2.5);
1241        let csv = ds.to_csv();
1242        let ds2 = ExperimentalDataset::from_csv(
1243            &csv.lines()
1244                .filter(|l| !l.starts_with('#'))
1245                .collect::<Vec<_>>()
1246                .join("\n"),
1247            meta,
1248        )
1249        .unwrap();
1250        assert_eq!(ds2.len(), 2);
1251    }
1252
1253    #[test]
1254    fn test_dataset_range() {
1255        let meta = SensorMetadata::new("S1", "x", "m", 10.0);
1256        let mut ds = ExperimentalDataset::new(meta);
1257        ds.push(0.0, 5.0);
1258        ds.push(0.1, 10.0);
1259        ds.push(0.2, 3.0);
1260        let (min, max) = ds.range().unwrap();
1261        assert!((min - 3.0).abs() < 1e-10);
1262        assert!((max - 10.0).abs() < 1e-10);
1263    }
1264
1265    #[test]
1266    fn test_dataset_downsample() {
1267        let meta = SensorMetadata::new("S1", "x", "m", 100.0);
1268        let mut ds = ExperimentalDataset::new(meta);
1269        for i in 0..10 {
1270            ds.push(i as f64, i as f64);
1271        }
1272        let ds2 = ds.downsample(2);
1273        assert_eq!(ds2.len(), 5);
1274    }
1275
1276    #[test]
1277    fn test_sensor_metadata_builder() {
1278        let meta = SensorMetadata::new("A1", "strain", "με", 1000.0)
1279            .with_location("beam top")
1280            .with_extra("calibration", "2026-01");
1281        assert_eq!(meta.location.unwrap(), "beam top");
1282        assert_eq!(meta.extra["calibration"], "2026-01");
1283    }
1284
1285    // --- DIC ---
1286
1287    #[test]
1288    fn test_dic_point_displacement() {
1289        let p = DicPoint::new(0.0, 0.0, 3.0, 4.0, 0.95);
1290        assert!((p.displacement_magnitude() - 5.0).abs() < 1e-10);
1291    }
1292
1293    #[test]
1294    fn test_dic_from_csv() {
1295        let csv = "0,0,1,2,0.9\n1,0,3,4,0.85\n";
1296        let dic = DigitalImageCorrelation::from_csv(csv, 0.8).unwrap();
1297        assert_eq!(dic.points.len(), 2);
1298    }
1299
1300    #[test]
1301    fn test_dic_filter_by_correlation() {
1302        let csv = "0,0,1,2,0.9\n1,0,3,4,0.3\n";
1303        let mut dic = DigitalImageCorrelation::from_csv(csv, 0.8).unwrap();
1304        dic.filter_by_correlation();
1305        assert_eq!(dic.points.len(), 1);
1306    }
1307
1308    #[test]
1309    fn test_dic_mean_displacement() {
1310        let mut dic = DigitalImageCorrelation::new(0.5);
1311        dic.points.push(DicPoint::new(0.0, 0.0, 3.0, 4.0, 0.9)); // magnitude 5
1312        dic.points.push(DicPoint::new(1.0, 0.0, 0.0, 0.0, 0.9)); // magnitude 0
1313        assert!((dic.mean_displacement() - 2.5).abs() < 1e-10);
1314    }
1315
1316    #[test]
1317    fn test_dic_max_displacement() {
1318        let mut dic = DigitalImageCorrelation::new(0.5);
1319        dic.points.push(DicPoint::new(0.0, 0.0, 3.0, 4.0, 0.9));
1320        dic.points.push(DicPoint::new(1.0, 0.0, 1.0, 0.0, 0.9));
1321        assert!((dic.max_displacement() - 5.0).abs() < 1e-10);
1322    }
1323
1324    #[test]
1325    fn test_dic_compute_strain_field() {
1326        let mut dic = DigitalImageCorrelation::new(0.5);
1327        let gs = 1.0;
1328        // Create a 3x3 grid so central-difference neighbors exist in both axes
1329        for iy in 0..3i64 {
1330            for ix in 0..3i64 {
1331                let x = ix as f64 * gs;
1332                let y = iy as f64 * gs;
1333                dic.points
1334                    .push(DicPoint::new(x, y, x * 0.01, y * 0.005, 0.9));
1335            }
1336        }
1337        let strains = dic.compute_strain_field(gs);
1338        assert!(!strains.is_empty());
1339    }
1340
1341    #[test]
1342    fn test_strain_tensor_principal() {
1343        let st = StrainTensor2D {
1344            exx: 0.001,
1345            eyy: 0.0,
1346            exy: 0.0,
1347        };
1348        let (e1, e2, _) = st.principal_strains();
1349        assert!(e1 > e2);
1350    }
1351
1352    #[test]
1353    fn test_strain_tensor_von_mises() {
1354        let st = StrainTensor2D {
1355            exx: 0.001,
1356            eyy: 0.0,
1357            exy: 0.0,
1358        };
1359        assert!(st.von_mises() > 0.0);
1360    }
1361
1362    // --- PIV ---
1363
1364    #[test]
1365    fn test_piv_from_csv() {
1366        let csv = "0,0,1.0,0.5,10.0\n1,0,1.5,0.3,12.0\n";
1367        let piv = PivDataReader::from_csv(csv, 5.0).unwrap();
1368        assert_eq!(piv.vectors.len(), 2);
1369    }
1370
1371    #[test]
1372    fn test_piv_filter_snr() {
1373        let csv = "0,0,1.0,0.5,10.0\n1,0,1.5,0.3,2.0\n";
1374        let mut piv = PivDataReader::from_csv(csv, 5.0).unwrap();
1375        piv.filter_by_snr();
1376        assert_eq!(piv.vectors.len(), 1);
1377    }
1378
1379    #[test]
1380    fn test_piv_statistics_basic() {
1381        let mut piv = PivDataReader::new(0.0);
1382        piv.vectors.push(PivVector::new(0.0, 0.0, 1.0, 0.0, 10.0));
1383        piv.vectors.push(PivVector::new(1.0, 0.0, 1.0, 0.0, 10.0));
1384        let stats = piv.statistics();
1385        assert!((stats.mean_u - 1.0).abs() < 1e-10);
1386        assert!((stats.u_rms).abs() < 1e-10);
1387    }
1388
1389    #[test]
1390    fn test_piv_turbulence_intensity() {
1391        let mut piv = PivDataReader::new(0.0);
1392        piv.vectors.push(PivVector::new(0.0, 0.0, 1.0, 0.0, 10.0));
1393        piv.vectors.push(PivVector::new(1.0, 0.0, 2.0, 0.0, 10.0));
1394        let stats = piv.statistics();
1395        assert!(stats.turbulence_intensity >= 0.0);
1396    }
1397
1398    #[test]
1399    fn test_piv_vorticity_field() {
1400        let mut piv = PivDataReader::new(0.0);
1401        let gs = 1.0;
1402        for ix in 0..3i64 {
1403            for iy in 0..3i64 {
1404                let x = ix as f64 * gs;
1405                let y = iy as f64 * gs;
1406                piv.vectors
1407                    .push(PivVector::new(x, y, -y * 0.1, x * 0.1, 10.0));
1408            }
1409        }
1410        let vort = piv.vorticity_field(gs);
1411        assert!(!vort.is_empty());
1412    }
1413
1414    #[test]
1415    fn test_piv_speed() {
1416        let v = PivVector::new(0.0, 0.0, 3.0, 4.0, 10.0);
1417        assert!((v.speed() - 5.0).abs() < 1e-10);
1418    }
1419
1420    // --- StrainGauge ---
1421
1422    #[test]
1423    fn test_rosette_cartesian_strains() {
1424        // e0=100, e45=50, e90=0 → γxy = 2*50 - 100 - 0 = 0
1425        let r = RosetteReading::new(0.0, 100.0, 50.0, 0.0);
1426        let (exx, eyy, gxy) = r.cartesian_strains();
1427        assert!((exx - 100.0).abs() < 1e-10);
1428        assert!((eyy - 0.0).abs() < 1e-10);
1429        assert!((gxy - 0.0).abs() < 1e-10);
1430    }
1431
1432    #[test]
1433    fn test_rosette_principal_strains() {
1434        let r = RosetteReading::new(0.0, 100.0, 0.0, 0.0);
1435        let (e1, e2, _) = r.principal_strains();
1436        assert!(e1 >= e2);
1437    }
1438
1439    #[test]
1440    fn test_rosette_mohr_circle() {
1441        let r = RosetteReading::new(0.0, 100.0, 0.0, 0.0);
1442        let (center, radius) = r.mohr_circle();
1443        assert!(radius >= 0.0);
1444        assert!(center.is_finite());
1445    }
1446
1447    #[test]
1448    fn test_strain_gauge_from_csv() {
1449        let csv = "0.0,100,50,10\n0.1,200,80,20\n";
1450        let sg = StrainGaugeReader::from_csv(csv, 2.0).unwrap();
1451        assert_eq!(sg.readings.len(), 2);
1452    }
1453
1454    #[test]
1455    fn test_strain_gauge_peak() {
1456        let mut sg = StrainGaugeReader::new(2.0);
1457        sg.push(0.0, 100.0, 0.0, 0.0);
1458        sg.push(1.0, 500.0, 0.0, 0.0);
1459        assert!(sg.peak_principal_strain() > 0.0);
1460    }
1461
1462    #[test]
1463    fn test_strain_gauge_mean_von_mises() {
1464        let mut sg = StrainGaugeReader::new(2.0);
1465        sg.push(0.0, 100.0, 50.0, 0.0);
1466        let vm = sg.mean_von_mises();
1467        assert!(vm >= 0.0);
1468    }
1469
1470    #[test]
1471    fn test_strain_gauge_to_csv() {
1472        let mut sg = StrainGaugeReader::new(2.0);
1473        sg.push(0.0, 100.0, 50.0, 10.0);
1474        let csv = sg.to_csv();
1475        assert!(csv.contains("100"));
1476    }
1477
1478    // --- DMA ---
1479
1480    #[test]
1481    fn test_dma_tan_delta() {
1482        let p = DmaPoint::new(1.0, 1e6, 2e5, 25.0);
1483        assert!((p.tan_delta() - 0.2).abs() < 1e-10);
1484    }
1485
1486    #[test]
1487    fn test_dma_complex_modulus() {
1488        let p = DmaPoint::new(1.0, 3.0, 4.0, 25.0);
1489        assert!((p.complex_modulus_magnitude() - 5.0).abs() < 1e-10);
1490    }
1491
1492    #[test]
1493    fn test_dma_from_csv() {
1494        let csv = "1.0,1e6,2e5,25.0\n10.0,1.1e6,2.1e5,25.0\n";
1495        let dma = DmaDataReader::from_csv(csv, 25.0).unwrap();
1496        assert_eq!(dma.points.len(), 2);
1497    }
1498
1499    #[test]
1500    fn test_dma_mean_tan_delta() {
1501        let mut dma = DmaDataReader::new(25.0);
1502        dma.push(1.0, 1e6, 2e5, 25.0);
1503        dma.push(10.0, 1e6, 3e5, 25.0);
1504        let mtd = dma.mean_tan_delta();
1505        assert!(mtd > 0.0);
1506    }
1507
1508    #[test]
1509    fn test_dma_max_storage_modulus() {
1510        let mut dma = DmaDataReader::new(25.0);
1511        dma.push(1.0, 1e6, 2e5, 25.0);
1512        dma.push(10.0, 2e6, 3e5, 25.0);
1513        assert!((dma.max_storage_modulus() - 2e6).abs() < 1.0);
1514    }
1515
1516    #[test]
1517    fn test_dma_wlf_shift_factors() {
1518        let mut dma = DmaDataReader::new(25.0);
1519        dma.push(1.0, 1e6, 2e5, 0.0);
1520        dma.push(1.0, 1e6, 2e5, 25.0);
1521        dma.push(1.0, 1e6, 2e5, 50.0);
1522        let shifts = dma.wlf_shift_factors(8.86, 101.6);
1523        assert_eq!(shifts.len(), 3);
1524    }
1525
1526    #[test]
1527    fn test_dma_master_curve_storage() {
1528        let mut dma = DmaDataReader::new(25.0);
1529        dma.push(1.0, 1e6, 2e5, 0.0);
1530        dma.push(10.0, 1.1e6, 2.1e5, 25.0);
1531        let curve = dma.master_curve_storage(8.86, 101.6);
1532        assert!(!curve.is_empty());
1533    }
1534
1535    #[test]
1536    fn test_dma_master_curve_tan_delta() {
1537        let mut dma = DmaDataReader::new(25.0);
1538        dma.push(1.0, 1e6, 2e5, 25.0);
1539        dma.push(10.0, 1.2e6, 2.4e5, 40.0);
1540        let curve = dma.master_curve_tan_delta(8.86, 101.6);
1541        assert!(!curve.is_empty());
1542        for (_, td) in &curve {
1543            assert!(td.is_finite());
1544        }
1545    }
1546
1547    #[test]
1548    fn test_dma_filter_by_temperature() {
1549        let mut dma = DmaDataReader::new(25.0);
1550        dma.push(1.0, 1e6, 2e5, 10.0);
1551        dma.push(1.0, 1e6, 2e5, 30.0);
1552        dma.push(1.0, 1e6, 2e5, 60.0);
1553        let filtered = dma.filter_by_temperature(0.0, 40.0);
1554        assert_eq!(filtered.len(), 2);
1555    }
1556
1557    // --- ExperimentalComparison ---
1558
1559    #[test]
1560    fn test_comparison_rmse() {
1561        let cmp = ExperimentalComparison::new(vec![1.0, 2.0, 3.0], vec![1.0, 2.0, 4.0]);
1562        let rmse = cmp.rmse().unwrap();
1563        // error at last = 1.0, rmse = sqrt(1/3)
1564        assert!((rmse - (1.0_f64 / 3.0).sqrt()).abs() < 1e-10);
1565    }
1566
1567    #[test]
1568    fn test_comparison_mae() {
1569        let cmp = ExperimentalComparison::new(vec![0.0, 1.0, 2.0], vec![0.0, 2.0, 2.0]);
1570        let mae = cmp.mae().unwrap();
1571        assert!((mae - 1.0 / 3.0).abs() < 1e-10);
1572    }
1573
1574    #[test]
1575    fn test_comparison_correlation_perfect() {
1576        let exp: Vec<f64> = (0..10).map(|i| i as f64).collect();
1577        let sim = exp.clone();
1578        let cmp = ExperimentalComparison::new(exp, sim);
1579        let r = cmp.correlation().unwrap();
1580        assert!((r - 1.0).abs() < 1e-10);
1581    }
1582
1583    #[test]
1584    fn test_comparison_r_squared_perfect() {
1585        let exp: Vec<f64> = (0..10).map(|i| i as f64).collect();
1586        let sim = exp.clone();
1587        let cmp = ExperimentalComparison::new(exp, sim);
1588        let r2 = cmp.r_squared().unwrap();
1589        assert!((r2 - 1.0).abs() < 1e-10);
1590    }
1591
1592    #[test]
1593    fn test_comparison_length_mismatch() {
1594        let cmp = ExperimentalComparison::new(vec![1.0, 2.0], vec![1.0]);
1595        assert_eq!(cmp.rmse(), Err(ComparisonError::LengthMismatch));
1596    }
1597
1598    #[test]
1599    fn test_comparison_empty() {
1600        let cmp = ExperimentalComparison::new(vec![], vec![]);
1601        assert_eq!(cmp.rmse(), Err(ComparisonError::EmptyData));
1602    }
1603
1604    #[test]
1605    fn test_comparison_max_error() {
1606        let cmp = ExperimentalComparison::new(vec![0.0, 1.0, 2.0], vec![0.0, 1.5, 2.0]);
1607        let me = cmp.max_error().unwrap();
1608        assert!((me - 0.5).abs() < 1e-10);
1609    }
1610
1611    #[test]
1612    fn test_comparison_bias() {
1613        let cmp = ExperimentalComparison::new(vec![0.0, 1.0], vec![1.0, 2.0]);
1614        let b = cmp.bias().unwrap();
1615        assert!((b - 1.0).abs() < 1e-10);
1616    }
1617
1618    #[test]
1619    fn test_comparison_relative_errors() {
1620        let cmp = ExperimentalComparison::new(vec![1.0, 2.0], vec![1.1, 2.2]);
1621        let re = cmp.relative_errors().unwrap();
1622        assert!((re[0] - 0.1).abs() < 1e-10);
1623        assert!((re[1] - 0.1).abs() < 1e-10);
1624    }
1625
1626    #[test]
1627    fn test_comparison_nrmse() {
1628        let cmp = ExperimentalComparison::new(vec![0.0, 10.0], vec![1.0, 9.0]);
1629        let nrmse = cmp.nrmse().unwrap();
1630        assert!(nrmse.is_finite() && nrmse > 0.0);
1631    }
1632
1633    #[test]
1634    fn test_comparison_all_metrics() {
1635        let exp: Vec<f64> = (0..5).map(|i| i as f64).collect();
1636        let sim: Vec<f64> = exp.iter().map(|&v| v + 0.1).collect();
1637        let cmp = ExperimentalComparison::new(exp, sim);
1638        let m = cmp.all_metrics().unwrap();
1639        assert!(m.rmse > 0.0);
1640        assert!(m.mae > 0.0);
1641        assert!(m.correlation > 0.0);
1642    }
1643
1644    #[test]
1645    fn test_percentile() {
1646        let data: Vec<f64> = (1..=10).map(|i| i as f64).collect();
1647        let p50 = percentile(&data, 50.0);
1648        assert!((p50 - 5.5).abs() < 0.1);
1649    }
1650
1651    #[test]
1652    fn test_piv_empty_statistics() {
1653        let piv = PivDataReader::new(0.0);
1654        let stats = piv.statistics();
1655        assert_eq!(stats.mean_speed, 0.0);
1656    }
1657
1658    #[test]
1659    fn test_dic_empty_strain_field() {
1660        let dic = DigitalImageCorrelation::new(0.5);
1661        let strains = dic.compute_strain_field(1.0);
1662        assert!(strains.is_empty());
1663    }
1664
1665    #[test]
1666    fn test_dataset_empty_mean() {
1667        let meta = SensorMetadata::new("S", "x", "m", 1.0);
1668        let ds = ExperimentalDataset::new(meta);
1669        assert_eq!(ds.mean(), 0.0);
1670    }
1671
1672    #[test]
1673    fn test_dataset_empty_range() {
1674        let meta = SensorMetadata::new("S", "x", "m", 1.0);
1675        let ds = ExperimentalDataset::new(meta);
1676        assert!(ds.range().is_none());
1677    }
1678
1679    #[test]
1680    fn test_rosette_von_mises_nonneg() {
1681        let r = RosetteReading::new(0.0, 200.0, 100.0, 50.0);
1682        assert!(r.von_mises() >= 0.0);
1683    }
1684
1685    #[test]
1686    fn test_comparison_error_display() {
1687        let e = ComparisonError::LengthMismatch;
1688        assert!(!format!("{}", e).is_empty());
1689    }
1690}