#![allow(clippy::needless_range_loop)]
#![allow(dead_code)]
#![allow(clippy::too_many_arguments)]
fn read_f64_le(data: &[u8], offset: usize) -> Option<f64> {
if offset + 8 > data.len() {
return None;
}
let arr: [u8; 8] = data[offset..offset + 8].try_into().ok()?;
Some(f64::from_le_bytes(arr))
}
fn push_f64_le(buf: &mut Vec<u8>, v: f64) {
buf.extend_from_slice(&v.to_le_bytes());
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImuSample {
pub timestamp: f64,
pub accel: [f64; 3],
pub gyro: [f64; 3],
pub mag: [f64; 3],
}
impl ImuSample {
pub fn new(timestamp: f64, accel: [f64; 3], gyro: [f64; 3], mag: [f64; 3]) -> Self {
Self {
timestamp,
accel,
gyro,
mag,
}
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(80);
push_f64_le(&mut buf, self.timestamp);
for &v in &self.accel {
push_f64_le(&mut buf, v);
}
for &v in &self.gyro {
push_f64_le(&mut buf, v);
}
for &v in &self.mag {
push_f64_le(&mut buf, v);
}
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 80 {
return None;
}
let mut o = 0usize;
let mut next = || {
let v = read_f64_le(data, o);
o += 8;
v
};
Some(Self {
timestamp: next()?,
accel: [next()?, next()?, next()?],
gyro: [next()?, next()?, next()?],
mag: [next()?, next()?, next()?],
})
}
pub fn accel_magnitude(&self) -> f64 {
let [ax, ay, az] = self.accel;
(ax * ax + ay * ay + az * az).sqrt()
}
pub fn gyro_magnitude(&self) -> f64 {
let [gx, gy, gz] = self.gyro;
(gx * gx + gy * gy + gz * gz).sqrt()
}
}
#[derive(Debug, Clone, Default)]
pub struct ImuStream {
pub samples: Vec<ImuSample>,
}
impl ImuStream {
pub fn new() -> Self {
Self {
samples: Vec::new(),
}
}
pub fn push(&mut self, sample: ImuSample) {
self.samples.push(sample);
}
pub fn len(&self) -> usize {
self.samples.len()
}
pub fn is_empty(&self) -> bool {
self.samples.is_empty()
}
pub fn mean_accel(&self) -> [f64; 3] {
if self.samples.is_empty() {
return [0.0; 3];
}
let n = self.samples.len() as f64;
let mut sum = [0.0f64; 3];
for s in &self.samples {
for k in 0..3 {
sum[k] += s.accel[k];
}
}
[sum[0] / n, sum[1] / n, sum[2] / n]
}
pub fn mean_gyro(&self) -> [f64; 3] {
if self.samples.is_empty() {
return [0.0; 3];
}
let n = self.samples.len() as f64;
let mut sum = [0.0f64; 3];
for s in &self.samples {
for k in 0..3 {
sum[k] += s.gyro[k];
}
}
[sum[0] / n, sum[1] / n, sum[2] / n]
}
pub fn to_bytes(&self) -> Vec<u8> {
let n = self.samples.len() as u64;
let mut buf = Vec::with_capacity(8 + self.samples.len() * 80);
buf.extend_from_slice(&n.to_le_bytes());
for s in &self.samples {
buf.extend_from_slice(&s.to_bytes());
}
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 8 {
return None;
}
let n = u64::from_le_bytes(data[0..8].try_into().ok()?) as usize;
if data.len() < 8 + n * 80 {
return None;
}
let mut samples = Vec::with_capacity(n);
for i in 0..n {
samples.push(ImuSample::from_bytes(&data[8 + i * 80..])?);
}
Some(Self { samples })
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PressureSample {
pub timestamp: f64,
pub pressure_pa: f64,
pub temperature_c: f64,
}
impl PressureSample {
pub fn new(timestamp: f64, pressure_pa: f64, temperature_c: f64) -> Self {
Self {
timestamp,
pressure_pa,
temperature_c,
}
}
pub fn altitude_m(&self, p0: f64, h0: f64) -> f64 {
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);
h0 + (T0 / L) * (1.0 - (self.pressure_pa / p0).powf(exp))
}
pub fn to_bytes(&self) -> [u8; 24] {
let mut buf = [0u8; 24];
buf[0..8].copy_from_slice(&self.timestamp.to_le_bytes());
buf[8..16].copy_from_slice(&self.pressure_pa.to_le_bytes());
buf[16..24].copy_from_slice(&self.temperature_c.to_le_bytes());
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 24 {
return None;
}
Some(Self {
timestamp: read_f64_le(data, 0)?,
pressure_pa: read_f64_le(data, 8)?,
temperature_c: read_f64_le(data, 16)?,
})
}
}
#[derive(Debug, Clone, Default)]
pub struct PressureTimeSeries {
pub samples: Vec<PressureSample>,
}
impl PressureTimeSeries {
pub fn new() -> Self {
Self {
samples: Vec::new(),
}
}
pub fn push(&mut self, s: PressureSample) {
self.samples.push(s);
}
pub fn min_pressure(&self) -> f64 {
self.samples
.iter()
.map(|s| s.pressure_pa)
.fold(f64::INFINITY, f64::min)
}
pub fn max_pressure(&self) -> f64 {
self.samples
.iter()
.map(|s| s.pressure_pa)
.fold(f64::NEG_INFINITY, f64::max)
}
pub fn mean_pressure(&self) -> f64 {
if self.samples.is_empty() {
return 0.0;
}
let sum: f64 = self.samples.iter().map(|s| s.pressure_pa).sum();
sum / self.samples.len() as f64
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TempProbeReading {
pub probe_id: u32,
pub timestamp: f64,
pub temperature_c: f64,
pub position: [f64; 3],
}
impl TempProbeReading {
pub fn new(probe_id: u32, timestamp: f64, temperature_c: f64, position: [f64; 3]) -> Self {
Self {
probe_id,
timestamp,
temperature_c,
position,
}
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(44);
buf.extend_from_slice(&self.probe_id.to_le_bytes());
push_f64_le(&mut buf, self.timestamp);
push_f64_le(&mut buf, self.temperature_c);
for &v in &self.position {
push_f64_le(&mut buf, v);
}
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 44 {
return None;
}
let probe_id = u32::from_le_bytes(data[0..4].try_into().ok()?);
Some(Self {
probe_id,
timestamp: read_f64_le(data, 4)?,
temperature_c: read_f64_le(data, 12)?,
position: [
read_f64_le(data, 20)?,
read_f64_le(data, 28)?,
read_f64_le(data, 36)?,
],
})
}
}
#[derive(Debug, Clone, Default)]
pub struct TempArraySnapshot {
pub timestamp: f64,
pub readings: Vec<f64>,
}
impl TempArraySnapshot {
pub fn new(timestamp: f64, readings: Vec<f64>) -> Self {
Self {
timestamp,
readings,
}
}
pub fn num_probes(&self) -> usize {
self.readings.len()
}
pub fn max_temp(&self) -> f64 {
self.readings
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max)
}
pub fn min_temp(&self) -> f64 {
self.readings.iter().cloned().fold(f64::INFINITY, f64::min)
}
pub fn mean_temp(&self) -> f64 {
if self.readings.is_empty() {
return 0.0;
}
self.readings.iter().sum::<f64>() / self.readings.len() as f64
}
pub fn rms_deviation(&self) -> f64 {
if self.readings.is_empty() {
return 0.0;
}
let mean = self.mean_temp();
let var = self
.readings
.iter()
.map(|&t| (t - mean).powi(2))
.sum::<f64>()
/ self.readings.len() as f64;
var.sqrt()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct StrainGaugeSample {
pub gauge_id: u32,
pub timestamp: f64,
pub bridge_voltage_v: f64,
pub microstrain: f64,
pub temperature_c: f64,
}
impl StrainGaugeSample {
pub fn new(
gauge_id: u32,
timestamp: f64,
bridge_voltage_v: f64,
microstrain: f64,
temperature_c: f64,
) -> Self {
Self {
gauge_id,
timestamp,
bridge_voltage_v,
microstrain,
temperature_c,
}
}
pub fn stress_mpa(&self, e_gpa: f64) -> f64 {
self.microstrain * 1e-6 * e_gpa * 1e3
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(36);
buf.extend_from_slice(&self.gauge_id.to_le_bytes());
push_f64_le(&mut buf, self.timestamp);
push_f64_le(&mut buf, self.bridge_voltage_v);
push_f64_le(&mut buf, self.microstrain);
push_f64_le(&mut buf, self.temperature_c);
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 36 {
return None;
}
let gauge_id = u32::from_le_bytes(data[0..4].try_into().ok()?);
Some(Self {
gauge_id,
timestamp: read_f64_le(data, 4)?,
bridge_voltage_v: read_f64_le(data, 12)?,
microstrain: read_f64_le(data, 20)?,
temperature_c: read_f64_le(data, 28)?,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ForceTorqueSample {
pub timestamp: f64,
pub force_n: [f64; 3],
pub torque_nm: [f64; 3],
}
impl ForceTorqueSample {
pub fn new(timestamp: f64, force_n: [f64; 3], torque_nm: [f64; 3]) -> Self {
Self {
timestamp,
force_n,
torque_nm,
}
}
pub fn force_magnitude(&self) -> f64 {
let [fx, fy, fz] = self.force_n;
(fx * fx + fy * fy + fz * fz).sqrt()
}
pub fn torque_magnitude(&self) -> f64 {
let [tx, ty, tz] = self.torque_nm;
(tx * tx + ty * ty + tz * tz).sqrt()
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(56);
push_f64_le(&mut buf, self.timestamp);
for &v in &self.force_n {
push_f64_le(&mut buf, v);
}
for &v in &self.torque_nm {
push_f64_le(&mut buf, v);
}
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 56 {
return None;
}
let mut o = 0usize;
let mut next = || {
let v = read_f64_le(data, o);
o += 8;
v
};
Some(Self {
timestamp: next()?,
force_n: [next()?, next()?, next()?],
torque_nm: [next()?, next()?, next()?],
})
}
}
#[derive(Debug, Clone, Default)]
pub struct ForceTorqueLog {
pub samples: Vec<ForceTorqueSample>,
}
impl ForceTorqueLog {
pub fn new() -> Self {
Self {
samples: Vec::new(),
}
}
pub fn push(&mut self, s: ForceTorqueSample) {
self.samples.push(s);
}
pub fn peak_force(&self) -> f64 {
self.samples
.iter()
.map(|s| s.force_magnitude())
.fold(0.0_f64, f64::max)
}
pub fn peak_torque(&self) -> f64 {
self.samples
.iter()
.map(|s| s.torque_magnitude())
.fold(0.0_f64, f64::max)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OpticalEncoderSample {
pub axis_id: u32,
pub timestamp: f64,
pub angle_rad: f64,
pub velocity_rad_s: f64,
pub ticks: i64,
}
impl OpticalEncoderSample {
pub fn new(
axis_id: u32,
timestamp: f64,
angle_rad: f64,
velocity_rad_s: f64,
ticks: i64,
) -> Self {
Self {
axis_id,
timestamp,
angle_rad,
velocity_rad_s,
ticks,
}
}
pub fn angle_deg(&self) -> f64 {
self.angle_rad.to_degrees()
}
pub fn velocity_rpm(&self) -> f64 {
self.velocity_rad_s * 60.0 / (2.0 * std::f64::consts::PI)
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(36);
buf.extend_from_slice(&self.axis_id.to_le_bytes());
push_f64_le(&mut buf, self.timestamp);
push_f64_le(&mut buf, self.angle_rad);
push_f64_le(&mut buf, self.velocity_rad_s);
buf.extend_from_slice(&self.ticks.to_le_bytes());
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 36 {
return None;
}
let axis_id = u32::from_le_bytes(data[0..4].try_into().ok()?);
let ticks = i64::from_le_bytes(data[28..36].try_into().ok()?);
Some(Self {
axis_id,
timestamp: read_f64_le(data, 4)?,
angle_rad: read_f64_le(data, 12)?,
velocity_rad_s: read_f64_le(data, 20)?,
ticks,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LvdtSample {
pub sensor_id: u32,
pub timestamp: f64,
pub displacement_m: f64,
pub raw_voltage_v: f64,
}
impl LvdtSample {
pub fn new(sensor_id: u32, timestamp: f64, displacement_m: f64, raw_voltage_v: f64) -> Self {
Self {
sensor_id,
timestamp,
displacement_m,
raw_voltage_v,
}
}
pub fn displacement_mm(&self) -> f64 {
self.displacement_m * 1e3
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(28);
buf.extend_from_slice(&self.sensor_id.to_le_bytes());
push_f64_le(&mut buf, self.timestamp);
push_f64_le(&mut buf, self.displacement_m);
push_f64_le(&mut buf, self.raw_voltage_v);
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 28 {
return None;
}
let sensor_id = u32::from_le_bytes(data[0..4].try_into().ok()?);
Some(Self {
sensor_id,
timestamp: read_f64_le(data, 4)?,
displacement_m: read_f64_le(data, 12)?,
raw_voltage_v: read_f64_le(data, 20)?,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThermocoupleType {
TypeK,
TypeJ,
TypeT,
TypeE,
TypeN,
TypeR,
TypeS,
TypeB,
}
#[derive(Debug, Clone)]
pub struct ThermocoupleCalibration {
pub tc_type: ThermocoupleType,
pub cold_junction_c: f64,
pub poly_coeffs: Vec<f64>,
pub valid_range_mv: [f64; 2],
}
impl ThermocoupleCalibration {
pub fn new(
tc_type: ThermocoupleType,
cold_junction_c: f64,
poly_coeffs: Vec<f64>,
valid_range_mv: [f64; 2],
) -> Self {
Self {
tc_type,
cold_junction_c,
poly_coeffs,
valid_range_mv,
}
}
pub fn convert(&self, emf_mv: f64) -> Option<f64> {
let [lo, hi] = self.valid_range_mv;
if emf_mv < lo || emf_mv > hi {
return None;
}
let mut result = 0.0f64;
let mut power = 1.0f64;
for &c in &self.poly_coeffs {
result += c * power;
power *= emf_mv;
}
Some(result + self.cold_junction_c)
}
pub fn type_k_linear(emf_mv: f64) -> f64 {
emf_mv / 0.041
}
}
#[derive(Debug, Clone)]
pub struct KalmanState {
pub timestamp: f64,
pub position_m: [f64; 3],
pub velocity_ms: [f64; 3],
pub quaternion: [f64; 4],
pub accel_bias: [f64; 3],
pub gyro_bias: [f64; 3],
pub cov_diagonal: [f64; 15],
}
impl KalmanState {
pub fn identity(timestamp: f64) -> Self {
Self {
timestamp,
position_m: [0.0; 3],
velocity_ms: [0.0; 3],
quaternion: [1.0, 0.0, 0.0, 0.0],
accel_bias: [0.0; 3],
gyro_bias: [0.0; 3],
cov_diagonal: [1.0; 15],
}
}
pub fn quaternion_is_unit(&self) -> bool {
let [qw, qx, qy, qz] = self.quaternion;
let norm = (qw * qw + qx * qx + qy * qy + qz * qz).sqrt();
(norm - 1.0).abs() < 1e-6
}
pub fn roll_rad(&self) -> f64 {
let [qw, qx, qy, qz] = self.quaternion;
let sinr_cosp = 2.0 * (qw * qx + qy * qz);
let cosr_cosp = 1.0 - 2.0 * (qx * qx + qy * qy);
sinr_cosp.atan2(cosr_cosp)
}
pub fn pitch_rad(&self) -> f64 {
let [qw, qx, qy, qz] = self.quaternion;
let sinp = 2.0 * (qw * qy - qz * qx);
sinp.clamp(-1.0, 1.0).asin()
}
pub fn yaw_rad(&self) -> f64 {
let [qw, qx, qy, qz] = self.quaternion;
let siny_cosp = 2.0 * (qw * qz + qx * qy);
let cosy_cosp = 1.0 - 2.0 * (qy * qy + qz * qz);
siny_cosp.atan2(cosy_cosp)
}
pub fn to_bytes(&self) -> Vec<u8> {
let n_floats = 1 + 3 + 3 + 4 + 3 + 3 + 15; let mut buf = Vec::with_capacity(n_floats * 8);
push_f64_le(&mut buf, self.timestamp);
for &v in &self.position_m {
push_f64_le(&mut buf, v);
}
for &v in &self.velocity_ms {
push_f64_le(&mut buf, v);
}
for &v in &self.quaternion {
push_f64_le(&mut buf, v);
}
for &v in &self.accel_bias {
push_f64_le(&mut buf, v);
}
for &v in &self.gyro_bias {
push_f64_le(&mut buf, v);
}
for &v in &self.cov_diagonal {
push_f64_le(&mut buf, v);
}
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
let needed = 32 * 8;
if data.len() < needed {
return None;
}
let mut o = 0usize;
let mut next = || {
let v = read_f64_le(data, o);
o += 8;
v
};
let timestamp = next()?;
let position_m = [next()?, next()?, next()?];
let velocity_ms = [next()?, next()?, next()?];
let quaternion = [next()?, next()?, next()?, next()?];
let accel_bias = [next()?, next()?, next()?];
let gyro_bias = [next()?, next()?, next()?];
let mut cov = [0.0f64; 15];
for c in &mut cov {
*c = next()?;
}
Some(Self {
timestamp,
position_m,
velocity_ms,
quaternion,
accel_bias,
gyro_bias,
cov_diagonal: cov,
})
}
}
#[derive(Debug, Clone)]
pub struct CalibrationMatrix {
pub name: String,
pub rows: usize,
pub cols: usize,
pub data: Vec<f64>,
pub input_unit: String,
pub output_unit: String,
pub calibration_timestamp: f64,
}
impl CalibrationMatrix {
pub fn identity(
name: impl Into<String>,
rows: usize,
cols: usize,
input_unit: impl Into<String>,
output_unit: impl Into<String>,
) -> Self {
let mut data = vec![0.0f64; rows * cols];
for i in 0..rows.min(cols) {
data[i * cols + i] = 1.0;
}
Self {
name: name.into(),
rows,
cols,
data,
input_unit: input_unit.into(),
output_unit: output_unit.into(),
calibration_timestamp: 0.0,
}
}
pub fn get(&self, row: usize, col: usize) -> Option<f64> {
if row >= self.rows || col >= self.cols {
return None;
}
Some(self.data[row * self.cols + col])
}
pub fn set(&mut self, row: usize, col: usize, value: f64) -> bool {
if row >= self.rows || col >= self.cols {
return false;
}
self.data[row * self.cols + col] = value;
true
}
pub fn apply(&self, v: &[f64]) -> Option<Vec<f64>> {
if v.len() != self.cols {
return None;
}
let mut out = vec![0.0f64; self.rows];
for r in 0..self.rows {
for c in 0..self.cols {
out[r] += self.data[r * self.cols + c] * v[c];
}
}
Some(out)
}
pub fn frobenius_norm(&self) -> f64 {
self.data.iter().map(|&v| v * v).sum::<f64>().sqrt()
}
pub fn to_bytes(&self) -> Vec<u8> {
let n = self.rows * self.cols;
let mut buf = Vec::with_capacity(24 + n * 8);
buf.extend_from_slice(&(self.rows as u64).to_le_bytes());
buf.extend_from_slice(&(self.cols as u64).to_le_bytes());
push_f64_le(&mut buf, self.calibration_timestamp);
for &v in &self.data {
push_f64_le(&mut buf, v);
}
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 24 {
return None;
}
let rows = u64::from_le_bytes(data[0..8].try_into().ok()?) as usize;
let cols = u64::from_le_bytes(data[8..16].try_into().ok()?) as usize;
let calib_ts = read_f64_le(data, 16)?;
let n = rows * cols;
if data.len() < 24 + n * 8 {
return None;
}
let mut elems = Vec::with_capacity(n);
for i in 0..n {
elems.push(read_f64_le(data, 24 + i * 8)?);
}
Some(Self {
name: String::new(),
rows,
cols,
data: elems,
input_unit: String::new(),
output_unit: String::new(),
calibration_timestamp: calib_ts,
})
}
}
#[derive(Debug, Clone)]
pub struct SensorFusionRecord {
pub timestamp: f64,
pub position_m: [f64; 3],
pub velocity_ms: [f64; 3],
pub euler_rad: [f64; 3],
pub altitude_m: f64,
pub temperature_c: f64,
pub imu_innovation: f64,
}
impl SensorFusionRecord {
pub fn zero(timestamp: f64) -> Self {
Self {
timestamp,
position_m: [0.0; 3],
velocity_ms: [0.0; 3],
euler_rad: [0.0; 3],
altitude_m: 0.0,
temperature_c: 20.0,
imu_innovation: 0.0,
}
}
pub fn horizontal_speed(&self) -> f64 {
let [vx, vy, _] = self.velocity_ms;
(vx * vx + vy * vy).sqrt()
}
pub fn speed_3d(&self) -> f64 {
let [vx, vy, vz] = self.velocity_ms;
(vx * vx + vy * vy + vz * vz).sqrt()
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(104);
push_f64_le(&mut buf, self.timestamp);
for &v in &self.position_m {
push_f64_le(&mut buf, v);
}
for &v in &self.velocity_ms {
push_f64_le(&mut buf, v);
}
for &v in &self.euler_rad {
push_f64_le(&mut buf, v);
}
push_f64_le(&mut buf, self.altitude_m);
push_f64_le(&mut buf, self.temperature_c);
push_f64_le(&mut buf, self.imu_innovation);
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 104 {
return None;
}
let mut o = 0usize;
let mut next = || {
let v = read_f64_le(data, o);
o += 8;
v
};
Some(Self {
timestamp: next()?,
position_m: [next()?, next()?, next()?],
velocity_ms: [next()?, next()?, next()?],
euler_rad: [next()?, next()?, next()?],
altitude_m: next()?,
temperature_c: next()?,
imu_innovation: next()?,
})
}
}
pub const SENSOR_DATA_MAGIC: [u8; 8] = *b"OXISENS\0";
pub const SENSOR_DATA_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub struct SensorDataHeader {
pub node_name: String,
pub sample_rate_hz: f64,
pub record_count: u64,
pub created_at: f64,
}
impl SensorDataHeader {
pub fn new(
node_name: impl Into<String>,
sample_rate_hz: f64,
record_count: u64,
created_at: f64,
) -> Self {
Self {
node_name: node_name.into(),
sample_rate_hz,
record_count,
created_at,
}
}
pub fn sample_interval_s(&self) -> f64 {
if self.sample_rate_hz == 0.0 {
f64::INFINITY
} else {
1.0 / self.sample_rate_hz
}
}
pub fn duration_s(&self) -> f64 {
self.record_count as f64 * self.sample_interval_s()
}
}
#[derive(Debug, Clone)]
pub struct ChannelStats {
pub count: u64,
pub mean: f64,
pub m2: f64,
pub min: f64,
pub max: f64,
}
impl Default for ChannelStats {
fn default() -> Self {
Self {
count: 0,
mean: 0.0,
m2: 0.0,
min: f64::INFINITY,
max: f64::NEG_INFINITY,
}
}
}
impl ChannelStats {
pub fn new() -> Self {
Self::default()
}
pub fn update(&mut self, value: f64) {
self.count += 1;
if value < self.min {
self.min = value;
}
if value > self.max {
self.max = value;
}
let delta = value - self.mean;
self.mean += delta / self.count as f64;
let delta2 = value - self.mean;
self.m2 += delta * delta2;
}
pub fn variance(&self) -> f64 {
if self.count < 2 {
0.0
} else {
self.m2 / (self.count - 1) as f64
}
}
pub fn std_dev(&self) -> f64 {
self.variance().sqrt()
}
pub fn range(&self) -> f64 {
if self.count == 0 {
0.0
} else {
self.max - self.min
}
}
}
#[derive(Debug, Clone)]
pub struct MultiChannelSensorLog {
pub channel_names: Vec<String>,
pub rows: Vec<Vec<f64>>,
}
impl MultiChannelSensorLog {
pub fn new(channel_names: Vec<String>) -> Self {
Self {
channel_names,
rows: Vec::new(),
}
}
pub fn num_channels(&self) -> usize {
self.channel_names.len()
}
pub fn push_row(&mut self, timestamp: f64, values: &[f64]) -> bool {
if values.len() != self.num_channels() {
return false;
}
let mut row = Vec::with_capacity(1 + values.len());
row.push(timestamp);
row.extend_from_slice(values);
self.rows.push(row);
true
}
pub fn channel_values(&self, idx: usize) -> Vec<f64> {
if idx >= self.num_channels() {
return vec![];
}
self.rows.iter().map(|r| r[idx + 1]).collect()
}
pub fn channel_stats(&self, idx: usize) -> ChannelStats {
let mut s = ChannelStats::new();
for v in self.channel_values(idx) {
s.update(v);
}
s
}
pub fn to_csv_lines(&self) -> Vec<String> {
let header = {
let mut h = String::from("timestamp");
for name in &self.channel_names {
h.push(',');
h.push_str(name);
}
h
};
let mut lines = vec![header];
for row in &self.rows {
let parts: Vec<String> = row.iter().map(|v| format!("{:.6}", v)).collect();
lines.push(parts.join(","));
}
lines
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_imu_sample_round_trip() {
let s = ImuSample::new(
1.23,
[1.0, -2.0, 9.8],
[0.01, -0.02, 0.003],
[24.1, -5.0, 42.0],
);
let bytes = s.to_bytes();
assert_eq!(bytes.len(), 80);
let s2 = ImuSample::from_bytes(&bytes).unwrap();
assert!((s2.timestamp - s.timestamp).abs() < 1e-12);
for k in 0..3 {
assert!((s2.accel[k] - s.accel[k]).abs() < 1e-12);
assert!((s2.gyro[k] - s.gyro[k]).abs() < 1e-12);
assert!((s2.mag[k] - s.mag[k]).abs() < 1e-12);
}
}
#[test]
fn test_imu_sample_accel_magnitude() {
let s = ImuSample::new(0.0, [3.0, 4.0, 0.0], [0.0; 3], [0.0; 3]);
assert!((s.accel_magnitude() - 5.0).abs() < 1e-12);
}
#[test]
fn test_imu_sample_gyro_magnitude() {
let s = ImuSample::new(0.0, [0.0; 3], [0.0, 0.0, 1.0], [0.0; 3]);
assert!((s.gyro_magnitude() - 1.0).abs() < 1e-12);
}
#[test]
fn test_imu_sample_from_bytes_truncated() {
let short = [0u8; 10];
assert!(ImuSample::from_bytes(&short).is_none());
}
#[test]
fn test_imu_stream_round_trip() {
let mut stream = ImuStream::new();
for i in 0..5 {
stream.push(ImuSample::new(
i as f64 * 0.01,
[0.0, 0.0, 9.81],
[0.0; 3],
[0.0; 3],
));
}
let bytes = stream.to_bytes();
let s2 = ImuStream::from_bytes(&bytes).unwrap();
assert_eq!(s2.len(), 5);
assert!((s2.samples[0].timestamp).abs() < 1e-12);
}
#[test]
fn test_imu_stream_mean_accel() {
let mut stream = ImuStream::new();
stream.push(ImuSample::new(0.0, [2.0, 0.0, 0.0], [0.0; 3], [0.0; 3]));
stream.push(ImuSample::new(0.01, [4.0, 0.0, 0.0], [0.0; 3], [0.0; 3]));
let m = stream.mean_accel();
assert!((m[0] - 3.0).abs() < 1e-12);
}
#[test]
fn test_imu_stream_empty_mean() {
let stream = ImuStream::new();
assert_eq!(stream.mean_accel(), [0.0; 3]);
assert_eq!(stream.mean_gyro(), [0.0; 3]);
}
#[test]
fn test_pressure_sample_round_trip() {
let s = PressureSample::new(5.0, 101325.0, 22.5);
let bytes = s.to_bytes();
let s2 = PressureSample::from_bytes(&bytes).unwrap();
assert!((s2.pressure_pa - 101325.0).abs() < 1e-6);
assert!((s2.temperature_c - 22.5).abs() < 1e-9);
}
#[test]
fn test_pressure_altitude_sea_level() {
let s = PressureSample::new(0.0, 101325.0, 15.0);
let alt = s.altitude_m(101325.0, 0.0);
assert!(
alt.abs() < 1.0,
"altitude at sea-level pressure should be ~0 m, got {}",
alt
);
}
#[test]
fn test_pressure_altitude_decreases_with_pressure() {
let s_low = PressureSample::new(0.0, 101325.0, 15.0);
let s_high = PressureSample::new(0.0, 89875.0, 10.0);
let alt_low = s_low.altitude_m(101325.0, 0.0);
let alt_high = s_high.altitude_m(101325.0, 0.0);
assert!(alt_high > alt_low, "lower pressure → higher altitude");
}
#[test]
fn test_pressure_time_series_statistics() {
let mut ts = PressureTimeSeries::new();
for i in 0..10 {
ts.push(PressureSample::new(
i as f64,
100000.0 + i as f64 * 100.0,
20.0,
));
}
assert!((ts.min_pressure() - 100000.0).abs() < 1e-6);
assert!((ts.max_pressure() - 100900.0).abs() < 1e-6);
assert!((ts.mean_pressure() - 100450.0).abs() < 1e-6);
}
#[test]
fn test_temp_probe_round_trip() {
let r = TempProbeReading::new(3, 1.5, 37.5, [1.0, 2.0, 3.0]);
let bytes = r.to_bytes();
assert_eq!(bytes.len(), 44);
let r2 = TempProbeReading::from_bytes(&bytes).unwrap();
assert_eq!(r2.probe_id, 3);
assert!((r2.temperature_c - 37.5).abs() < 1e-9);
}
#[test]
fn test_temp_array_snapshot_stats() {
let snap = TempArraySnapshot::new(0.0, vec![10.0, 20.0, 30.0, 40.0]);
assert!((snap.min_temp() - 10.0).abs() < 1e-9);
assert!((snap.max_temp() - 40.0).abs() < 1e-9);
assert!((snap.mean_temp() - 25.0).abs() < 1e-9);
}
#[test]
fn test_temp_array_rms_deviation() {
let snap = TempArraySnapshot::new(0.0, vec![20.0, 20.0, 20.0]);
assert!(snap.rms_deviation() < 1e-9);
}
#[test]
fn test_strain_gauge_round_trip() {
let s = StrainGaugeSample::new(0, 2.0, 0.001, 500.0, 23.5);
let bytes = s.to_bytes();
let s2 = StrainGaugeSample::from_bytes(&bytes).unwrap();
assert!((s2.microstrain - 500.0).abs() < 1e-9);
}
#[test]
fn test_strain_gauge_stress() {
let s = StrainGaugeSample::new(0, 0.0, 0.0, 1000.0, 20.0);
let stress = s.stress_mpa(200.0);
assert!((stress - 200.0).abs() < 1e-6);
}
#[test]
fn test_force_torque_round_trip() {
let s = ForceTorqueSample::new(3.125, [10.0, 0.0, -5.0], [0.0, 1.0, 0.0]);
let bytes = s.to_bytes();
assert_eq!(bytes.len(), 56);
let s2 = ForceTorqueSample::from_bytes(&bytes).unwrap();
assert!((s2.force_n[0] - 10.0).abs() < 1e-9);
assert!((s2.torque_nm[1] - 1.0).abs() < 1e-9);
}
#[test]
fn test_force_torque_magnitudes() {
let s = ForceTorqueSample::new(0.0, [3.0, 4.0, 0.0], [0.0, 0.0, 5.0]);
assert!((s.force_magnitude() - 5.0).abs() < 1e-12);
assert!((s.torque_magnitude() - 5.0).abs() < 1e-12);
}
#[test]
fn test_force_torque_log_peaks() {
let mut log = ForceTorqueLog::new();
log.push(ForceTorqueSample::new(
0.0,
[1.0, 0.0, 0.0],
[0.0, 0.0, 0.5],
));
log.push(ForceTorqueSample::new(
1.0,
[10.0, 0.0, 0.0],
[0.0, 0.0, 2.0],
));
assert!((log.peak_force() - 10.0).abs() < 1e-9);
assert!((log.peak_torque() - 2.0).abs() < 1e-9);
}
#[test]
fn test_encoder_round_trip() {
let s = OpticalEncoderSample::new(0, 0.5, std::f64::consts::PI, 1.0, 2048);
let bytes = s.to_bytes();
assert_eq!(bytes.len(), 36);
let s2 = OpticalEncoderSample::from_bytes(&bytes).unwrap();
assert!((s2.angle_rad - std::f64::consts::PI).abs() < 1e-9);
assert_eq!(s2.ticks, 2048);
}
#[test]
fn test_encoder_angle_conversion() {
let s = OpticalEncoderSample::new(0, 0.0, std::f64::consts::PI, 0.0, 0);
assert!((s.angle_deg() - 180.0).abs() < 1e-9);
}
#[test]
fn test_encoder_rpm() {
let s = OpticalEncoderSample::new(0, 0.0, 0.0, 2.0 * std::f64::consts::PI, 0);
assert!((s.velocity_rpm() - 60.0).abs() < 1e-9);
}
#[test]
fn test_lvdt_round_trip() {
let s = LvdtSample::new(1, 0.1, 0.025, 2.5);
let bytes = s.to_bytes();
assert_eq!(bytes.len(), 28);
let s2 = LvdtSample::from_bytes(&bytes).unwrap();
assert!((s2.displacement_m - 0.025).abs() < 1e-12);
}
#[test]
fn test_lvdt_mm_conversion() {
let s = LvdtSample::new(0, 0.0, 0.005, 0.5);
assert!((s.displacement_mm() - 5.0).abs() < 1e-9);
}
#[test]
fn test_thermocouple_linear_poly() {
let cal = ThermocoupleCalibration::new(
ThermocoupleType::TypeK,
0.0,
vec![0.0, 25.0],
[0.0, 10.0],
);
let t = cal.convert(4.0).unwrap();
assert!((t - 100.0).abs() < 1e-9);
}
#[test]
fn test_thermocouple_out_of_range() {
let cal = ThermocoupleCalibration::new(
ThermocoupleType::TypeK,
0.0,
vec![0.0, 25.0],
[0.0, 10.0],
);
assert!(cal.convert(-1.0).is_none());
assert!(cal.convert(11.0).is_none());
}
#[test]
fn test_thermocouple_type_k_linear() {
let t = ThermocoupleCalibration::type_k_linear(1.0);
assert!((t - 1.0 / 0.041).abs() < 1e-6);
}
#[test]
fn test_thermocouple_cold_junction_offset() {
let cal =
ThermocoupleCalibration::new(ThermocoupleType::TypeJ, 20.0, vec![5.0], [0.0, 100.0]);
let t = cal.convert(50.0).unwrap();
assert!((t - 25.0).abs() < 1e-9);
}
#[test]
fn test_kalman_state_round_trip() {
let mut k = KalmanState::identity(100.0);
k.position_m = [1.0, 2.0, 3.0];
k.velocity_ms = [0.5, -0.1, 0.0];
let bytes = k.to_bytes();
let k2 = KalmanState::from_bytes(&bytes).unwrap();
assert!((k2.timestamp - 100.0).abs() < 1e-9);
assert!((k2.position_m[0] - 1.0).abs() < 1e-9);
}
#[test]
fn test_kalman_identity_quaternion() {
let k = KalmanState::identity(0.0);
assert!(k.quaternion_is_unit());
}
#[test]
fn test_kalman_euler_zero_at_identity() {
let k = KalmanState::identity(0.0);
assert!(k.roll_rad().abs() < 1e-9);
assert!(k.pitch_rad().abs() < 1e-9);
assert!(k.yaw_rad().abs() < 1e-9);
}
#[test]
fn test_calibration_matrix_identity_apply() {
let m = CalibrationMatrix::identity("test", 3, 3, "m/s²", "m/s²");
let v = [1.0, 2.0, 3.0];
let out = m.apply(&v).unwrap();
for k in 0..3 {
assert!((out[k] - v[k]).abs() < 1e-12);
}
}
#[test]
fn test_calibration_matrix_round_trip() {
let mut m = CalibrationMatrix::identity("cm", 2, 2, "in", "out");
m.set(0, 1, 0.5);
let bytes = m.to_bytes();
let m2 = CalibrationMatrix::from_bytes(&bytes).unwrap();
assert_eq!(m2.rows, 2);
assert_eq!(m2.cols, 2);
assert!((m2.get(0, 1).unwrap() - 0.5).abs() < 1e-12);
}
#[test]
fn test_calibration_matrix_frobenius_identity() {
let m = CalibrationMatrix::identity("id", 3, 3, "a", "b");
assert!((m.frobenius_norm() - 3.0f64.sqrt()).abs() < 1e-9);
}
#[test]
fn test_calibration_matrix_wrong_vector_size() {
let m = CalibrationMatrix::identity("id", 3, 3, "a", "b");
assert!(m.apply(&[1.0, 2.0]).is_none());
}
#[test]
fn test_fusion_record_round_trip() {
let mut r = SensorFusionRecord::zero(9.9);
r.velocity_ms = [3.0, 4.0, 0.0];
r.altitude_m = 150.0;
let bytes = r.to_bytes();
assert_eq!(bytes.len(), 104);
let r2 = SensorFusionRecord::from_bytes(&bytes).unwrap();
assert!((r2.altitude_m - 150.0).abs() < 1e-9);
assert!((r2.horizontal_speed() - 5.0).abs() < 1e-9);
}
#[test]
fn test_fusion_record_speed_3d() {
let mut r = SensorFusionRecord::zero(0.0);
r.velocity_ms = [1.0, 2.0, 2.0];
assert!((r.speed_3d() - 3.0).abs() < 1e-9);
}
#[test]
fn test_channel_stats_basic() {
let mut s = ChannelStats::new();
for v in [0.0_f64, 2.0, 4.0] {
s.update(v);
}
assert!((s.mean - 2.0).abs() < 1e-9);
assert!((s.std_dev() - 2.0).abs() < 1e-9);
}
#[test]
fn test_channel_stats_single_sample() {
let mut s = ChannelStats::new();
s.update(42.0);
assert!((s.mean - 42.0).abs() < 1e-12);
assert!(s.variance() < 1e-12);
assert!((s.range()).abs() < 1e-12);
}
#[test]
fn test_channel_stats_empty_variance() {
let s = ChannelStats::new();
assert_eq!(s.variance(), 0.0);
assert_eq!(s.range(), 0.0);
}
#[test]
fn test_multi_channel_push_and_retrieve() {
let mut log = MultiChannelSensorLog::new(vec!["ax".into(), "ay".into()]);
assert!(log.push_row(0.0, &[1.0, 2.0]));
assert!(log.push_row(0.01, &[3.0, 4.0]));
let ax_vals = log.channel_values(0);
assert_eq!(ax_vals, vec![1.0, 3.0]);
}
#[test]
fn test_multi_channel_wrong_row_length() {
let mut log = MultiChannelSensorLog::new(vec!["x".into(), "y".into()]);
assert!(!log.push_row(0.0, &[1.0])); }
#[test]
fn test_multi_channel_csv_header() {
let log = MultiChannelSensorLog::new(vec!["p".into(), "q".into()]);
let lines = log.to_csv_lines();
assert_eq!(lines[0], "timestamp,p,q");
}
#[test]
fn test_multi_channel_stats() {
let mut log = MultiChannelSensorLog::new(vec!["v".into()]);
for i in 0..5 {
log.push_row(i as f64, &[i as f64 * 2.0]);
}
let stats = log.channel_stats(0);
assert!((stats.mean - 4.0).abs() < 1e-9);
assert!((stats.min).abs() < 1e-9);
assert!((stats.max - 8.0).abs() < 1e-9);
}
#[test]
fn test_sensor_data_header_interval() {
let hdr = SensorDataHeader::new("node1", 100.0, 1000, 0.0);
assert!((hdr.sample_interval_s() - 0.01).abs() < 1e-12);
assert!((hdr.duration_s() - 10.0).abs() < 1e-9);
}
#[test]
fn test_sensor_data_header_zero_rate() {
let hdr = SensorDataHeader::new("n", 0.0, 100, 0.0);
assert!(hdr.sample_interval_s().is_infinite());
}
}