1#![allow(dead_code)]
15#![allow(clippy::too_many_arguments)]
16
17use std::collections::HashMap;
18use std::fmt;
19
20#[derive(Debug, Clone)]
26pub struct SensorMetadata {
27 pub sensor_id: String,
29 pub quantity: String,
31 pub unit: String,
33 pub sampling_rate_hz: f64,
35 pub location: Option<String>,
37 pub extra: HashMap<String, String>,
39}
40
41impl SensorMetadata {
42 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 pub fn with_location(mut self, location: impl Into<String>) -> Self {
61 self.location = Some(location.into());
62 self
63 }
64
65 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#[derive(Debug, Clone, PartialEq)]
74pub struct DataSample {
75 pub time: f64,
77 pub value: f64,
79 pub is_outlier: bool,
81}
82
83impl DataSample {
84 pub fn new(time: f64, value: f64) -> Self {
86 Self {
87 time,
88 value,
89 is_outlier: false,
90 }
91 }
92}
93
94#[derive(Debug, Clone)]
96pub struct ExperimentalDataset {
97 pub metadata: SensorMetadata,
99 pub samples: Vec<DataSample>,
101}
102
103impl ExperimentalDataset {
104 pub fn new(metadata: SensorMetadata) -> Self {
106 Self {
107 metadata,
108 samples: Vec::new(),
109 }
110 }
111
112 pub fn push(&mut self, time: f64, value: f64) {
114 self.samples.push(DataSample::new(time, value));
115 }
116
117 pub fn len(&self) -> usize {
119 self.samples.len()
120 }
121
122 pub fn is_empty(&self) -> bool {
124 self.samples.is_empty()
125 }
126
127 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 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 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 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 pub fn outliers(&self) -> Vec<&DataSample> {
189 self.samples.iter().filter(|s| s.is_outlier).collect()
190 }
191
192 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 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 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 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
258fn 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#[derive(Debug, Clone, PartialEq)]
276pub struct DicPoint {
277 pub x: f64,
279 pub y: f64,
281 pub u: f64,
283 pub v: f64,
285 pub correlation: f64,
287}
288
289impl DicPoint {
290 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 pub fn displacement_magnitude(&self) -> f64 {
303 (self.u * self.u + self.v * self.v).sqrt()
304 }
305}
306
307#[derive(Debug, Clone, PartialEq)]
309pub struct StrainTensor2D {
310 pub exx: f64,
312 pub eyy: f64,
314 pub exy: f64,
316}
317
318impl StrainTensor2D {
319 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 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#[derive(Debug, Clone)]
338pub struct DigitalImageCorrelation {
339 pub points: Vec<DicPoint>,
341 pub min_correlation: f64,
343}
344
345impl DigitalImageCorrelation {
346 pub fn new(min_correlation: f64) -> Self {
348 Self {
349 points: Vec::new(),
350 min_correlation,
351 }
352 }
353
354 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 pub fn filter_by_correlation(&mut self) {
379 self.points
380 .retain(|p| p.correlation >= self.min_correlation);
381 }
382
383 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 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 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 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#[derive(Debug, Clone, PartialEq)]
455pub struct PivVector {
456 pub x: f64,
458 pub y: f64,
460 pub u: f64,
462 pub v: f64,
464 pub snr: f64,
466}
467
468impl PivVector {
469 pub fn new(x: f64, y: f64, u: f64, v: f64, snr: f64) -> Self {
471 Self { x, y, u, v, snr }
472 }
473
474 pub fn speed(&self) -> f64 {
476 (self.u * self.u + self.v * self.v).sqrt()
477 }
478
479 pub fn components(&self) -> (f64, f64) {
482 (self.u, self.v)
483 }
484}
485
486#[derive(Debug, Clone)]
488pub struct PivStatistics {
489 pub mean_speed: f64,
491 pub u_rms: f64,
493 pub v_rms: f64,
495 pub turbulence_intensity: f64,
497 pub mean_u: f64,
499 pub mean_v: f64,
501}
502
503#[derive(Debug, Clone)]
505pub struct PivDataReader {
506 pub vectors: Vec<PivVector>,
508 pub min_snr: f64,
510}
511
512impl PivDataReader {
513 pub fn new(min_snr: f64) -> Self {
515 Self {
516 vectors: Vec::new(),
517 min_snr,
518 }
519 }
520
521 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 pub fn filter_by_snr(&mut self) {
546 self.vectors.retain(|v| v.snr >= self.min_snr);
547 }
548
549 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 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#[derive(Debug, Clone, PartialEq)]
638pub struct RosetteReading {
639 pub e0: f64,
641 pub e45: f64,
643 pub e90: f64,
645 pub time: f64,
647}
648
649impl RosetteReading {
650 pub fn new(time: f64, e0: f64, e45: f64, e90: f64) -> Self {
652 Self { e0, e45, e90, time }
653 }
654
655 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 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 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 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#[derive(Debug, Clone)]
691pub struct StrainGaugeReader {
692 pub readings: Vec<RosetteReading>,
694 pub gauge_factor: f64,
696}
697
698impl StrainGaugeReader {
699 pub fn new(gauge_factor: f64) -> Self {
701 Self {
702 readings: Vec::new(),
703 gauge_factor,
704 }
705 }
706
707 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 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 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 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 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#[derive(Debug, Clone, PartialEq)]
769pub struct DmaPoint {
770 pub frequency_hz: f64,
772 pub storage_modulus: f64,
774 pub loss_modulus: f64,
776 pub temperature_c: f64,
778}
779
780impl DmaPoint {
781 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 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 pub fn complex_modulus_magnitude(&self) -> f64 {
806 (self.storage_modulus.powi(2) + self.loss_modulus.powi(2)).sqrt()
807 }
808}
809
810#[derive(Debug, Clone)]
812pub struct ShiftFactor {
813 pub temperature_c: f64,
815 pub log_at: f64,
817}
818
819#[derive(Debug, Clone)]
821pub struct DmaDataReader {
822 pub points: Vec<DmaPoint>,
824 pub reference_temperature_c: f64,
826}
827
828impl DmaDataReader {
829 pub fn new(reference_temperature_c: f64) -> Self {
831 Self {
832 points: Vec::new(),
833 reference_temperature_c,
834 }
835 }
836
837 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
982pub enum ComparisonError {
983 LengthMismatch,
985 EmptyData,
987 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#[derive(Debug, Clone)]
1003pub struct ComparisonMetrics {
1004 pub rmse: f64,
1006 pub nrmse: f64,
1008 pub correlation: f64,
1010 pub mae: f64,
1012 pub max_error: f64,
1014 pub r_squared: f64,
1016}
1017
1018#[derive(Debug, Clone)]
1020pub struct ExperimentalComparison {
1021 pub experimental: Vec<f64>,
1023 pub simulation: Vec<f64>,
1025}
1026
1027impl ExperimentalComparison {
1028 pub fn new(experimental: Vec<f64>, simulation: Vec<f64>) -> Self {
1030 Self {
1031 experimental,
1032 simulation,
1033 }
1034 }
1035
1036 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 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 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 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 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 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 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 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 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#[cfg(test)]
1167mod tests {
1168 use super::*;
1169
1170 #[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); 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 #[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)); dic.points.push(DicPoint::new(1.0, 0.0, 0.0, 0.0, 0.9)); 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 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 #[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 #[test]
1423 fn test_rosette_cartesian_strains() {
1424 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 #[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 #[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 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}