1#![allow(clippy::needless_range_loop)]
2#![allow(dead_code)]
22#![allow(clippy::too_many_arguments)]
23
24fn read_f64_le(data: &[u8], offset: usize) -> Option<f64> {
32 if offset + 8 > data.len() {
33 return None;
34 }
35 let arr: [u8; 8] = data[offset..offset + 8].try_into().ok()?;
36 Some(f64::from_le_bytes(arr))
37}
38
39fn push_f64_le(buf: &mut Vec<u8>, v: f64) {
41 buf.extend_from_slice(&v.to_le_bytes());
42}
43
44#[derive(Debug, Clone, PartialEq)]
53pub struct ImuSample {
54 pub timestamp: f64,
56 pub accel: [f64; 3],
58 pub gyro: [f64; 3],
60 pub mag: [f64; 3],
62}
63
64impl ImuSample {
65 pub fn new(timestamp: f64, accel: [f64; 3], gyro: [f64; 3], mag: [f64; 3]) -> Self {
67 Self {
68 timestamp,
69 accel,
70 gyro,
71 mag,
72 }
73 }
74
75 pub fn to_bytes(&self) -> Vec<u8> {
79 let mut buf = Vec::with_capacity(80);
80 push_f64_le(&mut buf, self.timestamp);
81 for &v in &self.accel {
82 push_f64_le(&mut buf, v);
83 }
84 for &v in &self.gyro {
85 push_f64_le(&mut buf, v);
86 }
87 for &v in &self.mag {
88 push_f64_le(&mut buf, v);
89 }
90 buf
91 }
92
93 pub fn from_bytes(data: &[u8]) -> Option<Self> {
95 if data.len() < 80 {
96 return None;
97 }
98 let mut o = 0usize;
99 let mut next = || {
100 let v = read_f64_le(data, o);
101 o += 8;
102 v
103 };
104 Some(Self {
105 timestamp: next()?,
106 accel: [next()?, next()?, next()?],
107 gyro: [next()?, next()?, next()?],
108 mag: [next()?, next()?, next()?],
109 })
110 }
111
112 pub fn accel_magnitude(&self) -> f64 {
114 let [ax, ay, az] = self.accel;
115 (ax * ax + ay * ay + az * az).sqrt()
116 }
117
118 pub fn gyro_magnitude(&self) -> f64 {
120 let [gx, gy, gz] = self.gyro;
121 (gx * gx + gy * gy + gz * gz).sqrt()
122 }
123}
124
125#[derive(Debug, Clone, Default)]
131pub struct ImuStream {
132 pub samples: Vec<ImuSample>,
134}
135
136impl ImuStream {
137 pub fn new() -> Self {
139 Self {
140 samples: Vec::new(),
141 }
142 }
143
144 pub fn push(&mut self, sample: ImuSample) {
146 self.samples.push(sample);
147 }
148
149 pub fn len(&self) -> usize {
151 self.samples.len()
152 }
153
154 pub fn is_empty(&self) -> bool {
156 self.samples.is_empty()
157 }
158
159 pub fn mean_accel(&self) -> [f64; 3] {
163 if self.samples.is_empty() {
164 return [0.0; 3];
165 }
166 let n = self.samples.len() as f64;
167 let mut sum = [0.0f64; 3];
168 for s in &self.samples {
169 for k in 0..3 {
170 sum[k] += s.accel[k];
171 }
172 }
173 [sum[0] / n, sum[1] / n, sum[2] / n]
174 }
175
176 pub fn mean_gyro(&self) -> [f64; 3] {
178 if self.samples.is_empty() {
179 return [0.0; 3];
180 }
181 let n = self.samples.len() as f64;
182 let mut sum = [0.0f64; 3];
183 for s in &self.samples {
184 for k in 0..3 {
185 sum[k] += s.gyro[k];
186 }
187 }
188 [sum[0] / n, sum[1] / n, sum[2] / n]
189 }
190
191 pub fn to_bytes(&self) -> Vec<u8> {
196 let n = self.samples.len() as u64;
197 let mut buf = Vec::with_capacity(8 + self.samples.len() * 80);
198 buf.extend_from_slice(&n.to_le_bytes());
199 for s in &self.samples {
200 buf.extend_from_slice(&s.to_bytes());
201 }
202 buf
203 }
204
205 pub fn from_bytes(data: &[u8]) -> Option<Self> {
207 if data.len() < 8 {
208 return None;
209 }
210 let n = u64::from_le_bytes(data[0..8].try_into().ok()?) as usize;
211 if data.len() < 8 + n * 80 {
212 return None;
213 }
214 let mut samples = Vec::with_capacity(n);
215 for i in 0..n {
216 samples.push(ImuSample::from_bytes(&data[8 + i * 80..])?);
217 }
218 Some(Self { samples })
219 }
220}
221
222#[derive(Debug, Clone, PartialEq)]
228pub struct PressureSample {
229 pub timestamp: f64,
231 pub pressure_pa: f64,
233 pub temperature_c: f64,
235}
236
237impl PressureSample {
238 pub fn new(timestamp: f64, pressure_pa: f64, temperature_c: f64) -> Self {
240 Self {
241 timestamp,
242 pressure_pa,
243 temperature_c,
244 }
245 }
246
247 pub fn altitude_m(&self, p0: f64, h0: f64) -> f64 {
251 const T0: f64 = 288.15; const L: f64 = 0.0065; const R: f64 = 8.3144598; const M: f64 = 0.0289644; const G: f64 = 9.80665; let exp = R * L / (G * M);
257 h0 + (T0 / L) * (1.0 - (self.pressure_pa / p0).powf(exp))
258 }
259
260 pub fn to_bytes(&self) -> [u8; 24] {
262 let mut buf = [0u8; 24];
263 buf[0..8].copy_from_slice(&self.timestamp.to_le_bytes());
264 buf[8..16].copy_from_slice(&self.pressure_pa.to_le_bytes());
265 buf[16..24].copy_from_slice(&self.temperature_c.to_le_bytes());
266 buf
267 }
268
269 pub fn from_bytes(data: &[u8]) -> Option<Self> {
271 if data.len() < 24 {
272 return None;
273 }
274 Some(Self {
275 timestamp: read_f64_le(data, 0)?,
276 pressure_pa: read_f64_le(data, 8)?,
277 temperature_c: read_f64_le(data, 16)?,
278 })
279 }
280}
281
282#[derive(Debug, Clone, Default)]
284pub struct PressureTimeSeries {
285 pub samples: Vec<PressureSample>,
287}
288
289impl PressureTimeSeries {
290 pub fn new() -> Self {
292 Self {
293 samples: Vec::new(),
294 }
295 }
296
297 pub fn push(&mut self, s: PressureSample) {
299 self.samples.push(s);
300 }
301
302 pub fn min_pressure(&self) -> f64 {
304 self.samples
305 .iter()
306 .map(|s| s.pressure_pa)
307 .fold(f64::INFINITY, f64::min)
308 }
309
310 pub fn max_pressure(&self) -> f64 {
312 self.samples
313 .iter()
314 .map(|s| s.pressure_pa)
315 .fold(f64::NEG_INFINITY, f64::max)
316 }
317
318 pub fn mean_pressure(&self) -> f64 {
320 if self.samples.is_empty() {
321 return 0.0;
322 }
323 let sum: f64 = self.samples.iter().map(|s| s.pressure_pa).sum();
324 sum / self.samples.len() as f64
325 }
326}
327
328#[derive(Debug, Clone, PartialEq)]
334pub struct TempProbeReading {
335 pub probe_id: u32,
337 pub timestamp: f64,
339 pub temperature_c: f64,
341 pub position: [f64; 3],
343}
344
345impl TempProbeReading {
346 pub fn new(probe_id: u32, timestamp: f64, temperature_c: f64, position: [f64; 3]) -> Self {
348 Self {
349 probe_id,
350 timestamp,
351 temperature_c,
352 position,
353 }
354 }
355
356 pub fn to_bytes(&self) -> Vec<u8> {
358 let mut buf = Vec::with_capacity(44);
359 buf.extend_from_slice(&self.probe_id.to_le_bytes());
360 push_f64_le(&mut buf, self.timestamp);
361 push_f64_le(&mut buf, self.temperature_c);
362 for &v in &self.position {
363 push_f64_le(&mut buf, v);
364 }
365 buf
366 }
367
368 pub fn from_bytes(data: &[u8]) -> Option<Self> {
370 if data.len() < 44 {
371 return None;
372 }
373 let probe_id = u32::from_le_bytes(data[0..4].try_into().ok()?);
374 Some(Self {
375 probe_id,
376 timestamp: read_f64_le(data, 4)?,
377 temperature_c: read_f64_le(data, 12)?,
378 position: [
379 read_f64_le(data, 20)?,
380 read_f64_le(data, 28)?,
381 read_f64_le(data, 36)?,
382 ],
383 })
384 }
385}
386
387#[derive(Debug, Clone, Default)]
389pub struct TempArraySnapshot {
390 pub timestamp: f64,
392 pub readings: Vec<f64>,
394}
395
396impl TempArraySnapshot {
397 pub fn new(timestamp: f64, readings: Vec<f64>) -> Self {
399 Self {
400 timestamp,
401 readings,
402 }
403 }
404
405 pub fn num_probes(&self) -> usize {
407 self.readings.len()
408 }
409
410 pub fn max_temp(&self) -> f64 {
412 self.readings
413 .iter()
414 .cloned()
415 .fold(f64::NEG_INFINITY, f64::max)
416 }
417
418 pub fn min_temp(&self) -> f64 {
420 self.readings.iter().cloned().fold(f64::INFINITY, f64::min)
421 }
422
423 pub fn mean_temp(&self) -> f64 {
425 if self.readings.is_empty() {
426 return 0.0;
427 }
428 self.readings.iter().sum::<f64>() / self.readings.len() as f64
429 }
430
431 pub fn rms_deviation(&self) -> f64 {
433 if self.readings.is_empty() {
434 return 0.0;
435 }
436 let mean = self.mean_temp();
437 let var = self
438 .readings
439 .iter()
440 .map(|&t| (t - mean).powi(2))
441 .sum::<f64>()
442 / self.readings.len() as f64;
443 var.sqrt()
444 }
445}
446
447#[derive(Debug, Clone, PartialEq)]
453pub struct StrainGaugeSample {
454 pub gauge_id: u32,
456 pub timestamp: f64,
458 pub bridge_voltage_v: f64,
460 pub microstrain: f64,
462 pub temperature_c: f64,
464}
465
466impl StrainGaugeSample {
467 pub fn new(
469 gauge_id: u32,
470 timestamp: f64,
471 bridge_voltage_v: f64,
472 microstrain: f64,
473 temperature_c: f64,
474 ) -> Self {
475 Self {
476 gauge_id,
477 timestamp,
478 bridge_voltage_v,
479 microstrain,
480 temperature_c,
481 }
482 }
483
484 pub fn stress_mpa(&self, e_gpa: f64) -> f64 {
488 self.microstrain * 1e-6 * e_gpa * 1e3
489 }
490
491 pub fn to_bytes(&self) -> Vec<u8> {
493 let mut buf = Vec::with_capacity(36);
494 buf.extend_from_slice(&self.gauge_id.to_le_bytes());
495 push_f64_le(&mut buf, self.timestamp);
496 push_f64_le(&mut buf, self.bridge_voltage_v);
497 push_f64_le(&mut buf, self.microstrain);
498 push_f64_le(&mut buf, self.temperature_c);
499 buf
500 }
501
502 pub fn from_bytes(data: &[u8]) -> Option<Self> {
504 if data.len() < 36 {
505 return None;
506 }
507 let gauge_id = u32::from_le_bytes(data[0..4].try_into().ok()?);
508 Some(Self {
509 gauge_id,
510 timestamp: read_f64_le(data, 4)?,
511 bridge_voltage_v: read_f64_le(data, 12)?,
512 microstrain: read_f64_le(data, 20)?,
513 temperature_c: read_f64_le(data, 28)?,
514 })
515 }
516}
517
518#[derive(Debug, Clone, PartialEq)]
526pub struct ForceTorqueSample {
527 pub timestamp: f64,
529 pub force_n: [f64; 3],
531 pub torque_nm: [f64; 3],
533}
534
535impl ForceTorqueSample {
536 pub fn new(timestamp: f64, force_n: [f64; 3], torque_nm: [f64; 3]) -> Self {
538 Self {
539 timestamp,
540 force_n,
541 torque_nm,
542 }
543 }
544
545 pub fn force_magnitude(&self) -> f64 {
547 let [fx, fy, fz] = self.force_n;
548 (fx * fx + fy * fy + fz * fz).sqrt()
549 }
550
551 pub fn torque_magnitude(&self) -> f64 {
553 let [tx, ty, tz] = self.torque_nm;
554 (tx * tx + ty * ty + tz * tz).sqrt()
555 }
556
557 pub fn to_bytes(&self) -> Vec<u8> {
559 let mut buf = Vec::with_capacity(56);
560 push_f64_le(&mut buf, self.timestamp);
561 for &v in &self.force_n {
562 push_f64_le(&mut buf, v);
563 }
564 for &v in &self.torque_nm {
565 push_f64_le(&mut buf, v);
566 }
567 buf
568 }
569
570 pub fn from_bytes(data: &[u8]) -> Option<Self> {
572 if data.len() < 56 {
573 return None;
574 }
575 let mut o = 0usize;
576 let mut next = || {
577 let v = read_f64_le(data, o);
578 o += 8;
579 v
580 };
581 Some(Self {
582 timestamp: next()?,
583 force_n: [next()?, next()?, next()?],
584 torque_nm: [next()?, next()?, next()?],
585 })
586 }
587}
588
589#[derive(Debug, Clone, Default)]
591pub struct ForceTorqueLog {
592 pub samples: Vec<ForceTorqueSample>,
594}
595
596impl ForceTorqueLog {
597 pub fn new() -> Self {
599 Self {
600 samples: Vec::new(),
601 }
602 }
603
604 pub fn push(&mut self, s: ForceTorqueSample) {
606 self.samples.push(s);
607 }
608
609 pub fn peak_force(&self) -> f64 {
611 self.samples
612 .iter()
613 .map(|s| s.force_magnitude())
614 .fold(0.0_f64, f64::max)
615 }
616
617 pub fn peak_torque(&self) -> f64 {
619 self.samples
620 .iter()
621 .map(|s| s.torque_magnitude())
622 .fold(0.0_f64, f64::max)
623 }
624}
625
626#[derive(Debug, Clone, PartialEq)]
632pub struct OpticalEncoderSample {
633 pub axis_id: u32,
635 pub timestamp: f64,
637 pub angle_rad: f64,
639 pub velocity_rad_s: f64,
641 pub ticks: i64,
643}
644
645impl OpticalEncoderSample {
646 pub fn new(
648 axis_id: u32,
649 timestamp: f64,
650 angle_rad: f64,
651 velocity_rad_s: f64,
652 ticks: i64,
653 ) -> Self {
654 Self {
655 axis_id,
656 timestamp,
657 angle_rad,
658 velocity_rad_s,
659 ticks,
660 }
661 }
662
663 pub fn angle_deg(&self) -> f64 {
665 self.angle_rad.to_degrees()
666 }
667
668 pub fn velocity_rpm(&self) -> f64 {
670 self.velocity_rad_s * 60.0 / (2.0 * std::f64::consts::PI)
671 }
672
673 pub fn to_bytes(&self) -> Vec<u8> {
675 let mut buf = Vec::with_capacity(36);
676 buf.extend_from_slice(&self.axis_id.to_le_bytes());
677 push_f64_le(&mut buf, self.timestamp);
678 push_f64_le(&mut buf, self.angle_rad);
679 push_f64_le(&mut buf, self.velocity_rad_s);
680 buf.extend_from_slice(&self.ticks.to_le_bytes());
681 buf
682 }
683
684 pub fn from_bytes(data: &[u8]) -> Option<Self> {
686 if data.len() < 36 {
687 return None;
688 }
689 let axis_id = u32::from_le_bytes(data[0..4].try_into().ok()?);
690 let ticks = i64::from_le_bytes(data[28..36].try_into().ok()?);
691 Some(Self {
692 axis_id,
693 timestamp: read_f64_le(data, 4)?,
694 angle_rad: read_f64_le(data, 12)?,
695 velocity_rad_s: read_f64_le(data, 20)?,
696 ticks,
697 })
698 }
699}
700
701#[derive(Debug, Clone, PartialEq)]
707pub struct LvdtSample {
708 pub sensor_id: u32,
710 pub timestamp: f64,
712 pub displacement_m: f64,
714 pub raw_voltage_v: f64,
716}
717
718impl LvdtSample {
719 pub fn new(sensor_id: u32, timestamp: f64, displacement_m: f64, raw_voltage_v: f64) -> Self {
721 Self {
722 sensor_id,
723 timestamp,
724 displacement_m,
725 raw_voltage_v,
726 }
727 }
728
729 pub fn displacement_mm(&self) -> f64 {
731 self.displacement_m * 1e3
732 }
733
734 pub fn to_bytes(&self) -> Vec<u8> {
736 let mut buf = Vec::with_capacity(28);
737 buf.extend_from_slice(&self.sensor_id.to_le_bytes());
738 push_f64_le(&mut buf, self.timestamp);
739 push_f64_le(&mut buf, self.displacement_m);
740 push_f64_le(&mut buf, self.raw_voltage_v);
741 buf
742 }
743
744 pub fn from_bytes(data: &[u8]) -> Option<Self> {
746 if data.len() < 28 {
747 return None;
748 }
749 let sensor_id = u32::from_le_bytes(data[0..4].try_into().ok()?);
750 Some(Self {
751 sensor_id,
752 timestamp: read_f64_le(data, 4)?,
753 displacement_m: read_f64_le(data, 12)?,
754 raw_voltage_v: read_f64_le(data, 20)?,
755 })
756 }
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq)]
765pub enum ThermocoupleType {
766 TypeK,
768 TypeJ,
770 TypeT,
772 TypeE,
774 TypeN,
776 TypeR,
778 TypeS,
780 TypeB,
782}
783
784#[derive(Debug, Clone)]
789pub struct ThermocoupleCalibration {
790 pub tc_type: ThermocoupleType,
792 pub cold_junction_c: f64,
794 pub poly_coeffs: Vec<f64>,
796 pub valid_range_mv: [f64; 2],
798}
799
800impl ThermocoupleCalibration {
801 pub fn new(
803 tc_type: ThermocoupleType,
804 cold_junction_c: f64,
805 poly_coeffs: Vec<f64>,
806 valid_range_mv: [f64; 2],
807 ) -> Self {
808 Self {
809 tc_type,
810 cold_junction_c,
811 poly_coeffs,
812 valid_range_mv,
813 }
814 }
815
816 pub fn convert(&self, emf_mv: f64) -> Option<f64> {
820 let [lo, hi] = self.valid_range_mv;
821 if emf_mv < lo || emf_mv > hi {
822 return None;
823 }
824 let mut result = 0.0f64;
825 let mut power = 1.0f64;
826 for &c in &self.poly_coeffs {
827 result += c * power;
828 power *= emf_mv;
829 }
830 Some(result + self.cold_junction_c)
831 }
832
833 pub fn type_k_linear(emf_mv: f64) -> f64 {
837 emf_mv / 0.041
838 }
839}
840
841#[derive(Debug, Clone)]
851pub struct KalmanState {
852 pub timestamp: f64,
854 pub position_m: [f64; 3],
856 pub velocity_ms: [f64; 3],
858 pub quaternion: [f64; 4],
860 pub accel_bias: [f64; 3],
862 pub gyro_bias: [f64; 3],
864 pub cov_diagonal: [f64; 15],
866}
867
868impl KalmanState {
869 pub fn identity(timestamp: f64) -> Self {
871 Self {
872 timestamp,
873 position_m: [0.0; 3],
874 velocity_ms: [0.0; 3],
875 quaternion: [1.0, 0.0, 0.0, 0.0],
876 accel_bias: [0.0; 3],
877 gyro_bias: [0.0; 3],
878 cov_diagonal: [1.0; 15],
879 }
880 }
881
882 pub fn quaternion_is_unit(&self) -> bool {
884 let [qw, qx, qy, qz] = self.quaternion;
885 let norm = (qw * qw + qx * qx + qy * qy + qz * qz).sqrt();
886 (norm - 1.0).abs() < 1e-6
887 }
888
889 pub fn roll_rad(&self) -> f64 {
891 let [qw, qx, qy, qz] = self.quaternion;
892 let sinr_cosp = 2.0 * (qw * qx + qy * qz);
893 let cosr_cosp = 1.0 - 2.0 * (qx * qx + qy * qy);
894 sinr_cosp.atan2(cosr_cosp)
895 }
896
897 pub fn pitch_rad(&self) -> f64 {
899 let [qw, qx, qy, qz] = self.quaternion;
900 let sinp = 2.0 * (qw * qy - qz * qx);
901 sinp.clamp(-1.0, 1.0).asin()
902 }
903
904 pub fn yaw_rad(&self) -> f64 {
906 let [qw, qx, qy, qz] = self.quaternion;
907 let siny_cosp = 2.0 * (qw * qz + qx * qy);
908 let cosy_cosp = 1.0 - 2.0 * (qy * qy + qz * qz);
909 siny_cosp.atan2(cosy_cosp)
910 }
911
912 pub fn to_bytes(&self) -> Vec<u8> {
914 let n_floats = 1 + 3 + 3 + 4 + 3 + 3 + 15; let mut buf = Vec::with_capacity(n_floats * 8);
916 push_f64_le(&mut buf, self.timestamp);
917 for &v in &self.position_m {
918 push_f64_le(&mut buf, v);
919 }
920 for &v in &self.velocity_ms {
921 push_f64_le(&mut buf, v);
922 }
923 for &v in &self.quaternion {
924 push_f64_le(&mut buf, v);
925 }
926 for &v in &self.accel_bias {
927 push_f64_le(&mut buf, v);
928 }
929 for &v in &self.gyro_bias {
930 push_f64_le(&mut buf, v);
931 }
932 for &v in &self.cov_diagonal {
933 push_f64_le(&mut buf, v);
934 }
935 buf
936 }
937
938 pub fn from_bytes(data: &[u8]) -> Option<Self> {
940 let needed = 32 * 8;
941 if data.len() < needed {
942 return None;
943 }
944 let mut o = 0usize;
945 let mut next = || {
946 let v = read_f64_le(data, o);
947 o += 8;
948 v
949 };
950 let timestamp = next()?;
951 let position_m = [next()?, next()?, next()?];
952 let velocity_ms = [next()?, next()?, next()?];
953 let quaternion = [next()?, next()?, next()?, next()?];
954 let accel_bias = [next()?, next()?, next()?];
955 let gyro_bias = [next()?, next()?, next()?];
956 let mut cov = [0.0f64; 15];
957 for c in &mut cov {
958 *c = next()?;
959 }
960 Some(Self {
961 timestamp,
962 position_m,
963 velocity_ms,
964 quaternion,
965 accel_bias,
966 gyro_bias,
967 cov_diagonal: cov,
968 })
969 }
970}
971
972#[derive(Debug, Clone)]
981pub struct CalibrationMatrix {
982 pub name: String,
984 pub rows: usize,
986 pub cols: usize,
988 pub data: Vec<f64>,
990 pub input_unit: String,
992 pub output_unit: String,
994 pub calibration_timestamp: f64,
996}
997
998impl CalibrationMatrix {
999 pub fn identity(
1004 name: impl Into<String>,
1005 rows: usize,
1006 cols: usize,
1007 input_unit: impl Into<String>,
1008 output_unit: impl Into<String>,
1009 ) -> Self {
1010 let mut data = vec![0.0f64; rows * cols];
1011 for i in 0..rows.min(cols) {
1012 data[i * cols + i] = 1.0;
1013 }
1014 Self {
1015 name: name.into(),
1016 rows,
1017 cols,
1018 data,
1019 input_unit: input_unit.into(),
1020 output_unit: output_unit.into(),
1021 calibration_timestamp: 0.0,
1022 }
1023 }
1024
1025 pub fn get(&self, row: usize, col: usize) -> Option<f64> {
1027 if row >= self.rows || col >= self.cols {
1028 return None;
1029 }
1030 Some(self.data[row * self.cols + col])
1031 }
1032
1033 pub fn set(&mut self, row: usize, col: usize, value: f64) -> bool {
1035 if row >= self.rows || col >= self.cols {
1036 return false;
1037 }
1038 self.data[row * self.cols + col] = value;
1039 true
1040 }
1041
1042 pub fn apply(&self, v: &[f64]) -> Option<Vec<f64>> {
1046 if v.len() != self.cols {
1047 return None;
1048 }
1049 let mut out = vec![0.0f64; self.rows];
1050 for r in 0..self.rows {
1051 for c in 0..self.cols {
1052 out[r] += self.data[r * self.cols + c] * v[c];
1053 }
1054 }
1055 Some(out)
1056 }
1057
1058 pub fn frobenius_norm(&self) -> f64 {
1060 self.data.iter().map(|&v| v * v).sum::<f64>().sqrt()
1061 }
1062
1063 pub fn to_bytes(&self) -> Vec<u8> {
1068 let n = self.rows * self.cols;
1069 let mut buf = Vec::with_capacity(24 + n * 8);
1070 buf.extend_from_slice(&(self.rows as u64).to_le_bytes());
1071 buf.extend_from_slice(&(self.cols as u64).to_le_bytes());
1072 push_f64_le(&mut buf, self.calibration_timestamp);
1073 for &v in &self.data {
1074 push_f64_le(&mut buf, v);
1075 }
1076 buf
1077 }
1078
1079 pub fn from_bytes(data: &[u8]) -> Option<Self> {
1081 if data.len() < 24 {
1082 return None;
1083 }
1084 let rows = u64::from_le_bytes(data[0..8].try_into().ok()?) as usize;
1085 let cols = u64::from_le_bytes(data[8..16].try_into().ok()?) as usize;
1086 let calib_ts = read_f64_le(data, 16)?;
1087 let n = rows * cols;
1088 if data.len() < 24 + n * 8 {
1089 return None;
1090 }
1091 let mut elems = Vec::with_capacity(n);
1092 for i in 0..n {
1093 elems.push(read_f64_le(data, 24 + i * 8)?);
1094 }
1095 Some(Self {
1096 name: String::new(),
1097 rows,
1098 cols,
1099 data: elems,
1100 input_unit: String::new(),
1101 output_unit: String::new(),
1102 calibration_timestamp: calib_ts,
1103 })
1104 }
1105}
1106
1107#[derive(Debug, Clone)]
1113pub struct SensorFusionRecord {
1114 pub timestamp: f64,
1116 pub position_m: [f64; 3],
1118 pub velocity_ms: [f64; 3],
1120 pub euler_rad: [f64; 3],
1122 pub altitude_m: f64,
1124 pub temperature_c: f64,
1126 pub imu_innovation: f64,
1128}
1129
1130impl SensorFusionRecord {
1131 pub fn zero(timestamp: f64) -> Self {
1133 Self {
1134 timestamp,
1135 position_m: [0.0; 3],
1136 velocity_ms: [0.0; 3],
1137 euler_rad: [0.0; 3],
1138 altitude_m: 0.0,
1139 temperature_c: 20.0,
1140 imu_innovation: 0.0,
1141 }
1142 }
1143
1144 pub fn horizontal_speed(&self) -> f64 {
1146 let [vx, vy, _] = self.velocity_ms;
1147 (vx * vx + vy * vy).sqrt()
1148 }
1149
1150 pub fn speed_3d(&self) -> f64 {
1152 let [vx, vy, vz] = self.velocity_ms;
1153 (vx * vx + vy * vy + vz * vz).sqrt()
1154 }
1155
1156 pub fn to_bytes(&self) -> Vec<u8> {
1158 let mut buf = Vec::with_capacity(104);
1159 push_f64_le(&mut buf, self.timestamp);
1160 for &v in &self.position_m {
1161 push_f64_le(&mut buf, v);
1162 }
1163 for &v in &self.velocity_ms {
1164 push_f64_le(&mut buf, v);
1165 }
1166 for &v in &self.euler_rad {
1167 push_f64_le(&mut buf, v);
1168 }
1169 push_f64_le(&mut buf, self.altitude_m);
1170 push_f64_le(&mut buf, self.temperature_c);
1171 push_f64_le(&mut buf, self.imu_innovation);
1172 buf
1173 }
1174
1175 pub fn from_bytes(data: &[u8]) -> Option<Self> {
1177 if data.len() < 104 {
1178 return None;
1179 }
1180 let mut o = 0usize;
1181 let mut next = || {
1182 let v = read_f64_le(data, o);
1183 o += 8;
1184 v
1185 };
1186 Some(Self {
1187 timestamp: next()?,
1188 position_m: [next()?, next()?, next()?],
1189 velocity_ms: [next()?, next()?, next()?],
1190 euler_rad: [next()?, next()?, next()?],
1191 altitude_m: next()?,
1192 temperature_c: next()?,
1193 imu_innovation: next()?,
1194 })
1195 }
1196}
1197
1198pub const SENSOR_DATA_MAGIC: [u8; 8] = *b"OXISENS\0";
1204
1205pub const SENSOR_DATA_VERSION: u32 = 1;
1207
1208#[derive(Debug, Clone)]
1210pub struct SensorDataHeader {
1211 pub node_name: String,
1213 pub sample_rate_hz: f64,
1215 pub record_count: u64,
1217 pub created_at: f64,
1219}
1220
1221impl SensorDataHeader {
1222 pub fn new(
1224 node_name: impl Into<String>,
1225 sample_rate_hz: f64,
1226 record_count: u64,
1227 created_at: f64,
1228 ) -> Self {
1229 Self {
1230 node_name: node_name.into(),
1231 sample_rate_hz,
1232 record_count,
1233 created_at,
1234 }
1235 }
1236
1237 pub fn sample_interval_s(&self) -> f64 {
1239 if self.sample_rate_hz == 0.0 {
1240 f64::INFINITY
1241 } else {
1242 1.0 / self.sample_rate_hz
1243 }
1244 }
1245
1246 pub fn duration_s(&self) -> f64 {
1248 self.record_count as f64 * self.sample_interval_s()
1249 }
1250}
1251
1252#[derive(Debug, Clone)]
1260pub struct ChannelStats {
1261 pub count: u64,
1263 pub mean: f64,
1265 pub m2: f64,
1267 pub min: f64,
1269 pub max: f64,
1271}
1272
1273impl Default for ChannelStats {
1274 fn default() -> Self {
1275 Self {
1276 count: 0,
1277 mean: 0.0,
1278 m2: 0.0,
1279 min: f64::INFINITY,
1280 max: f64::NEG_INFINITY,
1281 }
1282 }
1283}
1284
1285impl ChannelStats {
1286 pub fn new() -> Self {
1288 Self::default()
1289 }
1290
1291 pub fn update(&mut self, value: f64) {
1293 self.count += 1;
1294 if value < self.min {
1295 self.min = value;
1296 }
1297 if value > self.max {
1298 self.max = value;
1299 }
1300 let delta = value - self.mean;
1301 self.mean += delta / self.count as f64;
1302 let delta2 = value - self.mean;
1303 self.m2 += delta * delta2;
1304 }
1305
1306 pub fn variance(&self) -> f64 {
1308 if self.count < 2 {
1309 0.0
1310 } else {
1311 self.m2 / (self.count - 1) as f64
1312 }
1313 }
1314
1315 pub fn std_dev(&self) -> f64 {
1317 self.variance().sqrt()
1318 }
1319
1320 pub fn range(&self) -> f64 {
1322 if self.count == 0 {
1323 0.0
1324 } else {
1325 self.max - self.min
1326 }
1327 }
1328}
1329
1330#[derive(Debug, Clone)]
1337pub struct MultiChannelSensorLog {
1338 pub channel_names: Vec<String>,
1340 pub rows: Vec<Vec<f64>>,
1342}
1343
1344impl MultiChannelSensorLog {
1345 pub fn new(channel_names: Vec<String>) -> Self {
1347 Self {
1348 channel_names,
1349 rows: Vec::new(),
1350 }
1351 }
1352
1353 pub fn num_channels(&self) -> usize {
1355 self.channel_names.len()
1356 }
1357
1358 pub fn push_row(&mut self, timestamp: f64, values: &[f64]) -> bool {
1361 if values.len() != self.num_channels() {
1362 return false;
1363 }
1364 let mut row = Vec::with_capacity(1 + values.len());
1365 row.push(timestamp);
1366 row.extend_from_slice(values);
1367 self.rows.push(row);
1368 true
1369 }
1370
1371 pub fn channel_values(&self, idx: usize) -> Vec<f64> {
1373 if idx >= self.num_channels() {
1374 return vec![];
1375 }
1376 self.rows.iter().map(|r| r[idx + 1]).collect()
1377 }
1378
1379 pub fn channel_stats(&self, idx: usize) -> ChannelStats {
1381 let mut s = ChannelStats::new();
1382 for v in self.channel_values(idx) {
1383 s.update(v);
1384 }
1385 s
1386 }
1387
1388 pub fn to_csv_lines(&self) -> Vec<String> {
1390 let header = {
1391 let mut h = String::from("timestamp");
1392 for name in &self.channel_names {
1393 h.push(',');
1394 h.push_str(name);
1395 }
1396 h
1397 };
1398 let mut lines = vec![header];
1399 for row in &self.rows {
1400 let parts: Vec<String> = row.iter().map(|v| format!("{:.6}", v)).collect();
1401 lines.push(parts.join(","));
1402 }
1403 lines
1404 }
1405}
1406
1407#[cfg(test)]
1412mod tests {
1413 use super::*;
1414
1415 #[test]
1418 fn test_imu_sample_round_trip() {
1419 let s = ImuSample::new(
1420 1.23,
1421 [1.0, -2.0, 9.8],
1422 [0.01, -0.02, 0.003],
1423 [24.1, -5.0, 42.0],
1424 );
1425 let bytes = s.to_bytes();
1426 assert_eq!(bytes.len(), 80);
1427 let s2 = ImuSample::from_bytes(&bytes).unwrap();
1428 assert!((s2.timestamp - s.timestamp).abs() < 1e-12);
1429 for k in 0..3 {
1430 assert!((s2.accel[k] - s.accel[k]).abs() < 1e-12);
1431 assert!((s2.gyro[k] - s.gyro[k]).abs() < 1e-12);
1432 assert!((s2.mag[k] - s.mag[k]).abs() < 1e-12);
1433 }
1434 }
1435
1436 #[test]
1437 fn test_imu_sample_accel_magnitude() {
1438 let s = ImuSample::new(0.0, [3.0, 4.0, 0.0], [0.0; 3], [0.0; 3]);
1439 assert!((s.accel_magnitude() - 5.0).abs() < 1e-12);
1440 }
1441
1442 #[test]
1443 fn test_imu_sample_gyro_magnitude() {
1444 let s = ImuSample::new(0.0, [0.0; 3], [0.0, 0.0, 1.0], [0.0; 3]);
1445 assert!((s.gyro_magnitude() - 1.0).abs() < 1e-12);
1446 }
1447
1448 #[test]
1449 fn test_imu_sample_from_bytes_truncated() {
1450 let short = [0u8; 10];
1451 assert!(ImuSample::from_bytes(&short).is_none());
1452 }
1453
1454 #[test]
1457 fn test_imu_stream_round_trip() {
1458 let mut stream = ImuStream::new();
1459 for i in 0..5 {
1460 stream.push(ImuSample::new(
1461 i as f64 * 0.01,
1462 [0.0, 0.0, 9.81],
1463 [0.0; 3],
1464 [0.0; 3],
1465 ));
1466 }
1467 let bytes = stream.to_bytes();
1468 let s2 = ImuStream::from_bytes(&bytes).unwrap();
1469 assert_eq!(s2.len(), 5);
1470 assert!((s2.samples[0].timestamp).abs() < 1e-12);
1471 }
1472
1473 #[test]
1474 fn test_imu_stream_mean_accel() {
1475 let mut stream = ImuStream::new();
1476 stream.push(ImuSample::new(0.0, [2.0, 0.0, 0.0], [0.0; 3], [0.0; 3]));
1477 stream.push(ImuSample::new(0.01, [4.0, 0.0, 0.0], [0.0; 3], [0.0; 3]));
1478 let m = stream.mean_accel();
1479 assert!((m[0] - 3.0).abs() < 1e-12);
1480 }
1481
1482 #[test]
1483 fn test_imu_stream_empty_mean() {
1484 let stream = ImuStream::new();
1485 assert_eq!(stream.mean_accel(), [0.0; 3]);
1486 assert_eq!(stream.mean_gyro(), [0.0; 3]);
1487 }
1488
1489 #[test]
1492 fn test_pressure_sample_round_trip() {
1493 let s = PressureSample::new(5.0, 101325.0, 22.5);
1494 let bytes = s.to_bytes();
1495 let s2 = PressureSample::from_bytes(&bytes).unwrap();
1496 assert!((s2.pressure_pa - 101325.0).abs() < 1e-6);
1497 assert!((s2.temperature_c - 22.5).abs() < 1e-9);
1498 }
1499
1500 #[test]
1501 fn test_pressure_altitude_sea_level() {
1502 let s = PressureSample::new(0.0, 101325.0, 15.0);
1503 let alt = s.altitude_m(101325.0, 0.0);
1504 assert!(
1505 alt.abs() < 1.0,
1506 "altitude at sea-level pressure should be ~0 m, got {}",
1507 alt
1508 );
1509 }
1510
1511 #[test]
1512 fn test_pressure_altitude_decreases_with_pressure() {
1513 let s_low = PressureSample::new(0.0, 101325.0, 15.0);
1514 let s_high = PressureSample::new(0.0, 89875.0, 10.0);
1515 let alt_low = s_low.altitude_m(101325.0, 0.0);
1516 let alt_high = s_high.altitude_m(101325.0, 0.0);
1517 assert!(alt_high > alt_low, "lower pressure → higher altitude");
1518 }
1519
1520 #[test]
1521 fn test_pressure_time_series_statistics() {
1522 let mut ts = PressureTimeSeries::new();
1523 for i in 0..10 {
1524 ts.push(PressureSample::new(
1525 i as f64,
1526 100000.0 + i as f64 * 100.0,
1527 20.0,
1528 ));
1529 }
1530 assert!((ts.min_pressure() - 100000.0).abs() < 1e-6);
1531 assert!((ts.max_pressure() - 100900.0).abs() < 1e-6);
1532 assert!((ts.mean_pressure() - 100450.0).abs() < 1e-6);
1533 }
1534
1535 #[test]
1538 fn test_temp_probe_round_trip() {
1539 let r = TempProbeReading::new(3, 1.5, 37.5, [1.0, 2.0, 3.0]);
1540 let bytes = r.to_bytes();
1541 assert_eq!(bytes.len(), 44);
1542 let r2 = TempProbeReading::from_bytes(&bytes).unwrap();
1543 assert_eq!(r2.probe_id, 3);
1544 assert!((r2.temperature_c - 37.5).abs() < 1e-9);
1545 }
1546
1547 #[test]
1548 fn test_temp_array_snapshot_stats() {
1549 let snap = TempArraySnapshot::new(0.0, vec![10.0, 20.0, 30.0, 40.0]);
1550 assert!((snap.min_temp() - 10.0).abs() < 1e-9);
1551 assert!((snap.max_temp() - 40.0).abs() < 1e-9);
1552 assert!((snap.mean_temp() - 25.0).abs() < 1e-9);
1553 }
1554
1555 #[test]
1556 fn test_temp_array_rms_deviation() {
1557 let snap = TempArraySnapshot::new(0.0, vec![20.0, 20.0, 20.0]);
1559 assert!(snap.rms_deviation() < 1e-9);
1560 }
1561
1562 #[test]
1565 fn test_strain_gauge_round_trip() {
1566 let s = StrainGaugeSample::new(0, 2.0, 0.001, 500.0, 23.5);
1567 let bytes = s.to_bytes();
1568 let s2 = StrainGaugeSample::from_bytes(&bytes).unwrap();
1569 assert!((s2.microstrain - 500.0).abs() < 1e-9);
1570 }
1571
1572 #[test]
1573 fn test_strain_gauge_stress() {
1574 let s = StrainGaugeSample::new(0, 0.0, 0.0, 1000.0, 20.0);
1576 let stress = s.stress_mpa(200.0);
1577 assert!((stress - 200.0).abs() < 1e-6);
1578 }
1579
1580 #[test]
1583 fn test_force_torque_round_trip() {
1584 let s = ForceTorqueSample::new(3.125, [10.0, 0.0, -5.0], [0.0, 1.0, 0.0]);
1585 let bytes = s.to_bytes();
1586 assert_eq!(bytes.len(), 56);
1587 let s2 = ForceTorqueSample::from_bytes(&bytes).unwrap();
1588 assert!((s2.force_n[0] - 10.0).abs() < 1e-9);
1589 assert!((s2.torque_nm[1] - 1.0).abs() < 1e-9);
1590 }
1591
1592 #[test]
1593 fn test_force_torque_magnitudes() {
1594 let s = ForceTorqueSample::new(0.0, [3.0, 4.0, 0.0], [0.0, 0.0, 5.0]);
1595 assert!((s.force_magnitude() - 5.0).abs() < 1e-12);
1596 assert!((s.torque_magnitude() - 5.0).abs() < 1e-12);
1597 }
1598
1599 #[test]
1600 fn test_force_torque_log_peaks() {
1601 let mut log = ForceTorqueLog::new();
1602 log.push(ForceTorqueSample::new(
1603 0.0,
1604 [1.0, 0.0, 0.0],
1605 [0.0, 0.0, 0.5],
1606 ));
1607 log.push(ForceTorqueSample::new(
1608 1.0,
1609 [10.0, 0.0, 0.0],
1610 [0.0, 0.0, 2.0],
1611 ));
1612 assert!((log.peak_force() - 10.0).abs() < 1e-9);
1613 assert!((log.peak_torque() - 2.0).abs() < 1e-9);
1614 }
1615
1616 #[test]
1619 fn test_encoder_round_trip() {
1620 let s = OpticalEncoderSample::new(0, 0.5, std::f64::consts::PI, 1.0, 2048);
1621 let bytes = s.to_bytes();
1622 assert_eq!(bytes.len(), 36);
1623 let s2 = OpticalEncoderSample::from_bytes(&bytes).unwrap();
1624 assert!((s2.angle_rad - std::f64::consts::PI).abs() < 1e-9);
1625 assert_eq!(s2.ticks, 2048);
1626 }
1627
1628 #[test]
1629 fn test_encoder_angle_conversion() {
1630 let s = OpticalEncoderSample::new(0, 0.0, std::f64::consts::PI, 0.0, 0);
1631 assert!((s.angle_deg() - 180.0).abs() < 1e-9);
1632 }
1633
1634 #[test]
1635 fn test_encoder_rpm() {
1636 let s = OpticalEncoderSample::new(0, 0.0, 0.0, 2.0 * std::f64::consts::PI, 0);
1638 assert!((s.velocity_rpm() - 60.0).abs() < 1e-9);
1639 }
1640
1641 #[test]
1644 fn test_lvdt_round_trip() {
1645 let s = LvdtSample::new(1, 0.1, 0.025, 2.5);
1646 let bytes = s.to_bytes();
1647 assert_eq!(bytes.len(), 28);
1648 let s2 = LvdtSample::from_bytes(&bytes).unwrap();
1649 assert!((s2.displacement_m - 0.025).abs() < 1e-12);
1650 }
1651
1652 #[test]
1653 fn test_lvdt_mm_conversion() {
1654 let s = LvdtSample::new(0, 0.0, 0.005, 0.5);
1655 assert!((s.displacement_mm() - 5.0).abs() < 1e-9);
1656 }
1657
1658 #[test]
1661 fn test_thermocouple_linear_poly() {
1662 let cal = ThermocoupleCalibration::new(
1664 ThermocoupleType::TypeK,
1665 0.0,
1666 vec![0.0, 25.0],
1667 [0.0, 10.0],
1668 );
1669 let t = cal.convert(4.0).unwrap();
1670 assert!((t - 100.0).abs() < 1e-9);
1671 }
1672
1673 #[test]
1674 fn test_thermocouple_out_of_range() {
1675 let cal = ThermocoupleCalibration::new(
1676 ThermocoupleType::TypeK,
1677 0.0,
1678 vec![0.0, 25.0],
1679 [0.0, 10.0],
1680 );
1681 assert!(cal.convert(-1.0).is_none());
1682 assert!(cal.convert(11.0).is_none());
1683 }
1684
1685 #[test]
1686 fn test_thermocouple_type_k_linear() {
1687 let t = ThermocoupleCalibration::type_k_linear(1.0);
1689 assert!((t - 1.0 / 0.041).abs() < 1e-6);
1690 }
1691
1692 #[test]
1693 fn test_thermocouple_cold_junction_offset() {
1694 let cal =
1696 ThermocoupleCalibration::new(ThermocoupleType::TypeJ, 20.0, vec![5.0], [0.0, 100.0]);
1697 let t = cal.convert(50.0).unwrap();
1698 assert!((t - 25.0).abs() < 1e-9);
1699 }
1700
1701 #[test]
1704 fn test_kalman_state_round_trip() {
1705 let mut k = KalmanState::identity(100.0);
1706 k.position_m = [1.0, 2.0, 3.0];
1707 k.velocity_ms = [0.5, -0.1, 0.0];
1708 let bytes = k.to_bytes();
1709 let k2 = KalmanState::from_bytes(&bytes).unwrap();
1710 assert!((k2.timestamp - 100.0).abs() < 1e-9);
1711 assert!((k2.position_m[0] - 1.0).abs() < 1e-9);
1712 }
1713
1714 #[test]
1715 fn test_kalman_identity_quaternion() {
1716 let k = KalmanState::identity(0.0);
1717 assert!(k.quaternion_is_unit());
1718 }
1719
1720 #[test]
1721 fn test_kalman_euler_zero_at_identity() {
1722 let k = KalmanState::identity(0.0);
1723 assert!(k.roll_rad().abs() < 1e-9);
1724 assert!(k.pitch_rad().abs() < 1e-9);
1725 assert!(k.yaw_rad().abs() < 1e-9);
1726 }
1727
1728 #[test]
1731 fn test_calibration_matrix_identity_apply() {
1732 let m = CalibrationMatrix::identity("test", 3, 3, "m/s²", "m/s²");
1733 let v = [1.0, 2.0, 3.0];
1734 let out = m.apply(&v).unwrap();
1735 for k in 0..3 {
1736 assert!((out[k] - v[k]).abs() < 1e-12);
1737 }
1738 }
1739
1740 #[test]
1741 fn test_calibration_matrix_round_trip() {
1742 let mut m = CalibrationMatrix::identity("cm", 2, 2, "in", "out");
1743 m.set(0, 1, 0.5);
1744 let bytes = m.to_bytes();
1745 let m2 = CalibrationMatrix::from_bytes(&bytes).unwrap();
1746 assert_eq!(m2.rows, 2);
1747 assert_eq!(m2.cols, 2);
1748 assert!((m2.get(0, 1).unwrap() - 0.5).abs() < 1e-12);
1749 }
1750
1751 #[test]
1752 fn test_calibration_matrix_frobenius_identity() {
1753 let m = CalibrationMatrix::identity("id", 3, 3, "a", "b");
1754 assert!((m.frobenius_norm() - 3.0f64.sqrt()).abs() < 1e-9);
1756 }
1757
1758 #[test]
1759 fn test_calibration_matrix_wrong_vector_size() {
1760 let m = CalibrationMatrix::identity("id", 3, 3, "a", "b");
1761 assert!(m.apply(&[1.0, 2.0]).is_none());
1762 }
1763
1764 #[test]
1767 fn test_fusion_record_round_trip() {
1768 let mut r = SensorFusionRecord::zero(9.9);
1769 r.velocity_ms = [3.0, 4.0, 0.0];
1770 r.altitude_m = 150.0;
1771 let bytes = r.to_bytes();
1772 assert_eq!(bytes.len(), 104);
1773 let r2 = SensorFusionRecord::from_bytes(&bytes).unwrap();
1774 assert!((r2.altitude_m - 150.0).abs() < 1e-9);
1775 assert!((r2.horizontal_speed() - 5.0).abs() < 1e-9);
1776 }
1777
1778 #[test]
1779 fn test_fusion_record_speed_3d() {
1780 let mut r = SensorFusionRecord::zero(0.0);
1781 r.velocity_ms = [1.0, 2.0, 2.0];
1782 assert!((r.speed_3d() - 3.0).abs() < 1e-9);
1783 }
1784
1785 #[test]
1788 fn test_channel_stats_basic() {
1789 let mut s = ChannelStats::new();
1791 for v in [0.0_f64, 2.0, 4.0] {
1792 s.update(v);
1793 }
1794 assert!((s.mean - 2.0).abs() < 1e-9);
1795 assert!((s.std_dev() - 2.0).abs() < 1e-9);
1796 }
1797
1798 #[test]
1799 fn test_channel_stats_single_sample() {
1800 let mut s = ChannelStats::new();
1801 s.update(42.0);
1802 assert!((s.mean - 42.0).abs() < 1e-12);
1803 assert!(s.variance() < 1e-12);
1804 assert!((s.range()).abs() < 1e-12);
1805 }
1806
1807 #[test]
1808 fn test_channel_stats_empty_variance() {
1809 let s = ChannelStats::new();
1810 assert_eq!(s.variance(), 0.0);
1811 assert_eq!(s.range(), 0.0);
1812 }
1813
1814 #[test]
1817 fn test_multi_channel_push_and_retrieve() {
1818 let mut log = MultiChannelSensorLog::new(vec!["ax".into(), "ay".into()]);
1819 assert!(log.push_row(0.0, &[1.0, 2.0]));
1820 assert!(log.push_row(0.01, &[3.0, 4.0]));
1821 let ax_vals = log.channel_values(0);
1822 assert_eq!(ax_vals, vec![1.0, 3.0]);
1823 }
1824
1825 #[test]
1826 fn test_multi_channel_wrong_row_length() {
1827 let mut log = MultiChannelSensorLog::new(vec!["x".into(), "y".into()]);
1828 assert!(!log.push_row(0.0, &[1.0])); }
1830
1831 #[test]
1832 fn test_multi_channel_csv_header() {
1833 let log = MultiChannelSensorLog::new(vec!["p".into(), "q".into()]);
1834 let lines = log.to_csv_lines();
1835 assert_eq!(lines[0], "timestamp,p,q");
1836 }
1837
1838 #[test]
1839 fn test_multi_channel_stats() {
1840 let mut log = MultiChannelSensorLog::new(vec!["v".into()]);
1841 for i in 0..5 {
1842 log.push_row(i as f64, &[i as f64 * 2.0]);
1843 }
1844 let stats = log.channel_stats(0);
1845 assert!((stats.mean - 4.0).abs() < 1e-9);
1846 assert!((stats.min).abs() < 1e-9);
1847 assert!((stats.max - 8.0).abs() < 1e-9);
1848 }
1849
1850 #[test]
1853 fn test_sensor_data_header_interval() {
1854 let hdr = SensorDataHeader::new("node1", 100.0, 1000, 0.0);
1855 assert!((hdr.sample_interval_s() - 0.01).abs() < 1e-12);
1856 assert!((hdr.duration_s() - 10.0).abs() < 1e-9);
1857 }
1858
1859 #[test]
1860 fn test_sensor_data_header_zero_rate() {
1861 let hdr = SensorDataHeader::new("n", 0.0, 100, 0.0);
1862 assert!(hdr.sample_interval_s().is_infinite());
1863 }
1864}