Skip to main content

oxiphysics_io/
sensor_data_io.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Sensor data I/O for the OxiPhysics engine.
6//!
7//! Provides structured types and serialization/deserialization helpers for
8//! common physical sensor data streams including:
9//!
10//! - **IMU** (accelerometer, gyroscope, magnetometer)
11//! - **Pressure sensor** time series
12//! - **Temperature sensor array**
13//! - **Strain gauge** data
14//! - **Force/torque sensor** (6-DOF)
15//! - **Optical encoder** (position/velocity)
16//! - **LVDT displacement** sensor
17//! - **Thermocouple calibration** tables
18//! - **Sensor fusion** data (Kalman-filtered state)
19//! - **Calibration matrix** storage and retrieval
20
21#![allow(dead_code)]
22#![allow(clippy::too_many_arguments)]
23
24// ─────────────────────────────────────────────────────────────────────────────
25// Common helpers
26// ─────────────────────────────────────────────────────────────────────────────
27
28/// Read a little-endian `f64` from a byte slice at the given offset.
29///
30/// Returns `None` when fewer than 8 bytes remain.
31fn 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
39/// Append the little-endian encoding of `v` to `buf`.
40fn push_f64_le(buf: &mut Vec<u8>, v: f64) {
41    buf.extend_from_slice(&v.to_le_bytes());
42}
43
44// ─────────────────────────────────────────────────────────────────────────────
45// ImuSample — inertial measurement unit
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// A single sample from a 9-axis IMU.
49///
50/// All coordinate frames are body-fixed (right-hand rule, X forward, Y left,
51/// Z up) unless otherwise stated.
52#[derive(Debug, Clone, PartialEq)]
53pub struct ImuSample {
54    /// Timestamp in seconds.
55    pub timestamp: f64,
56    /// Accelerometer reading `[ax, ay, az]` in m/s².
57    pub accel: [f64; 3],
58    /// Gyroscope reading `[ωx, ωy, ωz]` in rad/s.
59    pub gyro: [f64; 3],
60    /// Magnetometer reading `[bx, by, bz]` in µT.
61    pub mag: [f64; 3],
62}
63
64impl ImuSample {
65    /// Construct a new sample from component arrays.
66    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    /// Serialize to 80 bytes of little-endian `f64`.
76    ///
77    /// Layout: `[t, ax, ay, az, ωx, ωy, ωz, bx, by, bz]` (10 × 8 bytes).
78    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    /// Deserialize from 80 bytes.  Returns `None` on truncated input.
94    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    /// Magnitude of the accelerometer vector in m/s².
113    pub fn accel_magnitude(&self) -> f64 {
114        let [ax, ay, az] = self.accel;
115        (ax * ax + ay * ay + az * az).sqrt()
116    }
117
118    /// Magnitude of the angular-rate vector in rad/s.
119    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// ─────────────────────────────────────────────────────────────────────────────
126// ImuStream — buffered IMU log
127// ─────────────────────────────────────────────────────────────────────────────
128
129/// A buffered sequence of [`ImuSample`] records with basic statistics.
130#[derive(Debug, Clone, Default)]
131pub struct ImuStream {
132    /// Raw samples in chronological order.
133    pub samples: Vec<ImuSample>,
134}
135
136impl ImuStream {
137    /// Create an empty stream.
138    pub fn new() -> Self {
139        Self {
140            samples: Vec::new(),
141        }
142    }
143
144    /// Append a sample.
145    pub fn push(&mut self, sample: ImuSample) {
146        self.samples.push(sample);
147    }
148
149    /// Return the number of samples.
150    pub fn len(&self) -> usize {
151        self.samples.len()
152    }
153
154    /// Return `true` when the stream contains no samples.
155    pub fn is_empty(&self) -> bool {
156        self.samples.is_empty()
157    }
158
159    /// Compute the mean accelerometer vector over all samples.
160    ///
161    /// Returns `[0,0,0]` for an empty stream.
162    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    /// Compute the mean gyroscope vector over all samples.
177    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    /// Serialize the entire stream to a byte vector.
192    ///
193    /// Format: 8-byte little-endian `u64` count, followed by concatenated
194    /// 80-byte sample records.
195    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    /// Deserialize a stream from bytes produced by [`ImuStream::to_bytes`].
206    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// ─────────────────────────────────────────────────────────────────────────────
223// PressureSample — barometric / differential / absolute pressure sensor
224// ─────────────────────────────────────────────────────────────────────────────
225
226/// A single pressure sensor reading with timestamp.
227#[derive(Debug, Clone, PartialEq)]
228pub struct PressureSample {
229    /// Timestamp in seconds.
230    pub timestamp: f64,
231    /// Pressure reading in Pascal.
232    pub pressure_pa: f64,
233    /// Temperature of the pressure sensor die in °C.
234    pub temperature_c: f64,
235}
236
237impl PressureSample {
238    /// Create a new sample.
239    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    /// Altitude estimate from pressure using the international barometric formula.
248    ///
249    /// Reference pressure `p0` in Pa and reference altitude `h0` in metres.
250    pub fn altitude_m(&self, p0: f64, h0: f64) -> f64 {
251        const T0: f64 = 288.15; // sea-level temperature K
252        const L: f64 = 0.0065; // lapse rate K/m
253        const R: f64 = 8.3144598; // J/(mol·K)
254        const M: f64 = 0.0289644; // kg/mol
255        const G: f64 = 9.80665; // m/s²
256        let exp = R * L / (G * M);
257        h0 + (T0 / L) * (1.0 - (self.pressure_pa / p0).powf(exp))
258    }
259
260    /// Serialize to 24 bytes.
261    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    /// Deserialize from 24 bytes.
270    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/// A time-ordered series of pressure samples.
283#[derive(Debug, Clone, Default)]
284pub struct PressureTimeSeries {
285    /// The underlying samples.
286    pub samples: Vec<PressureSample>,
287}
288
289impl PressureTimeSeries {
290    /// Create an empty series.
291    pub fn new() -> Self {
292        Self {
293            samples: Vec::new(),
294        }
295    }
296
297    /// Append a sample.
298    pub fn push(&mut self, s: PressureSample) {
299        self.samples.push(s);
300    }
301
302    /// Minimum pressure over the series in Pa, or `f64::NAN` if empty.
303    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    /// Maximum pressure over the series in Pa, or `f64::NAN` if empty.
311    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    /// Mean pressure in Pa.
319    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// ─────────────────────────────────────────────────────────────────────────────
329// TemperatureSensorArray — spatially distributed temperature probes
330// ─────────────────────────────────────────────────────────────────────────────
331
332/// A reading from one probe in a distributed temperature array.
333#[derive(Debug, Clone, PartialEq)]
334pub struct TempProbeReading {
335    /// Probe identifier (0-based).
336    pub probe_id: u32,
337    /// Timestamp in seconds.
338    pub timestamp: f64,
339    /// Temperature in °C.
340    pub temperature_c: f64,
341    /// 3-D position of the probe `[x, y, z]` in metres.
342    pub position: [f64; 3],
343}
344
345impl TempProbeReading {
346    /// Create a new reading.
347    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    /// Serialize to 44 bytes (4 + 8 + 8 + 3×8).
357    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    /// Deserialize from bytes.
369    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/// Snapshot of all probes in a temperature array at one instant.
388#[derive(Debug, Clone, Default)]
389pub struct TempArraySnapshot {
390    /// Timestamp for this snapshot.
391    pub timestamp: f64,
392    /// Temperature readings, one per probe.
393    pub readings: Vec<f64>,
394}
395
396impl TempArraySnapshot {
397    /// Create a new snapshot.
398    pub fn new(timestamp: f64, readings: Vec<f64>) -> Self {
399        Self {
400            timestamp,
401            readings,
402        }
403    }
404
405    /// Number of probes.
406    pub fn num_probes(&self) -> usize {
407        self.readings.len()
408    }
409
410    /// Maximum temperature in the array.
411    pub fn max_temp(&self) -> f64 {
412        self.readings
413            .iter()
414            .cloned()
415            .fold(f64::NEG_INFINITY, f64::max)
416    }
417
418    /// Minimum temperature in the array.
419    pub fn min_temp(&self) -> f64 {
420        self.readings.iter().cloned().fold(f64::INFINITY, f64::min)
421    }
422
423    /// Mean temperature in the array.
424    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    /// Root-mean-square deviation from the mean.
432    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// ─────────────────────────────────────────────────────────────────────────────
448// StrainGaugeSample
449// ─────────────────────────────────────────────────────────────────────────────
450
451/// A reading from a resistive strain gauge.
452#[derive(Debug, Clone, PartialEq)]
453pub struct StrainGaugeSample {
454    /// Gauge identifier (0-based).
455    pub gauge_id: u32,
456    /// Timestamp in seconds.
457    pub timestamp: f64,
458    /// Raw bridge voltage imbalance in Volts.
459    pub bridge_voltage_v: f64,
460    /// Calibrated strain in µm/m (microstrain).
461    pub microstrain: f64,
462    /// Gauge temperature in °C (for temperature compensation).
463    pub temperature_c: f64,
464}
465
466impl StrainGaugeSample {
467    /// Create a new sample.
468    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    /// Stress estimate in MPa from microstrain and Young's modulus `e_gpa`.
485    ///
486    /// Uses Hooke's law: σ = E · ε, with ε in µm/m → m/m.
487    pub fn stress_mpa(&self, e_gpa: f64) -> f64 {
488        self.microstrain * 1e-6 * e_gpa * 1e3
489    }
490
491    /// Serialize to 36 bytes (4-byte gauge_id + four f64 fields).
492    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    /// Deserialize from bytes.
503    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// ─────────────────────────────────────────────────────────────────────────────
519// ForceTorqueSample — 6-DOF force/torque sensor
520// ─────────────────────────────────────────────────────────────────────────────
521
522/// A single reading from a 6-DOF force/torque sensor.
523///
524/// Forces are in Newtons; torques are in Newton·metres.
525#[derive(Debug, Clone, PartialEq)]
526pub struct ForceTorqueSample {
527    /// Timestamp in seconds.
528    pub timestamp: f64,
529    /// Force vector `[Fx, Fy, Fz]` in N.
530    pub force_n: [f64; 3],
531    /// Torque vector `[Tx, Ty, Tz]` in N·m.
532    pub torque_nm: [f64; 3],
533}
534
535impl ForceTorqueSample {
536    /// Create a new 6-DOF sample.
537    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    /// Net force magnitude in N.
546    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    /// Net torque magnitude in N·m.
552    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    /// Serialize to 56 bytes (8 + 3×8 + 3×8).
558    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    /// Deserialize from 56 bytes.
571    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/// A log of sequential [`ForceTorqueSample`] records.
590#[derive(Debug, Clone, Default)]
591pub struct ForceTorqueLog {
592    /// All samples, newest appended last.
593    pub samples: Vec<ForceTorqueSample>,
594}
595
596impl ForceTorqueLog {
597    /// Create an empty log.
598    pub fn new() -> Self {
599        Self {
600            samples: Vec::new(),
601        }
602    }
603
604    /// Append a sample.
605    pub fn push(&mut self, s: ForceTorqueSample) {
606        self.samples.push(s);
607    }
608
609    /// Peak force magnitude seen across all samples.
610    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    /// Peak torque magnitude seen across all samples.
618    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// ─────────────────────────────────────────────────────────────────────────────
627// OpticalEncoderSample — incremental / absolute rotary encoder
628// ─────────────────────────────────────────────────────────────────────────────
629
630/// A reading from an optical encoder.
631#[derive(Debug, Clone, PartialEq)]
632pub struct OpticalEncoderSample {
633    /// Encoder axis ID.
634    pub axis_id: u32,
635    /// Timestamp in seconds.
636    pub timestamp: f64,
637    /// Absolute angle in radians.
638    pub angle_rad: f64,
639    /// Angular velocity in rad/s (computed or measured).
640    pub velocity_rad_s: f64,
641    /// Encoder count (raw ticks).
642    pub ticks: i64,
643}
644
645impl OpticalEncoderSample {
646    /// Create a new sample.
647    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    /// Angle in degrees.
664    pub fn angle_deg(&self) -> f64 {
665        self.angle_rad.to_degrees()
666    }
667
668    /// Angular velocity in RPM.
669    pub fn velocity_rpm(&self) -> f64 {
670        self.velocity_rad_s * 60.0 / (2.0 * std::f64::consts::PI)
671    }
672
673    /// Serialize to 36 bytes (4 + 8 + 8 + 8 + 8).
674    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    /// Deserialize from bytes.
685    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// ─────────────────────────────────────────────────────────────────────────────
702// LvdtSample — Linear Variable Differential Transformer
703// ─────────────────────────────────────────────────────────────────────────────
704
705/// A reading from an LVDT displacement sensor.
706#[derive(Debug, Clone, PartialEq)]
707pub struct LvdtSample {
708    /// Sensor ID.
709    pub sensor_id: u32,
710    /// Timestamp in seconds.
711    pub timestamp: f64,
712    /// Displacement in metres (positive = extension).
713    pub displacement_m: f64,
714    /// Raw output voltage in Volts.
715    pub raw_voltage_v: f64,
716}
717
718impl LvdtSample {
719    /// Create a new sample.
720    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    /// Displacement in millimetres.
730    pub fn displacement_mm(&self) -> f64 {
731        self.displacement_m * 1e3
732    }
733
734    /// Serialize to 28 bytes.
735    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    /// Deserialize from bytes.
745    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// ─────────────────────────────────────────────────────────────────────────────
760// ThermocoupleCalibration — Seebeck-coefficient polynomial table
761// ─────────────────────────────────────────────────────────────────────────────
762
763/// Thermocouple type classification.
764#[derive(Debug, Clone, Copy, PartialEq, Eq)]
765pub enum ThermocoupleType {
766    /// Type K (chromel–alumel), −200…+1350 °C.
767    TypeK,
768    /// Type J (iron–constantan), −210…+1200 °C.
769    TypeJ,
770    /// Type T (copper–constantan), −250…+400 °C.
771    TypeT,
772    /// Type E (chromel–constantan), −270…+1000 °C.
773    TypeE,
774    /// Type N (nicrosil–nisil), −270…+1300 °C.
775    TypeN,
776    /// Type R (platinum-13%rhodium / platinum), 0…+1768 °C.
777    TypeR,
778    /// Type S (platinum-10%rhodium / platinum), 0…+1768 °C.
779    TypeS,
780    /// Type B (platinum-30%rhodium / platinum-6%rhodium), 0…+1820 °C.
781    TypeB,
782}
783
784/// Polynomial calibration record for a thermocouple.
785///
786/// The EMF (Volts) to temperature (°C) relationship is expressed as
787/// `T = Σ cᵢ · V^i` evaluated at ambient-compensated EMF.
788#[derive(Debug, Clone)]
789pub struct ThermocoupleCalibration {
790    /// Thermocouple type.
791    pub tc_type: ThermocoupleType,
792    /// Cold-junction (reference) temperature in °C.
793    pub cold_junction_c: f64,
794    /// Polynomial coefficients `[c0, c1, ..., cn]` for EMF (mV) → °C.
795    pub poly_coeffs: Vec<f64>,
796    /// Valid EMF range `[emf_min_mv, emf_max_mv]` in mV.
797    pub valid_range_mv: [f64; 2],
798}
799
800impl ThermocoupleCalibration {
801    /// Create a calibration record.
802    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    /// Evaluate the calibration polynomial at EMF `emf_mv` in mV.
817    ///
818    /// Returns `None` if `emf_mv` is outside the valid range.
819    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    /// Approximate type-K conversion for a raw EMF in mV (for quick checks).
834    ///
835    /// Uses a simplified linear sensitivity of 41 µV/°C.
836    pub fn type_k_linear(emf_mv: f64) -> f64 {
837        emf_mv / 0.041
838    }
839}
840
841// ─────────────────────────────────────────────────────────────────────────────
842// KalmanState — 15-state inertial navigation Kalman filter output
843// ─────────────────────────────────────────────────────────────────────────────
844
845/// The fused navigation state from a Kalman filter.
846///
847/// State vector: position (3), velocity (3), orientation quaternion (4),
848/// accelerometer bias (3), gyroscope bias (3) → total 16 scalars.
849/// The covariance diagonal (15 terms) is stored separately.
850#[derive(Debug, Clone)]
851pub struct KalmanState {
852    /// Timestamp in seconds.
853    pub timestamp: f64,
854    /// Position `[x, y, z]` in metres (ECEF or local NED depending on frame).
855    pub position_m: [f64; 3],
856    /// Velocity `[vx, vy, vz]` in m/s.
857    pub velocity_ms: [f64; 3],
858    /// Orientation quaternion `[qw, qx, qy, qz]` (unit quaternion).
859    pub quaternion: [f64; 4],
860    /// Accelerometer bias estimate `[bax, bay, baz]` in m/s².
861    pub accel_bias: [f64; 3],
862    /// Gyroscope bias estimate `[bgx, bgy, bgz]` in rad/s.
863    pub gyro_bias: [f64; 3],
864    /// Diagonal of the 15×15 error covariance matrix.
865    pub cov_diagonal: [f64; 15],
866}
867
868impl KalmanState {
869    /// Create a new Kalman state with identity quaternion and zero everything else.
870    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    /// Check that the quaternion is (approximately) normalised.
883    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    /// Roll angle in radians extracted from the quaternion.
890    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    /// Pitch angle in radians extracted from the quaternion.
898    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    /// Yaw angle in radians extracted from the quaternion.
905    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    /// Serialize to bytes (timestamp 8B + 3+3+4+3+3 f64s + 15 cov f64s = 8+95×8 = 768B).
913    pub fn to_bytes(&self) -> Vec<u8> {
914        let n_floats = 1 + 3 + 3 + 4 + 3 + 3 + 15; // = 32
915        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    /// Deserialize from bytes.
939    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// ─────────────────────────────────────────────────────────────────────────────
973// CalibrationMatrix — generic N×M matrix with metadata
974// ─────────────────────────────────────────────────────────────────────────────
975
976/// A labelled calibration matrix with row/column metadata.
977///
978/// Can represent misalignment corrections, scale-factor matrices, cross-axis
979/// coupling matrices, or any linear mapping used for sensor compensation.
980#[derive(Debug, Clone)]
981pub struct CalibrationMatrix {
982    /// Human-readable name (e.g., `"accel_misalignment"`).
983    pub name: String,
984    /// Number of rows.
985    pub rows: usize,
986    /// Number of columns.
987    pub cols: usize,
988    /// Elements in row-major order, length `rows × cols`.
989    pub data: Vec<f64>,
990    /// Unit string for the input quantities (e.g., `"m/s²"`).
991    pub input_unit: String,
992    /// Unit string for the output quantities (e.g., `"m/s²"`).
993    pub output_unit: String,
994    /// Calibration date as a Unix timestamp in seconds.
995    pub calibration_timestamp: f64,
996}
997
998impl CalibrationMatrix {
999    /// Create a new identity-like calibration matrix (diagonal 1, off-diagonal 0).
1000    ///
1001    /// The matrix must be square (`rows == cols`) for the identity to make sense;
1002    /// for non-square matrices the main diagonal up to `min(rows,cols)` is set to 1.
1003    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    /// Get element `(row, col)`.
1026    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    /// Set element `(row, col)`.
1034    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    /// Apply the calibration matrix to input vector `v` (length == `cols`).
1043    ///
1044    /// Returns `None` when `v.len() != cols`.
1045    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    /// Frobenius norm of the matrix.
1059    pub fn frobenius_norm(&self) -> f64 {
1060        self.data.iter().map(|&v| v * v).sum::<f64>().sqrt()
1061    }
1062
1063    /// Serialize to bytes.
1064    ///
1065    /// Layout: `[rows u64][cols u64][calib_ts f64][data… f64s]`
1066    /// (name and units are not persisted in this binary format).
1067    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    /// Deserialize from bytes.
1080    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// ─────────────────────────────────────────────────────────────────────────────
1108// SensorFusionRecord — fused multi-sensor output
1109// ─────────────────────────────────────────────────────────────────────────────
1110
1111/// Fused output combining IMU, pressure, and encoder data after Kalman filtering.
1112#[derive(Debug, Clone)]
1113pub struct SensorFusionRecord {
1114    /// Timestamp in seconds.
1115    pub timestamp: f64,
1116    /// Estimated position `[x, y, z]` in metres.
1117    pub position_m: [f64; 3],
1118    /// Estimated velocity `[vx, vy, vz]` in m/s.
1119    pub velocity_ms: [f64; 3],
1120    /// Estimated Euler angles `[roll, pitch, yaw]` in radians.
1121    pub euler_rad: [f64; 3],
1122    /// Estimated altitude in metres (from barometric fusion).
1123    pub altitude_m: f64,
1124    /// Fused temperature in °C.
1125    pub temperature_c: f64,
1126    /// Innovation (residual) magnitude for IMU update.
1127    pub imu_innovation: f64,
1128}
1129
1130impl SensorFusionRecord {
1131    /// Create a new fusion record with all-zero fields.
1132    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    /// Horizontal speed in m/s (`sqrt(vx² + vy²)`).
1145    pub fn horizontal_speed(&self) -> f64 {
1146        let [vx, vy, _] = self.velocity_ms;
1147        (vx * vx + vy * vy).sqrt()
1148    }
1149
1150    /// 3-D speed in m/s.
1151    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    /// Serialize to 104 bytes.
1157    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    /// Deserialize from 104 bytes.
1176    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
1198// ─────────────────────────────────────────────────────────────────────────────
1199// SensorDataHeader — binary file header
1200// ─────────────────────────────────────────────────────────────────────────────
1201
1202/// Magic bytes used to identify OxiPhysics sensor data binary files.
1203pub const SENSOR_DATA_MAGIC: [u8; 8] = *b"OXISENS\0";
1204
1205/// Version of the sensor data binary format.
1206pub const SENSOR_DATA_VERSION: u32 = 1;
1207
1208/// Binary file header for sensor data archives.
1209#[derive(Debug, Clone)]
1210pub struct SensorDataHeader {
1211    /// Sensor node name.
1212    pub node_name: String,
1213    /// Sample rate in Hz.
1214    pub sample_rate_hz: f64,
1215    /// Number of records in the file.
1216    pub record_count: u64,
1217    /// File creation timestamp (Unix time, seconds).
1218    pub created_at: f64,
1219}
1220
1221impl SensorDataHeader {
1222    /// Create a new header.
1223    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    /// Sample interval in seconds (reciprocal of sample rate).
1238    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    /// Duration of the recording in seconds.
1247    pub fn duration_s(&self) -> f64 {
1248        self.record_count as f64 * self.sample_interval_s()
1249    }
1250}
1251
1252// ─────────────────────────────────────────────────────────────────────────────
1253// SensorDataStats — running statistics helper
1254// ─────────────────────────────────────────────────────────────────────────────
1255
1256/// Welford online-variance statistics accumulator for one scalar channel.
1257///
1258/// Computes mean and variance incrementally without storing all samples.
1259#[derive(Debug, Clone)]
1260pub struct ChannelStats {
1261    /// Number of samples observed.
1262    pub count: u64,
1263    /// Running mean.
1264    pub mean: f64,
1265    /// Running M2 (sum of squared deviations) for Welford's algorithm.
1266    pub m2: f64,
1267    /// Minimum value seen.
1268    pub min: f64,
1269    /// Maximum value seen.
1270    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    /// Create an empty accumulator.
1287    pub fn new() -> Self {
1288        Self::default()
1289    }
1290
1291    /// Update with a new sample value.
1292    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    /// Sample variance (Bessel-corrected).
1307    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    /// Sample standard deviation.
1316    pub fn std_dev(&self) -> f64 {
1317        self.variance().sqrt()
1318    }
1319
1320    /// Range `max - min`.
1321    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// ─────────────────────────────────────────────────────────────────────────────
1331// MultiChannelSensorLog — generic timestamped multi-channel log
1332// ─────────────────────────────────────────────────────────────────────────────
1333
1334/// A generic multi-channel sensor log, suitable for storing any mix of
1335/// floating-point channel data alongside a common timestamp.
1336#[derive(Debug, Clone)]
1337pub struct MultiChannelSensorLog {
1338    /// Channel names, one per column.
1339    pub channel_names: Vec<String>,
1340    /// Rows, each containing a timestamp followed by one value per channel.
1341    pub rows: Vec<Vec<f64>>,
1342}
1343
1344impl MultiChannelSensorLog {
1345    /// Create an empty log with the given channel names.
1346    pub fn new(channel_names: Vec<String>) -> Self {
1347        Self {
1348            channel_names,
1349            rows: Vec::new(),
1350        }
1351    }
1352
1353    /// Number of channels (excluding the timestamp column).
1354    pub fn num_channels(&self) -> usize {
1355        self.channel_names.len()
1356    }
1357
1358    /// Append a row.  `timestamp` is prepended; `values` must have exactly
1359    /// `num_channels` elements.  Returns `false` when the length is wrong.
1360    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    /// Extract all values for channel `idx` (0-based, excludes timestamp).
1372    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    /// Compute per-channel statistics.
1380    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    /// Export to CSV lines (header + data rows).
1389    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// ─────────────────────────────────────────────────────────────────────────────
1408// Unit tests
1409// ─────────────────────────────────────────────────────────────────────────────
1410
1411#[cfg(test)]
1412mod tests {
1413    use super::*;
1414
1415    // ── ImuSample ────────────────────────────────────────────────────────────
1416
1417    #[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    // ── ImuStream ────────────────────────────────────────────────────────────
1455
1456    #[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    // ── PressureSample ───────────────────────────────────────────────────────
1490
1491    #[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    // ── TempProbeReading ─────────────────────────────────────────────────────
1536
1537    #[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        // All equal → rms_deviation should be 0
1558        let snap = TempArraySnapshot::new(0.0, vec![20.0, 20.0, 20.0]);
1559        assert!(snap.rms_deviation() < 1e-9);
1560    }
1561
1562    // ── StrainGaugeSample ────────────────────────────────────────────────────
1563
1564    #[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        // 1000 µm/m at 200 GPa → 200 MPa
1575        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    // ── ForceTorqueSample ────────────────────────────────────────────────────
1581
1582    #[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    // ── OpticalEncoderSample ─────────────────────────────────────────────────
1617
1618    #[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        // 2π rad/s → 60 RPM
1637        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    // ── LvdtSample ────────────────────────────────────────────────────────────
1642
1643    #[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    // ── ThermocoupleCalibration ───────────────────────────────────────────────
1659
1660    #[test]
1661    fn test_thermocouple_linear_poly() {
1662        // Simple linear calibration: T = 0 + 25·V
1663        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        // At 1 mV → ~24.4 °C (1/0.041)
1688        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        // constant polynomial (c0 = 5, c1 = 0) + cold_junction 20 → T = 25
1695        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    // ── KalmanState ───────────────────────────────────────────────────────────
1702
1703    #[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    // ── CalibrationMatrix ─────────────────────────────────────────────────────
1729
1730    #[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        // Frobenius norm of I₃ = sqrt(3)
1755        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    // ── SensorFusionRecord ────────────────────────────────────────────────────
1765
1766    #[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    // ── ChannelStats ──────────────────────────────────────────────────────────
1786
1787    #[test]
1788    fn test_channel_stats_basic() {
1789        // Three samples with sample std-dev exactly 2.0: {0, 2, 4} → mean=2, m2=8, var=8/2=4
1790        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    // ── MultiChannelSensorLog ─────────────────────────────────────────────────
1815
1816    #[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])); // too few values
1829    }
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    // ── SensorDataHeader ──────────────────────────────────────────────────────
1851
1852    #[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}