Skip to main content

syntax_workout_core/
stream.rs

1use serde::{Deserialize, Serialize};
2use ts_rs::TS;
3
4/// A GPS position with optional altitude.
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
6#[ts(export, export_to = "../../../bindings/napi/generated/")]
7pub struct Position {
8    pub lat: f64,
9    pub lng: f64,
10    #[serde(skip_serializing_if = "Option::is_none")]
11    pub altitude: Option<f64>,
12}
13
14/// Primary sensor metric for a stream channel.
15///
16/// Covers the universal metrics found across Garmin FIT, Strava, and HealthKit.
17/// Use `Custom(String)` for device-specific or emerging metrics (e.g., VO2, HRV).
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
19#[ts(export, export_to = "../../../bindings/napi/generated/")]
20pub enum StreamMetric {
21    HeartRate,
22    Speed,
23    Cadence,
24    Power,
25    Altitude,
26    Grade,
27    Temperature,
28    Position,
29    Custom(String),
30}
31
32/// The data payload for a stream channel.
33///
34/// Per-channel typed enum: each channel is either entirely scalar values
35/// or entirely GPS positions. Match once per channel, then work with
36/// a uniform array.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
38#[ts(export, export_to = "../../../bindings/napi/generated/")]
39#[serde(tag = "type", content = "values")]
40pub enum StreamData {
41    /// Scalar measurements (HR in bpm, speed in m/s, power in watts, etc.).
42    /// NaN represents missing values at a given index.
43    Scalar(Vec<f64>),
44    /// GPS positions. Individual entries may be None (GPS signal loss).
45    Position(Vec<Option<Position>>),
46}
47
48impl StreamData {
49    /// Returns the number of samples in this channel.
50    pub fn len(&self) -> usize {
51        match self {
52            StreamData::Scalar(v) => v.len(),
53            StreamData::Position(v) => v.len(),
54        }
55    }
56
57    pub fn is_empty(&self) -> bool {
58        self.len() == 0
59    }
60}
61
62/// A named channel of time-series sensor data.
63///
64/// All channels within a [`Streams`] struct share the same timestamps
65/// array — entry `data[i]` corresponds to `timestamps[i]`.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
67#[ts(export, export_to = "../../../bindings/napi/generated/")]
68pub struct Stream {
69    pub metric: StreamMetric,
70    pub data: StreamData,
71}
72
73/// Validation error for stream data.
74#[derive(Debug, Clone, PartialEq)]
75pub struct StreamValidationError {
76    pub message: String,
77}
78
79impl std::fmt::Display for StreamValidationError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        write!(f, "{}", self.message)
82    }
83}
84
85impl std::error::Error for StreamValidationError {}
86
87/// Time-series data associated with a workout.
88///
89/// Design: Strava-style columnar layout. All arrays are index-aligned —
90/// `timestamps[i]` and every `channels[j].data[i]` refer to the same
91/// moment in time.
92///
93/// ```text
94/// TIME AXIS ──────────────────────────────────────────────▶
95///
96/// timestamps: [0, 5, 10, 15, 20, ...]
97/// channels:
98///   HeartRate(Scalar): [85, 125, 130, 129, 128, ...]
99///   Speed(Scalar):     [0, 2.5, 2.7, 2.8, 2.9, ...]
100///   GPS(Position):     [{51.5,-0.1}, {51.5,-0.1}, ...]
101/// ```
102///
103/// This is separate from the Node tree intentionally. The tree describes
104/// structure (exercises, sets, blocks). Streams describe continuous
105/// sensor recordings over time. They are connected by time offsets,
106/// not by parent-child nesting.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
108#[ts(export, export_to = "../../../bindings/napi/generated/")]
109pub struct Streams {
110    /// Seconds from workout start for each sample index.
111    /// Monotonically increasing. Length defines the shared array size.
112    pub timestamps: Vec<f64>,
113
114    /// Sensor data channels, all index-aligned with timestamps.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub channels: Vec<Stream>,
117}
118
119impl Streams {
120    /// Validate structural invariants: all channel data arrays must have
121    /// the same length as `timestamps`.
122    ///
123    /// Does NOT check semantic invariants (monotonic timestamps, valid
124    /// lat/lng ranges, NaN values). Semantic validation is app-level.
125    pub fn validate(&self) -> Result<(), StreamValidationError> {
126        let expected = self.timestamps.len();
127        for (i, channel) in self.channels.iter().enumerate() {
128            let actual = channel.data.len();
129            if actual != expected {
130                return Err(StreamValidationError {
131                    message: format!(
132                        "channel {} ({:?}) has {} samples, expected {} (timestamps.len())",
133                        i, channel.metric, actual, expected
134                    ),
135                });
136            }
137        }
138        Ok(())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn scalar_stream_round_trip() {
148        let stream = Stream {
149            metric: StreamMetric::HeartRate,
150            data: StreamData::Scalar(vec![85.0, 125.0, 130.0, 129.0]),
151        };
152        let json = serde_json::to_string_pretty(&stream).unwrap();
153        let back: Stream = serde_json::from_str(&json).unwrap();
154        assert_eq!(back, stream);
155        assert!(json.contains(r#""type": "Scalar""#));
156    }
157
158    #[test]
159    fn position_stream_round_trip() {
160        let stream = Stream {
161            metric: StreamMetric::Position,
162            data: StreamData::Position(vec![
163                Some(Position { lat: 51.5074, lng: -0.1278, altitude: Some(45.0) }),
164                None, // GPS signal loss
165                Some(Position { lat: 51.5075, lng: -0.1277, altitude: None }),
166            ]),
167        };
168        let json = serde_json::to_string_pretty(&stream).unwrap();
169        let back: Stream = serde_json::from_str(&json).unwrap();
170        assert_eq!(back, stream);
171    }
172
173    #[test]
174    fn streams_full_round_trip() {
175        let streams = Streams {
176            timestamps: vec![0.0, 5.0, 10.0, 15.0],
177            channels: vec![
178                Stream {
179                    metric: StreamMetric::HeartRate,
180                    data: StreamData::Scalar(vec![85.0, 125.0, 130.0, 129.0]),
181                },
182                Stream {
183                    metric: StreamMetric::Speed,
184                    data: StreamData::Scalar(vec![0.0, 2.5, 2.7, 2.8]),
185                },
186                Stream {
187                    metric: StreamMetric::Position,
188                    data: StreamData::Position(vec![
189                        Some(Position { lat: 51.5, lng: -0.1, altitude: Some(45.0) }),
190                        Some(Position { lat: 51.5, lng: -0.1, altitude: Some(46.0) }),
191                        Some(Position { lat: 51.5, lng: -0.1, altitude: Some(48.0) }),
192                        Some(Position { lat: 51.5, lng: -0.1, altitude: Some(52.0) }),
193                    ]),
194                },
195            ],
196        };
197        let json = serde_json::to_string_pretty(&streams).unwrap();
198        let back: Streams = serde_json::from_str(&json).unwrap();
199        assert_eq!(back, streams);
200    }
201
202    #[test]
203    fn streams_validate_valid() {
204        let streams = Streams {
205            timestamps: vec![0.0, 5.0, 10.0],
206            channels: vec![
207                Stream {
208                    metric: StreamMetric::HeartRate,
209                    data: StreamData::Scalar(vec![85.0, 125.0, 130.0]),
210                },
211                Stream {
212                    metric: StreamMetric::Position,
213                    data: StreamData::Position(vec![
214                        Some(Position { lat: 51.5, lng: -0.1, altitude: None }),
215                        None,
216                        Some(Position { lat: 51.5, lng: -0.1, altitude: None }),
217                    ]),
218                },
219            ],
220        };
221        assert!(streams.validate().is_ok());
222    }
223
224    #[test]
225    fn streams_validate_invalid_scalar() {
226        let streams = Streams {
227            timestamps: vec![0.0, 5.0, 10.0],
228            channels: vec![
229                Stream {
230                    metric: StreamMetric::HeartRate,
231                    data: StreamData::Scalar(vec![85.0, 125.0]), // wrong length
232                },
233            ],
234        };
235        let err = streams.validate().unwrap_err();
236        assert!(err.message.contains("2 samples"));
237        assert!(err.message.contains("expected 3"));
238    }
239
240    #[test]
241    fn streams_validate_invalid_position() {
242        let streams = Streams {
243            timestamps: vec![0.0, 5.0, 10.0],
244            channels: vec![
245                Stream {
246                    metric: StreamMetric::Position,
247                    data: StreamData::Position(vec![
248                        Some(Position { lat: 51.5, lng: -0.1, altitude: None }),
249                        None,
250                        None,
251                        None, // extra entry
252                    ]),
253                },
254            ],
255        };
256        let err = streams.validate().unwrap_err();
257        assert!(err.message.contains("4 samples"));
258        assert!(err.message.contains("expected 3"));
259    }
260
261    #[test]
262    fn stream_metric_variants_round_trip() {
263        let variants = vec![
264            StreamMetric::HeartRate,
265            StreamMetric::Speed,
266            StreamMetric::Cadence,
267            StreamMetric::Power,
268            StreamMetric::Altitude,
269            StreamMetric::Grade,
270            StreamMetric::Temperature,
271            StreamMetric::Position,
272            StreamMetric::Custom("VO2".into()),
273        ];
274        for metric in variants {
275            let json = serde_json::to_string(&metric).unwrap();
276            let back: StreamMetric = serde_json::from_str(&json).unwrap();
277            assert_eq!(back, metric);
278        }
279    }
280
281    #[test]
282    fn stream_data_len() {
283        let scalar = StreamData::Scalar(vec![1.0, 2.0, 3.0]);
284        assert_eq!(scalar.len(), 3);
285        assert!(!scalar.is_empty());
286
287        let pos = StreamData::Position(vec![None, None]);
288        assert_eq!(pos.len(), 2);
289
290        let empty = StreamData::Scalar(vec![]);
291        assert!(empty.is_empty());
292    }
293
294    #[test]
295    fn empty_streams_round_trip() {
296        let streams = Streams {
297            timestamps: vec![],
298            channels: vec![],
299        };
300        let json = serde_json::to_string(&streams).unwrap();
301        let back: Streams = serde_json::from_str(&json).unwrap();
302        assert_eq!(back, streams);
303        assert!(streams.validate().is_ok());
304    }
305}