Skip to main content

oxihuman_export/
telemetry_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Telemetry frame export with channel metadata.
6
7/// A telemetry channel definition.
8#[allow(dead_code)]
9#[derive(Debug, Clone)]
10pub struct TelemetryChannel {
11    pub id: u32,
12    pub name: String,
13    pub unit: String,
14    pub data_type: String,
15}
16
17/// A single telemetry frame (one timestamp, multiple channel values).
18#[allow(dead_code)]
19#[derive(Debug, Clone)]
20pub struct TelemetryFrame {
21    pub timestamp_us: u64,
22    pub values: Vec<f64>,
23}
24
25/// A telemetry export session.
26#[allow(dead_code)]
27pub struct TelemetryExport {
28    pub session_id: String,
29    pub channels: Vec<TelemetryChannel>,
30    pub frames: Vec<TelemetryFrame>,
31}
32
33impl TelemetryExport {
34    #[allow(dead_code)]
35    pub fn new(session_id: &str) -> Self {
36        Self {
37            session_id: session_id.to_string(),
38            channels: Vec::new(),
39            frames: Vec::new(),
40        }
41    }
42}
43
44/// Add a channel definition.
45#[allow(dead_code)]
46pub fn add_telemetry_channel(
47    export: &mut TelemetryExport,
48    name: &str,
49    unit: &str,
50    data_type: &str,
51) -> u32 {
52    let id = export.channels.len() as u32;
53    export.channels.push(TelemetryChannel {
54        id,
55        name: name.to_string(),
56        unit: unit.to_string(),
57        data_type: data_type.to_string(),
58    });
59    id
60}
61
62/// Record a telemetry frame.
63#[allow(dead_code)]
64pub fn record_frame(export: &mut TelemetryExport, timestamp_us: u64, values: Vec<f64>) {
65    export.frames.push(TelemetryFrame {
66        timestamp_us,
67        values,
68    });
69}
70
71/// Export to CSV string.
72#[allow(dead_code)]
73pub fn export_telemetry_csv(export: &TelemetryExport) -> String {
74    let mut out = String::new();
75    out.push_str("timestamp_us");
76    for ch in &export.channels {
77        out.push_str(&format!(",{}", ch.name));
78    }
79    out.push('\n');
80    for frame in &export.frames {
81        out.push_str(&frame.timestamp_us.to_string());
82        for v in &frame.values {
83            out.push_str(&format!(",{v}"));
84        }
85        out.push('\n');
86    }
87    out
88}
89
90/// Export metadata as JSON-like string.
91#[allow(dead_code)]
92pub fn export_telemetry_meta(export: &TelemetryExport) -> String {
93    let mut out = format!("{{\"session_id\":\"{}\",\"channels\":[", export.session_id);
94    for (i, ch) in export.channels.iter().enumerate() {
95        if i > 0 {
96            out.push(',');
97        }
98        out.push_str(&format!(
99            "{{\"id\":{},\"name\":\"{}\",\"unit\":\"{}\",\"type\":\"{}\"}}",
100            ch.id, ch.name, ch.unit, ch.data_type
101        ));
102    }
103    out.push_str("]}");
104    out
105}
106
107/// Frame count.
108#[allow(dead_code)]
109pub fn frame_count_tl(export: &TelemetryExport) -> usize {
110    export.frames.len()
111}
112
113/// Channel count.
114#[allow(dead_code)]
115pub fn channel_count_tl(export: &TelemetryExport) -> usize {
116    export.channels.len()
117}
118
119/// Duration in microseconds.
120#[allow(dead_code)]
121pub fn session_duration_us(export: &TelemetryExport) -> u64 {
122    if export.frames.is_empty() {
123        return 0;
124    }
125    let min_t = export
126        .frames
127        .iter()
128        .map(|f| f.timestamp_us)
129        .min()
130        .unwrap_or(0);
131    let max_t = export
132        .frames
133        .iter()
134        .map(|f| f.timestamp_us)
135        .max()
136        .unwrap_or(0);
137    max_t.saturating_sub(min_t)
138}
139
140/// Average value of a channel across all frames.
141#[allow(dead_code)]
142pub fn channel_average(export: &TelemetryExport, channel_id: usize) -> f64 {
143    let values: Vec<f64> = export
144        .frames
145        .iter()
146        .filter_map(|f| f.values.get(channel_id).copied())
147        .collect();
148    if values.is_empty() {
149        0.0
150    } else {
151        values.iter().sum::<f64>() / values.len() as f64
152    }
153}
154
155/// Find channel by name.
156#[allow(dead_code)]
157pub fn find_channel_by_name<'a>(
158    export: &'a TelemetryExport,
159    name: &str,
160) -> Option<&'a TelemetryChannel> {
161    export.channels.iter().find(|c| c.name == name)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn sample_export() -> TelemetryExport {
169        let mut e = TelemetryExport::new("test_session");
170        add_telemetry_channel(&mut e, "velocity", "m/s", "f64");
171        add_telemetry_channel(&mut e, "rpm", "rpm", "f64");
172        record_frame(&mut e, 0, vec![1.0, 3000.0]);
173        record_frame(&mut e, 1000, vec![2.0, 3200.0]);
174        record_frame(&mut e, 2000, vec![1.5, 3100.0]);
175        e
176    }
177
178    #[test]
179    fn channel_count_correct() {
180        let e = sample_export();
181        assert_eq!(channel_count_tl(&e), 2);
182    }
183
184    #[test]
185    fn frame_count_correct() {
186        let e = sample_export();
187        assert_eq!(frame_count_tl(&e), 3);
188    }
189
190    #[test]
191    fn session_duration_2000us() {
192        let e = sample_export();
193        assert_eq!(session_duration_us(&e), 2000);
194    }
195
196    #[test]
197    fn channel_average_velocity() {
198        let e = sample_export();
199        let avg = channel_average(&e, 0);
200        assert!((avg - 1.5).abs() < 1e-5);
201    }
202
203    #[test]
204    fn csv_has_header() {
205        let e = sample_export();
206        let csv = export_telemetry_csv(&e);
207        assert!(csv.starts_with("timestamp_us,velocity,rpm"));
208    }
209
210    #[test]
211    fn csv_line_count() {
212        let e = sample_export();
213        let csv = export_telemetry_csv(&e);
214        let lines: Vec<&str> = csv.trim().split('\n').collect();
215        assert_eq!(lines.len(), 4);
216    }
217
218    #[test]
219    fn meta_contains_session_id() {
220        let e = sample_export();
221        let meta = export_telemetry_meta(&e);
222        assert!(meta.contains("test_session"));
223    }
224
225    #[test]
226    fn find_channel_by_name_some() {
227        let e = sample_export();
228        let ch = find_channel_by_name(&e, "velocity");
229        assert!(ch.is_some());
230    }
231
232    #[test]
233    fn find_channel_by_name_none() {
234        let e = sample_export();
235        let ch = find_channel_by_name(&e, "nonexistent");
236        assert!(ch.is_none());
237    }
238
239    #[test]
240    fn empty_session_duration_zero() {
241        let e = TelemetryExport::new("empty");
242        assert_eq!(session_duration_us(&e), 0);
243    }
244}