Skip to main content

oxiphysics_io/
sensor_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Sensor and telemetry I/O.
5//!
6//! Provides writers and readers for IMU, GPS, lidar, force gauge data,
7//! MAVLink-inspired binary protocol, sensor fusion, and data logging.
8
9#![allow(dead_code)]
10#![allow(clippy::too_many_arguments)]
11
12use std::collections::VecDeque;
13use std::io::{BufWriter, Write};
14
15/// IMU data record: timestamp, accelerometer, gyroscope, magnetometer.
16#[derive(Debug, Clone, PartialEq)]
17pub struct ImuDataRecord {
18    /// Timestamp in seconds.
19    pub timestamp: f64,
20    /// Accelerometer \[ax, ay, az\] in m/s².
21    pub accel: [f64; 3],
22    /// Gyroscope \[gx, gy, gz\] in rad/s.
23    pub gyro: [f64; 3],
24    /// Magnetometer \[mx, my, mz\] in µT.
25    pub mag: [f64; 3],
26}
27
28impl ImuDataRecord {
29    /// Create a new IMU record.
30    pub fn new(timestamp: f64, accel: [f64; 3], gyro: [f64; 3], mag: [f64; 3]) -> Self {
31        Self {
32            timestamp,
33            accel,
34            gyro,
35            mag,
36        }
37    }
38
39    /// Serialize to bytes (little-endian f64).
40    pub fn to_bytes(&self) -> Vec<u8> {
41        let mut buf = Vec::with_capacity(80);
42        buf.extend_from_slice(&self.timestamp.to_le_bytes());
43        for &v in &self.accel {
44            buf.extend_from_slice(&v.to_le_bytes());
45        }
46        for &v in &self.gyro {
47            buf.extend_from_slice(&v.to_le_bytes());
48        }
49        for &v in &self.mag {
50            buf.extend_from_slice(&v.to_le_bytes());
51        }
52        buf
53    }
54
55    /// Deserialize from bytes.
56    pub fn from_bytes(data: &[u8]) -> Option<Self> {
57        if data.len() < 80 {
58            return None;
59        }
60        let mut off = 0usize;
61        let read_f64 = |d: &[u8], o: &mut usize| -> f64 {
62            let v = f64::from_le_bytes(d[*o..*o + 8].try_into().unwrap_or([0u8; 8]));
63            *o += 8;
64            v
65        };
66        let timestamp = read_f64(data, &mut off);
67        let accel = [
68            read_f64(data, &mut off),
69            read_f64(data, &mut off),
70            read_f64(data, &mut off),
71        ];
72        let gyro = [
73            read_f64(data, &mut off),
74            read_f64(data, &mut off),
75            read_f64(data, &mut off),
76        ];
77        let mag = [
78            read_f64(data, &mut off),
79            read_f64(data, &mut off),
80            read_f64(data, &mut off),
81        ];
82        Some(Self {
83            timestamp,
84            accel,
85            gyro,
86            mag,
87        })
88    }
89}
90
91/// GPS data record: timestamp, position, velocity, HDOP.
92#[derive(Debug, Clone, PartialEq)]
93pub struct GpsDataRecord {
94    /// Timestamp in seconds.
95    pub timestamp: f64,
96    /// Latitude in degrees.
97    pub lat: f64,
98    /// Longitude in degrees.
99    pub lon: f64,
100    /// Altitude in meters.
101    pub alt: f64,
102    /// Velocity \[vn, ve, vd\] in m/s (NED frame).
103    pub velocity: [f64; 3],
104    /// Horizontal dilution of precision.
105    pub hdop: f64,
106}
107
108impl GpsDataRecord {
109    /// Create a new GPS record.
110    pub fn new(
111        timestamp: f64,
112        lat: f64,
113        lon: f64,
114        alt: f64,
115        velocity: [f64; 3],
116        hdop: f64,
117    ) -> Self {
118        Self {
119            timestamp,
120            lat,
121            lon,
122            alt,
123            velocity,
124            hdop,
125        }
126    }
127
128    /// Serialize to bytes.
129    pub fn to_bytes(&self) -> Vec<u8> {
130        let mut buf = Vec::with_capacity(64);
131        for v in [
132            self.timestamp,
133            self.lat,
134            self.lon,
135            self.alt,
136            self.velocity[0],
137            self.velocity[1],
138            self.velocity[2],
139            self.hdop,
140        ] {
141            buf.extend_from_slice(&v.to_le_bytes());
142        }
143        buf
144    }
145
146    /// Ground speed in m/s.
147    pub fn ground_speed(&self) -> f64 {
148        (self.velocity[0] * self.velocity[0] + self.velocity[1] * self.velocity[1]).sqrt()
149    }
150}
151
152/// LiDAR frame: timestamp, point cloud, intensities.
153#[derive(Debug, Clone)]
154pub struct LidarFrame {
155    /// Timestamp in seconds.
156    pub timestamp: f64,
157    /// Number of points.
158    pub n_points: usize,
159    /// 3D positions \[x, y, z\] in meters (f32 for compactness).
160    pub positions: Vec<[f32; 3]>,
161    /// Intensity values (0.0..1.0).
162    pub intensities: Vec<f32>,
163}
164
165impl LidarFrame {
166    /// Create a new empty LiDAR frame.
167    pub fn new(timestamp: f64) -> Self {
168        Self {
169            timestamp,
170            n_points: 0,
171            positions: Vec::new(),
172            intensities: Vec::new(),
173        }
174    }
175
176    /// Add a point.
177    pub fn add_point(&mut self, pos: [f32; 3], intensity: f32) {
178        self.positions.push(pos);
179        self.intensities.push(intensity);
180        self.n_points += 1;
181    }
182
183    /// Serialize to bytes.
184    pub fn to_bytes(&self) -> Vec<u8> {
185        let mut buf = Vec::new();
186        buf.extend_from_slice(&self.timestamp.to_le_bytes());
187        buf.extend_from_slice(&(self.n_points as u32).to_le_bytes());
188        for p in &self.positions {
189            for &v in p {
190                buf.extend_from_slice(&v.to_le_bytes());
191            }
192        }
193        for &intensity in &self.intensities {
194            buf.extend_from_slice(&intensity.to_le_bytes());
195        }
196        buf
197    }
198
199    /// Point cloud bounding box \[\[min_x, max_x\\], \[min_y, max_y\], \[min_z, max_z\]].
200    pub fn bounding_box(&self) -> [[f32; 2]; 3] {
201        let mut bb = [[f32::MAX, f32::MIN]; 3];
202        for p in &self.positions {
203            for axis in 0..3 {
204                bb[axis][0] = bb[axis][0].min(p[axis]);
205                bb[axis][1] = bb[axis][1].max(p[axis]);
206            }
207        }
208        bb
209    }
210}
211
212/// Force gauge record: timestamp, force, torque, temperature.
213#[derive(Debug, Clone)]
214pub struct ForceGaugeRecord {
215    /// Timestamp in seconds.
216    pub timestamp: f64,
217    /// Force \[fx, fy, fz\] in N.
218    pub force: [f64; 3],
219    /// Torque \[tx, ty, tz\] in N·m.
220    pub torque: [f64; 3],
221    /// Temperature in Celsius.
222    pub temperature: f64,
223}
224
225impl ForceGaugeRecord {
226    /// Create a new force gauge record.
227    pub fn new(timestamp: f64, force: [f64; 3], torque: [f64; 3], temperature: f64) -> Self {
228        Self {
229            timestamp,
230            force,
231            torque,
232            temperature,
233        }
234    }
235
236    /// Force magnitude in N.
237    pub fn force_magnitude(&self) -> f64 {
238        let f = &self.force;
239        (f[0] * f[0] + f[1] * f[1] + f[2] * f[2]).sqrt()
240    }
241
242    /// Torque magnitude in N·m.
243    pub fn torque_magnitude(&self) -> f64 {
244        let t = &self.torque;
245        (t[0] * t[0] + t[1] * t[1] + t[2] * t[2]).sqrt()
246    }
247
248    /// Serialize to bytes.
249    pub fn to_bytes(&self) -> Vec<u8> {
250        let mut buf = Vec::with_capacity(56);
251        for v in [
252            self.timestamp,
253            self.force[0],
254            self.force[1],
255            self.force[2],
256            self.torque[0],
257            self.torque[1],
258            self.torque[2],
259            self.temperature,
260        ] {
261            buf.extend_from_slice(&v.to_le_bytes());
262        }
263        buf
264    }
265}
266
267/// MAVLink-inspired telemetry protocol.
268///
269/// Binary protocol with magic byte, version, message_id, payload length, and CRC.
270#[derive(Debug, Clone)]
271pub struct TelemetryProtocol {
272    /// Magic byte (0xFE for MAVLink v1-style).
273    pub magic: u8,
274    /// Protocol version.
275    pub version: u8,
276    /// System ID.
277    pub sys_id: u8,
278    /// Component ID.
279    pub comp_id: u8,
280}
281
282/// Telemetry message.
283#[derive(Debug, Clone)]
284pub struct TelemetryMessage {
285    /// Message ID.
286    pub message_id: u16,
287    /// Sequence number.
288    pub seq: u8,
289    /// Payload bytes.
290    pub payload: Vec<u8>,
291}
292
293impl TelemetryProtocol {
294    /// Create a new telemetry protocol instance.
295    pub fn new(sys_id: u8, comp_id: u8) -> Self {
296        Self {
297            magic: 0xFE,
298            version: 1,
299            sys_id,
300            comp_id,
301        }
302    }
303
304    /// Encode a message to bytes.
305    pub fn encode(&self, msg: &TelemetryMessage) -> Vec<u8> {
306        let payload_len = msg.payload.len() as u8;
307        let mut buf = vec![self.magic, payload_len, msg.seq, self.sys_id, self.comp_id];
308        buf.extend_from_slice(&msg.message_id.to_le_bytes());
309        buf.extend_from_slice(&msg.payload);
310        let crc = self.crc16(&buf[1..]);
311        buf.extend_from_slice(&crc.to_le_bytes());
312        buf
313    }
314
315    /// Decode a message from bytes.
316    pub fn decode(&self, data: &[u8]) -> Option<TelemetryMessage> {
317        if data.len() < 8 {
318            return None;
319        }
320        if data[0] != self.magic {
321            return None;
322        }
323        let payload_len = data[1] as usize;
324        if data.len() < 7 + payload_len + 2 {
325            return None;
326        }
327        let seq = data[2];
328        let _sys_id = data[3];
329        let _comp_id = data[4];
330        let message_id = u16::from_le_bytes([data[5], data[6]]);
331        let payload = data[7..7 + payload_len].to_vec();
332        Some(TelemetryMessage {
333            message_id,
334            seq,
335            payload,
336        })
337    }
338
339    /// Simple CRC-16.
340    fn crc16(&self, data: &[u8]) -> u16 {
341        let mut crc = 0xFFFFu16;
342        for &byte in data {
343            let tmp = byte as u16 ^ (crc & 0xFF);
344            let tmp = tmp ^ (tmp << 4);
345            crc = (crc >> 8) ^ (tmp << 8) ^ (tmp << 3) ^ (tmp >> 4);
346        }
347        crc
348    }
349}
350
351/// Sensor fusion using complementary and Madgwick AHRS filters.
352///
353/// Estimates orientation from IMU data.
354#[derive(Debug, Clone)]
355pub struct SensorFusion {
356    /// Quaternion \[w, x, y, z\].
357    pub quaternion: [f64; 4],
358    /// Complementary filter weight alpha (for gyro vs accel).
359    pub alpha: f64,
360    /// Madgwick beta parameter.
361    pub beta: f64,
362    /// Euler angles \[roll, pitch, yaw\] in degrees.
363    pub euler: [f64; 3],
364}
365
366impl SensorFusion {
367    /// Create a new sensor fusion instance.
368    pub fn new(alpha: f64, beta: f64) -> Self {
369        Self {
370            quaternion: [1.0, 0.0, 0.0, 0.0],
371            alpha,
372            beta,
373            euler: [0.0; 3],
374        }
375    }
376
377    /// Update with complementary filter.
378    pub fn update_complementary(&mut self, accel: [f64; 3], gyro: [f64; 3], dt: f64) {
379        // Integrate gyro
380        let [w, x, y, z] = self.quaternion;
381        let [gx, gy, gz] = gyro;
382        let dw = 0.5 * (-x * gx - y * gy - z * gz);
383        let dx = 0.5 * (w * gx + y * gz - z * gy);
384        let dy = 0.5 * (w * gy - x * gz + z * gx);
385        let dz = 0.5 * (w * gz + x * gy - y * gx);
386        let qw = w + dw * dt;
387        let qx = x + dx * dt;
388        let qy = y + dy * dt;
389        let qz = z + dz * dt;
390        let mag = (qw * qw + qx * qx + qy * qy + qz * qz).sqrt().max(1e-10);
391        let q_gyro = [qw / mag, qx / mag, qy / mag, qz / mag];
392
393        // Accelerometer-derived roll/pitch
394        let ax = accel[0];
395        let ay = accel[1];
396        let az = accel[2];
397        let norm_a = (ax * ax + ay * ay + az * az).sqrt().max(1e-10);
398        let roll = (ay / norm_a).atan2((az / norm_a).max(1e-10));
399        let pitch = -(ax / norm_a)
400            .asin()
401            .clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2);
402        let q_accel = euler_to_quat(roll, pitch, 0.0);
403
404        // Complementary blend
405        let a = self.alpha;
406        let q_blend = quat_slerp(q_accel, q_gyro, a);
407        self.quaternion = q_blend;
408        self.euler = quat_to_euler(self.quaternion);
409    }
410
411    /// Update with Madgwick AHRS filter.
412    pub fn update_madgwick(&mut self, accel: [f64; 3], gyro: [f64; 3], dt: f64) {
413        let [mut qw, mut qx, mut qy, mut qz] = self.quaternion;
414        let [gx, gy, gz] = gyro;
415        let [ax, ay, az] = accel;
416
417        let norm_a = (ax * ax + ay * ay + az * az).sqrt().max(1e-10);
418        let (ax, ay, az) = (ax / norm_a, ay / norm_a, az / norm_a);
419
420        // Gradient step
421        let f1 = 2.0 * (qx * qz - qw * qy) - ax;
422        let f2 = 2.0 * (qw * qx + qy * qz) - ay;
423        let f3 = 1.0 - 2.0 * (qx * qx + qy * qy) - az;
424        let j11 = 2.0 * qy;
425        let j12 = 2.0 * qz;
426        let j13 = 2.0 * qw;
427        let j14 = 2.0 * qx;
428        let j21 = 2.0 * qx;
429        let j22 = 2.0 * qw;
430        let j23 = 2.0 * qz;
431        let j24 = 2.0 * qy;
432        let j31 = 0.0;
433        let j32 = 4.0 * qx;
434        let j33 = 4.0 * qy;
435        let j34 = 0.0;
436        let step_w = j11 * f1 + j21 * f2 + j31 * f3;
437        let step_x = j12 * f1 + j22 * f2 + j32 * f3;
438        let step_y = j13 * f1 + j23 * f2 + j33 * f3;
439        let step_z = j14 * f1 + j24 * f2 + j34 * f3;
440        let norm_s = (step_w * step_w + step_x * step_x + step_y * step_y + step_z * step_z)
441            .sqrt()
442            .max(1e-10);
443        let (sw, sx, sy, sz) = (
444            step_w / norm_s,
445            step_x / norm_s,
446            step_y / norm_s,
447            step_z / norm_s,
448        );
449
450        // Rate of change from gyro + correction
451        let qdotw = 0.5 * (-qx * gx - qy * gy - qz * gz) - self.beta * sw;
452        let qdotx = 0.5 * (qw * gx + qy * gz - qz * gy) - self.beta * sx;
453        let qdoty = 0.5 * (qw * gy - qx * gz + qz * gx) - self.beta * sy;
454        let qdotz = 0.5 * (qw * gz + qx * gy - qy * gx) - self.beta * sz;
455
456        qw += qdotw * dt;
457        qx += qdotx * dt;
458        qy += qdoty * dt;
459        qz += qdotz * dt;
460        let norm_q = (qw * qw + qx * qx + qy * qy + qz * qz).sqrt().max(1e-10);
461        self.quaternion = [qw / norm_q, qx / norm_q, qy / norm_q, qz / norm_q];
462        self.euler = quat_to_euler(self.quaternion);
463    }
464
465    /// Get roll angle in degrees.
466    pub fn roll(&self) -> f64 {
467        self.euler[0]
468    }
469    /// Get pitch angle in degrees.
470    pub fn pitch(&self) -> f64 {
471        self.euler[1]
472    }
473    /// Get yaw angle in degrees.
474    pub fn yaw(&self) -> f64 {
475        self.euler[2]
476    }
477}
478
479/// Ring-buffer data logger with configurable size.
480///
481/// Flushes to in-memory buffer when full.
482#[derive(Debug, Clone)]
483pub struct DataLogger<T: Clone> {
484    /// Ring buffer.
485    buffer: VecDeque<T>,
486    /// Maximum capacity.
487    capacity: usize,
488    /// Flushed records.
489    flushed: Vec<T>,
490}
491
492impl<T: Clone> DataLogger<T> {
493    /// Create a new data logger with given capacity.
494    pub fn new(capacity: usize) -> Self {
495        Self {
496            buffer: VecDeque::with_capacity(capacity),
497            capacity,
498            flushed: Vec::new(),
499        }
500    }
501
502    /// Log a record.
503    pub fn log(&mut self, record: T) {
504        if self.buffer.len() >= self.capacity {
505            self.flush();
506        }
507        self.buffer.push_back(record);
508    }
509
510    /// Flush buffer to flushed storage.
511    pub fn flush(&mut self) {
512        self.flushed.extend(self.buffer.drain(..));
513    }
514
515    /// Total number of logged records.
516    pub fn total_count(&self) -> usize {
517        self.buffer.len() + self.flushed.len()
518    }
519
520    /// Current buffer size.
521    pub fn buffer_size(&self) -> usize {
522        self.buffer.len()
523    }
524
525    /// Access all flushed records.
526    pub fn flushed_records(&self) -> &[T] {
527        &self.flushed
528    }
529
530    /// Pop oldest record.
531    pub fn pop(&mut self) -> Option<T> {
532        self.buffer.pop_front()
533    }
534
535    /// Replay from beginning (returns cloned records).
536    pub fn replay(&self) -> Vec<T> {
537        let mut all = self.flushed.clone();
538        all.extend(self.buffer.iter().cloned());
539        all
540    }
541}
542
543/// Sensor data writer for binary output.
544#[derive(Debug)]
545pub struct SensorDataWriter {
546    /// Output buffer.
547    pub buffer: Vec<u8>,
548    /// Number of records written.
549    pub n_records: usize,
550    /// Sensor type tag.
551    pub sensor_type: SensorType,
552}
553
554/// Sensor type tag.
555#[derive(Debug, Clone, Copy, PartialEq)]
556pub enum SensorType {
557    /// IMU sensor.
558    Imu,
559    /// GPS sensor.
560    Gps,
561    /// LiDAR sensor.
562    Lidar,
563    /// Force gauge sensor.
564    ForceGauge,
565}
566
567impl SensorDataWriter {
568    /// Create a new sensor data writer.
569    pub fn new(sensor_type: SensorType) -> Self {
570        Self {
571            buffer: Vec::new(),
572            n_records: 0,
573            sensor_type,
574        }
575    }
576
577    /// Write IMU record.
578    pub fn write_imu(&mut self, record: &ImuDataRecord) {
579        self.buffer.extend_from_slice(&record.to_bytes());
580        self.n_records += 1;
581    }
582
583    /// Write GPS record.
584    pub fn write_gps(&mut self, record: &GpsDataRecord) {
585        self.buffer.extend_from_slice(&record.to_bytes());
586        self.n_records += 1;
587    }
588
589    /// Write force gauge record.
590    pub fn write_force(&mut self, record: &ForceGaugeRecord) {
591        self.buffer.extend_from_slice(&record.to_bytes());
592        self.n_records += 1;
593    }
594
595    /// Write LiDAR frame.
596    pub fn write_lidar(&mut self, frame: &LidarFrame) {
597        self.buffer.extend_from_slice(&frame.to_bytes());
598        self.n_records += 1;
599    }
600
601    /// Clear buffer.
602    pub fn clear(&mut self) {
603        self.buffer.clear();
604        self.n_records = 0;
605    }
606
607    /// Total bytes written.
608    pub fn bytes_written(&self) -> usize {
609        self.buffer.len()
610    }
611}
612
613/// Sensor data reader with timestamp synchronization and interpolation.
614#[derive(Debug)]
615pub struct SensorDataReader {
616    /// Raw data buffer.
617    pub data: Vec<u8>,
618    /// Sensor type.
619    pub sensor_type: SensorType,
620    /// Read position.
621    pub pos: usize,
622}
623
624impl SensorDataReader {
625    /// Create a new sensor data reader.
626    pub fn new(data: Vec<u8>, sensor_type: SensorType) -> Self {
627        Self {
628            data,
629            sensor_type,
630            pos: 0,
631        }
632    }
633
634    /// Read next IMU record.
635    pub fn read_imu(&mut self) -> Option<ImuDataRecord> {
636        let record = ImuDataRecord::from_bytes(&self.data[self.pos..])?;
637        self.pos += 80;
638        Some(record)
639    }
640
641    /// Check if more data available.
642    pub fn has_more(&self) -> bool {
643        self.pos < self.data.len()
644    }
645
646    /// Seek to position.
647    pub fn seek(&mut self, pos: usize) {
648        self.pos = pos.min(self.data.len());
649    }
650
651    /// Remaining bytes.
652    pub fn remaining(&self) -> usize {
653        self.data.len().saturating_sub(self.pos)
654    }
655}
656
657/// Multi-column CSV telemetry writer.
658///
659/// Efficient append with header row.
660#[derive(Debug, Clone)]
661pub struct CsvTelemetry {
662    /// CSV header columns.
663    pub headers: Vec<String>,
664    /// Data rows.
665    pub rows: Vec<Vec<String>>,
666}
667
668impl CsvTelemetry {
669    /// Create a new CSV telemetry writer.
670    pub fn new(headers: Vec<String>) -> Self {
671        Self {
672            headers,
673            rows: Vec::new(),
674        }
675    }
676
677    /// Append a row.
678    pub fn append_row(&mut self, values: Vec<String>) {
679        self.rows.push(values);
680    }
681
682    /// Append IMU record as row.
683    pub fn append_imu(&mut self, r: &ImuDataRecord) {
684        self.rows.push(vec![
685            r.timestamp.to_string(),
686            r.accel[0].to_string(),
687            r.accel[1].to_string(),
688            r.accel[2].to_string(),
689            r.gyro[0].to_string(),
690            r.gyro[1].to_string(),
691            r.gyro[2].to_string(),
692        ]);
693    }
694
695    /// Serialize to CSV string.
696    pub fn to_csv_string(&self) -> String {
697        let mut out = self.headers.join(",") + "\n";
698        for row in &self.rows {
699            out += &(row.join(",") + "\n");
700        }
701        out
702    }
703
704    /// Number of data rows.
705    pub fn n_rows(&self) -> usize {
706        self.rows.len()
707    }
708
709    /// Write to a BufWriter.
710    pub fn write_to<W: Write>(&self, writer: &mut BufWriter<W>) -> std::io::Result<()> {
711        writer.write_all(self.to_csv_string().as_bytes())
712    }
713}
714
715// --- Helper functions ---
716
717/// Convert Euler angles (rad) to quaternion \[w, x, y, z\].
718fn euler_to_quat(roll: f64, pitch: f64, yaw: f64) -> [f64; 4] {
719    let (cr, sr) = ((roll * 0.5).cos(), (roll * 0.5).sin());
720    let (cp, sp) = ((pitch * 0.5).cos(), (pitch * 0.5).sin());
721    let (cy, sy) = ((yaw * 0.5).cos(), (yaw * 0.5).sin());
722    [
723        cr * cp * cy + sr * sp * sy,
724        sr * cp * cy - cr * sp * sy,
725        cr * sp * cy + sr * cp * sy,
726        cr * cp * sy - sr * sp * cy,
727    ]
728}
729
730/// Convert quaternion to Euler angles \[roll, pitch, yaw\] in degrees.
731fn quat_to_euler(q: [f64; 4]) -> [f64; 3] {
732    let [w, x, y, z] = q;
733    let roll = (2.0 * (w * x + y * z))
734        .atan2(1.0 - 2.0 * (x * x + y * y))
735        .to_degrees();
736    let sin_pitch = (2.0 * (w * y - z * x)).clamp(-1.0, 1.0);
737    let pitch = sin_pitch.asin().to_degrees();
738    let yaw = (2.0 * (w * z + x * y))
739        .atan2(1.0 - 2.0 * (y * y + z * z))
740        .to_degrees();
741    [roll, pitch, yaw]
742}
743
744/// Spherical linear interpolation between two quaternions.
745fn quat_slerp(q1: [f64; 4], q2: [f64; 4], t: f64) -> [f64; 4] {
746    let dot = q1[0] * q2[0] + q1[1] * q2[1] + q1[2] * q2[2] + q1[3] * q2[3];
747    let dot = dot.clamp(-1.0, 1.0);
748    if dot.abs() > 0.9995 {
749        // Linear interpolation for nearly identical quaternions
750        let s = 1.0 - t;
751        let q = [
752            s * q1[0] + t * q2[0],
753            s * q1[1] + t * q2[1],
754            s * q1[2] + t * q2[2],
755            s * q1[3] + t * q2[3],
756        ];
757        let norm = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3])
758            .sqrt()
759            .max(1e-10);
760        return [q[0] / norm, q[1] / norm, q[2] / norm, q[3] / norm];
761    }
762    let theta = dot.acos();
763    let sin_theta = theta.sin().max(1e-10);
764    let s1 = ((1.0 - t) * theta).sin() / sin_theta;
765    let s2 = (t * theta).sin() / sin_theta;
766    [
767        s1 * q1[0] + s2 * q2[0],
768        s1 * q1[1] + s2 * q2[1],
769        s1 * q1[2] + s2 * q2[2],
770        s1 * q1[3] + s2 * q2[3],
771    ]
772}
773
774// ============================================================
775// Tests
776// ============================================================
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn test_imu_serialize_deserialize() {
783        let r = ImuDataRecord::new(
784            1.0,
785            [0.1, 0.2, 9.81],
786            [0.01, 0.02, 0.03],
787            [20.0, 30.0, 40.0],
788        );
789        let bytes = r.to_bytes();
790        assert_eq!(bytes.len(), 80);
791        let r2 = ImuDataRecord::from_bytes(&bytes).unwrap();
792        assert!((r2.timestamp - 1.0).abs() < 1e-15);
793        assert!((r2.accel[2] - 9.81).abs() < 1e-10);
794    }
795
796    #[test]
797    fn test_imu_from_bytes_too_short() {
798        let result = ImuDataRecord::from_bytes(&[0u8; 10]);
799        assert!(result.is_none());
800    }
801
802    #[test]
803    fn test_gps_serialize() {
804        let r = GpsDataRecord::new(0.5, 35.0, 139.0, 100.0, [1.0, 2.0, 0.0], 1.2);
805        let bytes = r.to_bytes();
806        assert_eq!(bytes.len(), 64);
807    }
808
809    #[test]
810    fn test_gps_ground_speed() {
811        let r = GpsDataRecord::new(0.0, 0.0, 0.0, 0.0, [3.0, 4.0, 0.0], 1.0);
812        assert!((r.ground_speed() - 5.0).abs() < 1e-10);
813    }
814
815    #[test]
816    fn test_lidar_frame_add_point() {
817        let mut f = LidarFrame::new(1.0);
818        f.add_point([1.0, 2.0, 3.0], 0.5);
819        assert_eq!(f.n_points, 1);
820    }
821
822    #[test]
823    fn test_lidar_frame_serialize() {
824        let mut f = LidarFrame::new(1.0);
825        f.add_point([1.0, 0.0, 0.0], 0.8);
826        let bytes = f.to_bytes();
827        assert!(!bytes.is_empty());
828    }
829
830    #[test]
831    fn test_lidar_bounding_box() {
832        let mut f = LidarFrame::new(0.0);
833        f.add_point([1.0, 2.0, 3.0], 1.0);
834        f.add_point([-1.0, -2.0, -3.0], 0.5);
835        let bb = f.bounding_box();
836        assert!((bb[0][0] + 1.0).abs() < 1e-6);
837        assert!((bb[0][1] - 1.0).abs() < 1e-6);
838    }
839
840    #[test]
841    fn test_force_gauge_magnitude() {
842        let r = ForceGaugeRecord::new(0.0, [3.0, 4.0, 0.0], [0.0; 3], 25.0);
843        assert!((r.force_magnitude() - 5.0).abs() < 1e-10);
844    }
845
846    #[test]
847    fn test_force_gauge_serialize() {
848        let r = ForceGaugeRecord::new(1.0, [1.0, 2.0, 3.0], [0.1, 0.2, 0.3], 22.5);
849        let bytes = r.to_bytes();
850        assert_eq!(bytes.len(), 64);
851    }
852
853    #[test]
854    fn test_telemetry_encode_decode() {
855        let proto = TelemetryProtocol::new(1, 1);
856        let msg = TelemetryMessage {
857            message_id: 100,
858            seq: 0,
859            payload: vec![1u8, 2, 3, 4],
860        };
861        let encoded = proto.encode(&msg);
862        let decoded = proto.decode(&encoded).unwrap();
863        assert_eq!(decoded.message_id, 100);
864        assert_eq!(decoded.payload, vec![1u8, 2, 3, 4]);
865    }
866
867    #[test]
868    fn test_telemetry_wrong_magic() {
869        let proto = TelemetryProtocol::new(1, 1);
870        let mut data = vec![0u8; 20];
871        data[0] = 0x00; // wrong magic
872        assert!(proto.decode(&data).is_none());
873    }
874
875    #[test]
876    fn test_sensor_fusion_complementary() {
877        let mut sf = SensorFusion::new(0.98, 0.1);
878        let accel = [0.0, 0.0, 9.81];
879        let gyro = [0.0, 0.0, 0.0];
880        sf.update_complementary(accel, gyro, 0.01);
881        assert!(sf.roll().is_finite());
882        assert!(sf.pitch().is_finite());
883    }
884
885    #[test]
886    fn test_sensor_fusion_madgwick() {
887        let mut sf = SensorFusion::new(0.98, 0.1);
888        let accel = [0.0, 0.0, 9.81];
889        let gyro = [0.01, 0.02, 0.0];
890        sf.update_madgwick(accel, gyro, 0.01);
891        let norm = sf.quaternion.iter().map(|v| v * v).sum::<f64>().sqrt();
892        assert!((norm - 1.0).abs() < 0.01);
893    }
894
895    #[test]
896    fn test_data_logger_log_and_flush() {
897        let mut logger: DataLogger<i32> = DataLogger::new(3);
898        logger.log(1);
899        logger.log(2);
900        logger.log(3);
901        logger.log(4); // triggers flush
902        assert_eq!(logger.total_count(), 4);
903    }
904
905    #[test]
906    fn test_data_logger_replay() {
907        let mut logger: DataLogger<i32> = DataLogger::new(10);
908        for i in 0..5 {
909            logger.log(i);
910        }
911        let all = logger.replay();
912        assert_eq!(all.len(), 5);
913    }
914
915    #[test]
916    fn test_sensor_data_writer_imu() {
917        let mut w = SensorDataWriter::new(SensorType::Imu);
918        let r = ImuDataRecord::new(0.0, [0.0; 3], [0.0; 3], [0.0; 3]);
919        w.write_imu(&r);
920        assert_eq!(w.n_records, 1);
921        assert_eq!(w.bytes_written(), 80);
922    }
923
924    #[test]
925    fn test_sensor_data_writer_clear() {
926        let mut w = SensorDataWriter::new(SensorType::Gps);
927        let r = GpsDataRecord::new(0.0, 0.0, 0.0, 0.0, [0.0; 3], 1.0);
928        w.write_gps(&r);
929        w.clear();
930        assert_eq!(w.n_records, 0);
931        assert_eq!(w.bytes_written(), 0);
932    }
933
934    #[test]
935    fn test_sensor_data_reader_imu() {
936        let r = ImuDataRecord::new(1.5, [1.0, 2.0, 3.0], [0.1, 0.2, 0.3], [10.0, 20.0, 30.0]);
937        let bytes = r.to_bytes();
938        let mut reader = SensorDataReader::new(bytes, SensorType::Imu);
939        let r2 = reader.read_imu().unwrap();
940        assert!((r2.timestamp - 1.5).abs() < 1e-10);
941    }
942
943    #[test]
944    fn test_csv_telemetry_to_string() {
945        let mut csv = CsvTelemetry::new(vec!["t".into(), "ax".into(), "ay".into()]);
946        csv.append_row(vec!["0.0".into(), "1.0".into(), "2.0".into()]);
947        let s = csv.to_csv_string();
948        assert!(s.contains("t,ax,ay"));
949        assert!(s.contains("0.0,1.0,2.0"));
950    }
951
952    #[test]
953    fn test_csv_telemetry_n_rows() {
954        let mut csv = CsvTelemetry::new(vec!["x".into()]);
955        csv.append_row(vec!["1".into()]);
956        csv.append_row(vec!["2".into()]);
957        assert_eq!(csv.n_rows(), 2);
958    }
959
960    #[test]
961    fn test_quat_to_euler_identity() {
962        let euler = quat_to_euler([1.0, 0.0, 0.0, 0.0]);
963        for &v in &euler {
964            assert!(v.abs() < 1e-8, "Expected near zero, got {v}");
965        }
966    }
967}
968
969// ============================================================
970// New sensor time-series I/O  (added block)
971// ============================================================
972
973/// Single sensor reading with uncertainty.
974#[derive(Debug, Clone, PartialEq)]
975pub struct SensorReading {
976    /// Timestamp in seconds.
977    pub timestamp: f64,
978    /// Sensor ID.
979    pub sensor_id: usize,
980    /// Measured value (arbitrary units).
981    pub value: f64,
982    /// One-sigma measurement uncertainty.
983    pub uncertainty: f64,
984}
985
986impl SensorReading {
987    /// Create a new sensor reading.
988    pub fn new(timestamp: f64, sensor_id: usize, value: f64, uncertainty: f64) -> Self {
989        Self {
990            timestamp,
991            sensor_id,
992            value,
993            uncertainty,
994        }
995    }
996}
997
998/// Time series of readings for one sensor.
999#[derive(Debug, Clone)]
1000pub struct SensorTimeSeries {
1001    /// Sensor identifier.
1002    pub id: usize,
1003    /// Human-readable sensor name.
1004    pub name: String,
1005    /// Ordered readings (ascending timestamp assumed).
1006    pub readings: Vec<SensorReading>,
1007    /// Nominal sample rate in Hz.
1008    pub sample_rate_hz: f64,
1009}
1010
1011impl SensorTimeSeries {
1012    /// Create a new empty time series.
1013    pub fn new(id: usize, name: impl Into<String>, sample_rate_hz: f64) -> Self {
1014        Self {
1015            id,
1016            name: name.into(),
1017            readings: Vec::new(),
1018            sample_rate_hz,
1019        }
1020    }
1021
1022    /// Append a reading.
1023    pub fn push(&mut self, r: SensorReading) {
1024        self.readings.push(r);
1025    }
1026
1027    /// Number of readings.
1028    pub fn len(&self) -> usize {
1029        self.readings.len()
1030    }
1031
1032    /// True when no readings.
1033    pub fn is_empty(&self) -> bool {
1034        self.readings.is_empty()
1035    }
1036}
1037
1038/// Write a `SensorTimeSeries` to a two-column CSV file `timestamp,value`.
1039///
1040/// Returns an error string on I/O failure.
1041pub fn write_sensor_csv(series: &SensorTimeSeries, path: &str) -> Result<(), String> {
1042    use std::io::Write as IoWrite;
1043    let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
1044    let mut writer = std::io::BufWriter::new(file);
1045    writeln!(writer, "timestamp,value,uncertainty").map_err(|e| e.to_string())?;
1046    for r in &series.readings {
1047        writeln!(writer, "{},{},{}", r.timestamp, r.value, r.uncertainty)
1048            .map_err(|e| e.to_string())?;
1049    }
1050    Ok(())
1051}
1052
1053/// Read a CSV file written by [`write_sensor_csv`] into a `SensorTimeSeries`.
1054pub fn read_sensor_csv(path: &str, sensor_id: usize) -> Result<SensorTimeSeries, String> {
1055    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1056    let mut lines = content.lines();
1057    let _header = lines.next().ok_or("missing header")?;
1058    let mut series = SensorTimeSeries::new(sensor_id, path, 0.0);
1059    for line in lines {
1060        let parts: Vec<&str> = line.split(',').collect();
1061        if parts.len() < 2 {
1062            continue;
1063        }
1064        let ts: f64 = parts[0]
1065            .trim()
1066            .parse()
1067            .map_err(|_| format!("bad timestamp: {}", parts[0]))?;
1068        let val: f64 = parts[1]
1069            .trim()
1070            .parse()
1071            .map_err(|_| format!("bad value: {}", parts[1]))?;
1072        let unc: f64 = if parts.len() >= 3 {
1073            parts[2].trim().parse().unwrap_or(0.0)
1074        } else {
1075            0.0
1076        };
1077        series.push(SensorReading::new(ts, sensor_id, val, unc));
1078    }
1079    Ok(series)
1080}
1081
1082/// Compute a causal moving average of sensor values with the given window size.
1083///
1084/// The first `window-1` values use a partial window.
1085pub fn sensor_moving_average(series: &SensorTimeSeries, window: usize) -> SensorTimeSeries {
1086    let w = window.max(1);
1087    let mut out = SensorTimeSeries::new(series.id, series.name.clone(), series.sample_rate_hz);
1088    for i in 0..series.readings.len() {
1089        let start = (i + 1).saturating_sub(w);
1090        let slice = &series.readings[start..=i];
1091        let avg = slice.iter().map(|r| r.value).sum::<f64>() / slice.len() as f64;
1092        let mut r = series.readings[i].clone();
1093        r.value = avg;
1094        out.push(r);
1095    }
1096    out
1097}
1098
1099/// Downsample a series by keeping every `factor`-th reading.
1100///
1101/// A factor of 1 returns a clone of the original series.
1102pub fn sensor_downsample(series: &SensorTimeSeries, factor: usize) -> SensorTimeSeries {
1103    let f = factor.max(1);
1104    let mut out = SensorTimeSeries::new(
1105        series.id,
1106        series.name.clone(),
1107        series.sample_rate_hz / f as f64,
1108    );
1109    for (i, r) in series.readings.iter().enumerate() {
1110        if i % f == 0 {
1111            out.push(r.clone());
1112        }
1113    }
1114    out
1115}
1116
1117/// Find indices of local maxima above `threshold` with at least `min_gap` samples between peaks.
1118pub fn sensor_peak_detection(
1119    series: &SensorTimeSeries,
1120    threshold: f64,
1121    min_gap: usize,
1122) -> Vec<usize> {
1123    let vals: Vec<f64> = series.readings.iter().map(|r| r.value).collect();
1124    let n = vals.len();
1125    let mut peaks = Vec::new();
1126    let mut last_peak: Option<usize> = None;
1127    for i in 1..n.saturating_sub(1) {
1128        if vals[i] > threshold && vals[i] >= vals[i - 1] && vals[i] >= vals[i + 1] {
1129            if let Some(lp) = last_peak
1130                && i - lp < min_gap
1131            {
1132                continue;
1133            }
1134            peaks.push(i);
1135            last_peak = Some(i);
1136        }
1137    }
1138    peaks
1139}
1140
1141/// Compute the DFT magnitude spectrum of a sensor time series.
1142///
1143/// Returns a vector of `(frequency_hz, magnitude)` pairs for bins 0..N/2.
1144pub fn sensor_fft_spectrum(series: &SensorTimeSeries) -> Vec<(f64, f64)> {
1145    let n = series.readings.len();
1146    if n == 0 {
1147        return Vec::new();
1148    }
1149    let vals: Vec<f64> = series.readings.iter().map(|r| r.value).collect();
1150    let dt = if series.sample_rate_hz > 0.0 {
1151        1.0 / series.sample_rate_hz
1152    } else {
1153        1.0
1154    };
1155    let half = n / 2 + 1;
1156    let mut spectrum = Vec::with_capacity(half);
1157    for k in 0..half {
1158        let mut re = 0.0f64;
1159        let mut im = 0.0f64;
1160        for (t, &v) in vals.iter().enumerate() {
1161            let angle = -2.0 * std::f64::consts::PI * (k as f64) * (t as f64) / (n as f64);
1162            re += v * angle.cos();
1163            im += v * angle.sin();
1164        }
1165        let mag = (re * re + im * im).sqrt();
1166        let freq = (k as f64) / (n as f64 * dt);
1167        spectrum.push((freq, mag));
1168    }
1169    spectrum
1170}
1171
1172/// Apply a linear calibration to a raw sensor value: `gain * raw + offset`.
1173pub fn sensor_calibration_linear(raw: f64, gain: f64, offset: f64) -> f64 {
1174    gain * raw + offset
1175}
1176
1177/// Resample all series in `series` to the same `target_rate` using nearest-neighbour interpolation.
1178///
1179/// Returns one `SensorTimeSeries` per input, all with the same length.
1180pub fn sensor_synchronize(series: &[SensorTimeSeries], target_rate: f64) -> Vec<SensorTimeSeries> {
1181    if series.is_empty() || target_rate <= 0.0 {
1182        return Vec::new();
1183    }
1184    // Determine common time span.
1185    let t_start = series
1186        .iter()
1187        .filter_map(|s| s.readings.first().map(|r| r.timestamp))
1188        .fold(f64::INFINITY, f64::min);
1189    let t_end = series
1190        .iter()
1191        .filter_map(|s| s.readings.last().map(|r| r.timestamp))
1192        .fold(f64::NEG_INFINITY, f64::max);
1193    if t_start >= t_end {
1194        return series
1195            .iter()
1196            .map(|s| SensorTimeSeries::new(s.id, s.name.clone(), target_rate))
1197            .collect();
1198    }
1199    let n_samples = ((t_end - t_start) * target_rate).ceil() as usize + 1;
1200    let timestamps: Vec<f64> = (0..n_samples)
1201        .map(|i| t_start + i as f64 / target_rate)
1202        .collect();
1203
1204    series
1205        .iter()
1206        .map(|s| {
1207            let mut out = SensorTimeSeries::new(s.id, s.name.clone(), target_rate);
1208            for &ts in &timestamps {
1209                // Nearest-neighbour lookup.
1210                let val = if s.readings.is_empty() {
1211                    0.0
1212                } else {
1213                    s.readings
1214                        .iter()
1215                        .min_by(|a, b| {
1216                            (a.timestamp - ts)
1217                                .abs()
1218                                .partial_cmp(&(b.timestamp - ts).abs())
1219                                .expect("operation should succeed")
1220                        })
1221                        .map(|r| r.value)
1222                        .unwrap_or(0.0)
1223                };
1224                out.push(SensorReading::new(ts, s.id, val, 0.0));
1225            }
1226            out
1227        })
1228        .collect()
1229}
1230
1231/// Build a matrix (row = time step, col = channel) from synchronised channels.
1232///
1233/// All channels must have the same length; shorter channels are zero-padded.
1234pub fn merge_sensor_channels(channels: &[SensorTimeSeries]) -> Vec<Vec<f64>> {
1235    if channels.is_empty() {
1236        return Vec::new();
1237    }
1238    let n_rows = channels.iter().map(|c| c.len()).max().unwrap_or(0);
1239    (0..n_rows)
1240        .map(|i| {
1241            channels
1242                .iter()
1243                .map(|c| c.readings.get(i).map(|r| r.value).unwrap_or(0.0))
1244                .collect()
1245        })
1246        .collect()
1247}
1248
1249// ── sensor_io tests ──────────────────────────────────────────────────────────
1250#[cfg(test)]
1251mod sensor_ts_tests {
1252    use super::*;
1253
1254    fn make_series(n: usize, sample_rate_hz: f64) -> SensorTimeSeries {
1255        let mut s = SensorTimeSeries::new(1, "test", sample_rate_hz);
1256        for i in 0..n {
1257            s.push(SensorReading::new(
1258                i as f64 / sample_rate_hz,
1259                1,
1260                i as f64,
1261                0.1,
1262            ));
1263        }
1264        s
1265    }
1266
1267    #[test]
1268    fn test_sensor_reading_new() {
1269        let r = SensorReading::new(1.0, 2, 3.5, 0.1);
1270        assert!((r.timestamp - 1.0).abs() < 1e-15);
1271        assert_eq!(r.sensor_id, 2);
1272        assert!((r.value - 3.5).abs() < 1e-15);
1273    }
1274
1275    #[test]
1276    fn test_series_len() {
1277        let s = make_series(10, 100.0);
1278        assert_eq!(s.len(), 10);
1279    }
1280
1281    #[test]
1282    fn test_series_is_empty() {
1283        let s = SensorTimeSeries::new(0, "empty", 100.0);
1284        assert!(s.is_empty());
1285    }
1286
1287    #[test]
1288    fn test_write_read_csv_roundtrip() {
1289        let s = make_series(5, 10.0);
1290        let path = "/tmp/oxiphysics_sensor_test.csv";
1291        write_sensor_csv(&s, path).unwrap();
1292        let s2 = read_sensor_csv(path, 1).unwrap();
1293        assert_eq!(s2.len(), 5);
1294        for (a, b) in s.readings.iter().zip(s2.readings.iter()) {
1295            assert!((a.timestamp - b.timestamp).abs() < 1e-10);
1296            assert!((a.value - b.value).abs() < 1e-10);
1297        }
1298    }
1299
1300    #[test]
1301    fn test_write_csv_creates_file() {
1302        let s = make_series(3, 1.0);
1303        let path = "/tmp/oxiphysics_sensor_create_test.csv";
1304        assert!(write_sensor_csv(&s, path).is_ok());
1305    }
1306
1307    #[test]
1308    fn test_read_csv_nonexistent() {
1309        let res = read_sensor_csv("/tmp/nonexistent_oxiphysics_xyz.csv", 0);
1310        assert!(res.is_err());
1311    }
1312
1313    #[test]
1314    fn test_moving_average_same_length() {
1315        let s = make_series(10, 10.0);
1316        let avg = sensor_moving_average(&s, 3);
1317        assert_eq!(avg.len(), s.len());
1318    }
1319
1320    #[test]
1321    fn test_moving_average_smooths() {
1322        // Noisy signal: alternating 0, 10
1323        let mut s = SensorTimeSeries::new(0, "noisy", 1.0);
1324        for i in 0..10 {
1325            s.push(SensorReading::new(
1326                i as f64,
1327                0,
1328                if i % 2 == 0 { 0.0 } else { 10.0 },
1329                0.0,
1330            ));
1331        }
1332        let avg = sensor_moving_average(&s, 3);
1333        // Peak value should be less than original max.
1334        let max_avg = avg
1335            .readings
1336            .iter()
1337            .map(|r| r.value)
1338            .fold(f64::NEG_INFINITY, f64::max);
1339        assert!(max_avg < 10.0, "max_avg={max_avg}");
1340    }
1341
1342    #[test]
1343    fn test_moving_average_window_1_identity() {
1344        let s = make_series(5, 10.0);
1345        let avg = sensor_moving_average(&s, 1);
1346        for (a, b) in s.readings.iter().zip(avg.readings.iter()) {
1347            assert!((a.value - b.value).abs() < 1e-12);
1348        }
1349    }
1350
1351    #[test]
1352    fn test_downsample_reduces_count() {
1353        let s = make_series(10, 10.0);
1354        let ds = sensor_downsample(&s, 2);
1355        assert_eq!(ds.len(), 5);
1356    }
1357
1358    #[test]
1359    fn test_downsample_factor_1_same_count() {
1360        let s = make_series(8, 10.0);
1361        let ds = sensor_downsample(&s, 1);
1362        assert_eq!(ds.len(), s.len());
1363    }
1364
1365    #[test]
1366    fn test_downsample_sample_rate_reduced() {
1367        let s = make_series(10, 100.0);
1368        let ds = sensor_downsample(&s, 4);
1369        assert!((ds.sample_rate_hz - 25.0).abs() < 1e-10);
1370    }
1371
1372    #[test]
1373    fn test_peak_detection_finds_peaks() {
1374        let mut s = SensorTimeSeries::new(0, "peaks", 1.0);
1375        // Create: 0 5 0 0 7 0 0 6 0
1376        let vals = [0.0f64, 5.0, 0.0, 0.0, 7.0, 0.0, 0.0, 6.0, 0.0];
1377        for (i, &v) in vals.iter().enumerate() {
1378            s.push(SensorReading::new(i as f64, 0, v, 0.0));
1379        }
1380        let peaks = sensor_peak_detection(&s, 3.0, 1);
1381        assert!(!peaks.is_empty(), "should find at least one peak");
1382        // Check that all found peaks are above threshold.
1383        for &p in &peaks {
1384            assert!(s.readings[p].value > 3.0);
1385        }
1386    }
1387
1388    #[test]
1389    fn test_peak_detection_no_peaks_below_threshold() {
1390        let mut s = SensorTimeSeries::new(0, "flat", 1.0);
1391        for i in 0..5 {
1392            s.push(SensorReading::new(i as f64, 0, 1.0, 0.0));
1393        }
1394        let peaks = sensor_peak_detection(&s, 5.0, 1);
1395        assert!(peaks.is_empty());
1396    }
1397
1398    #[test]
1399    fn test_peak_detection_min_gap_enforced() {
1400        let mut s = SensorTimeSeries::new(0, "close_peaks", 1.0);
1401        let vals = [0.0f64, 5.0, 0.0, 5.0, 0.0, 5.0, 0.0];
1402        for (i, &v) in vals.iter().enumerate() {
1403            s.push(SensorReading::new(i as f64, 0, v, 0.0));
1404        }
1405        let peaks_close = sensor_peak_detection(&s, 3.0, 1);
1406        let peaks_far = sensor_peak_detection(&s, 3.0, 3);
1407        assert!(peaks_far.len() <= peaks_close.len());
1408    }
1409
1410    #[test]
1411    fn test_fft_spectrum_positive_magnitude() {
1412        let s = make_series(16, 16.0);
1413        let spec = sensor_fft_spectrum(&s);
1414        assert!(!spec.is_empty());
1415        for (_, mag) in &spec {
1416            assert!(*mag >= 0.0);
1417        }
1418    }
1419
1420    #[test]
1421    fn test_fft_spectrum_empty_series() {
1422        let s = SensorTimeSeries::new(0, "empty", 100.0);
1423        let spec = sensor_fft_spectrum(&s);
1424        assert!(spec.is_empty());
1425    }
1426
1427    #[test]
1428    fn test_fft_spectrum_length() {
1429        let s = make_series(10, 10.0);
1430        let spec = sensor_fft_spectrum(&s);
1431        assert_eq!(spec.len(), 6); // N/2 + 1 = 5 + 1
1432    }
1433
1434    #[test]
1435    fn test_calibration_linear_gain_offset() {
1436        let cal = sensor_calibration_linear(5.0, 2.0, 1.0);
1437        assert!((cal - 11.0).abs() < 1e-12);
1438    }
1439
1440    #[test]
1441    fn test_calibration_linear_identity() {
1442        let val = sensor_calibration_linear(3.0, 1.0, 0.0);
1443        assert!((val - 3.0).abs() < 1e-12);
1444    }
1445
1446    #[test]
1447    fn test_calibration_linear_zero_gain() {
1448        let val = sensor_calibration_linear(100.0, 0.0, 5.0);
1449        assert!((val - 5.0).abs() < 1e-12);
1450    }
1451
1452    #[test]
1453    fn test_synchronize_equal_length() {
1454        let s1 = make_series(10, 10.0);
1455        let s2 = make_series(8, 8.0);
1456        let synced = sensor_synchronize(&[s1, s2], 10.0);
1457        assert_eq!(synced.len(), 2);
1458        assert_eq!(synced[0].len(), synced[1].len());
1459    }
1460
1461    #[test]
1462    fn test_synchronize_empty_input() {
1463        let synced = sensor_synchronize(&[], 10.0);
1464        assert!(synced.is_empty());
1465    }
1466
1467    #[test]
1468    fn test_synchronize_zero_rate() {
1469        let s = make_series(5, 10.0);
1470        let synced = sensor_synchronize(&[s], 0.0);
1471        assert!(synced.is_empty());
1472    }
1473
1474    #[test]
1475    fn test_merge_sensor_channels_shape() {
1476        let s1 = make_series(5, 10.0);
1477        let s2 = make_series(5, 10.0);
1478        let mat = merge_sensor_channels(&[s1, s2]);
1479        assert_eq!(mat.len(), 5);
1480        for row in &mat {
1481            assert_eq!(row.len(), 2);
1482        }
1483    }
1484
1485    #[test]
1486    fn test_merge_sensor_channels_empty() {
1487        let mat = merge_sensor_channels(&[]);
1488        assert!(mat.is_empty());
1489    }
1490
1491    #[test]
1492    fn test_merge_sensor_channels_values() {
1493        let s1 = make_series(3, 1.0);
1494        let mat = merge_sensor_channels(&[s1]);
1495        assert!((mat[0][0] - 0.0).abs() < 1e-10);
1496        assert!((mat[1][0] - 1.0).abs() < 1e-10);
1497        assert!((mat[2][0] - 2.0).abs() < 1e-10);
1498    }
1499
1500    #[test]
1501    fn test_csv_roundtrip_larger_series() {
1502        let s = make_series(100, 50.0);
1503        let path = "/tmp/oxiphysics_sensor_large.csv";
1504        write_sensor_csv(&s, path).unwrap();
1505        let s2 = read_sensor_csv(path, 1).unwrap();
1506        assert_eq!(s2.len(), 100);
1507    }
1508
1509    #[test]
1510    fn test_sensor_time_series_push() {
1511        let mut s = SensorTimeSeries::new(0, "x", 1.0);
1512        s.push(SensorReading::new(0.0, 0, 42.0, 0.0));
1513        assert_eq!(s.len(), 1);
1514        assert!(!s.is_empty());
1515    }
1516
1517    #[test]
1518    fn test_fft_dc_component_is_sum() {
1519        // DC bin (k=0) magnitude should equal |sum of values|.
1520        let mut s = SensorTimeSeries::new(0, "dc", 4.0);
1521        let vals = [2.0f64, 2.0, 2.0, 2.0];
1522        for (i, &v) in vals.iter().enumerate() {
1523            s.push(SensorReading::new(i as f64 / 4.0, 0, v, 0.0));
1524        }
1525        let spec = sensor_fft_spectrum(&s);
1526        // DC magnitude = |sum| = 8.0
1527        assert!((spec[0].1 - 8.0).abs() < 1e-8);
1528    }
1529}