Skip to main content

chronix_analytics/preprocess/
resampling.rs

1//! Resampling — change the sampling rate of time-series data.
2
3use crate::compute::simd_sum;
4use crate::preprocess::interpolation::Interpolator;
5
6/// Aggregation function for downsampling.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8pub enum AggregationFn {
9    /// Arithmetic mean of values in the bucket.
10    Mean,
11    /// Minimum value in the bucket.
12    Min,
13    /// Maximum value in the bucket.
14    Max,
15    /// Sum of values in the bucket.
16    Sum,
17    /// Last (most recent) value in the bucket.
18    Last,
19    /// First (earliest) value in the bucket.
20    First,
21}
22
23/// Configuration for resampling.
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub struct ResampleConfig {
26    /// Target interval in nanoseconds.
27    pub target_interval_ns: i64,
28    /// Aggregation function for downsampling.
29    pub aggregation: AggregationFn,
30    /// Interpolation method for upsampling.
31    pub interpolation: Interpolator,
32}
33
34/// Resampler converts time-series data from one interval to another.
35pub struct Resampler;
36
37impl Resampler {
38    /// Resample the given data to the target interval.
39    ///
40    /// - **Downscale** (reduce rate): groups by time boundaries, applies aggregation.
41    /// - **Upscale** (increase rate): interpolates between points.
42    pub fn resample(
43        timestamps: &[i64],
44        values: &[f64],
45        config: &ResampleConfig,
46    ) -> (Vec<i64>, Vec<f64>) {
47        if timestamps.is_empty() {
48            return (Vec::new(), Vec::new());
49        }
50        if timestamps.len() == 1 {
51            return (timestamps.to_vec(), values.to_vec());
52        }
53
54        // Determine source interval (median delta)
55        let source_interval = Self::estimate_interval(timestamps);
56
57        if config.target_interval_ns > source_interval {
58            Self::downscale(timestamps, values, config)
59        } else if config.target_interval_ns < source_interval {
60            Self::upscale(timestamps, values, config)
61        } else {
62            (timestamps.to_vec(), values.to_vec())
63        }
64    }
65
66    /// Estimate the source interval from the timestamp data.
67    fn estimate_interval(timestamps: &[i64]) -> i64 {
68        if timestamps.len() < 2 {
69            return 0;
70        }
71        let mut deltas: Vec<i64> = timestamps.windows(2).map(|w| w[1] - w[0]).collect();
72        deltas.sort_unstable();
73        deltas[deltas.len() / 2] // median
74    }
75
76    /// Downscale: group into buckets and aggregate using O(n) two-pointer scan.
77    fn downscale(
78        timestamps: &[i64],
79        values: &[f64],
80        config: &ResampleConfig,
81    ) -> (Vec<i64>, Vec<f64>) {
82        let interval = config.target_interval_ns;
83        if interval <= 0 {
84            return (timestamps.to_vec(), values.to_vec());
85        }
86        let start = timestamps[0];
87        let end = timestamps[timestamps.len() - 1];
88
89        // Use saturating arithmetic to prevent overflow when the time span
90        // is huge relative to the interval (e.g. years of nanosecond data).
91        let n_buckets = ((end - start) / interval).saturating_add(1) as usize;
92        // Cap pre-allocation at input length to prevent OOM when
93        // target_interval_ns is very small relative to the time span.
94        let cap = n_buckets.min(timestamps.len());
95        let mut out_ts = Vec::with_capacity(cap);
96        let mut out_vals = Vec::with_capacity(cap);
97
98        // Single-pass two-pointer: timestamps are sorted, so we advance a cursor.
99        // When a bucket is empty (sparse data), skip directly to the bucket
100        // containing the next timestamp instead of iterating empty buckets.
101        let mut cursor = 0usize;
102        let mut bucket_start = start;
103        while bucket_start <= end && cursor < timestamps.len() {
104            let bucket_end = bucket_start.saturating_add(interval);
105
106            // Collect values in this bucket via advancing cursor
107            let bucket_begin = cursor;
108            while cursor < timestamps.len()
109                && timestamps[cursor] >= bucket_start
110                && timestamps[cursor] < bucket_end
111            {
112                cursor += 1;
113            }
114            let bucket_vals = &values[bucket_begin..cursor];
115
116            if !bucket_vals.is_empty() {
117                let agg = match config.aggregation {
118                    AggregationFn::Mean => simd_sum(bucket_vals) / bucket_vals.len() as f64,
119                    AggregationFn::Min => bucket_vals.iter().copied().fold(f64::INFINITY, f64::min),
120                    AggregationFn::Max => bucket_vals
121                        .iter()
122                        .copied()
123                        .fold(f64::NEG_INFINITY, f64::max),
124                    AggregationFn::Sum => simd_sum(bucket_vals),
125                    AggregationFn::Last => bucket_vals[bucket_vals.len() - 1],
126                    AggregationFn::First => bucket_vals[0],
127                };
128                out_ts.push(bucket_start);
129                out_vals.push(agg);
130                bucket_start = bucket_end;
131            } else if cursor < timestamps.len() {
132                // Empty bucket with more data ahead — skip directly to the
133                // bucket containing the next timestamp (O(1) instead of
134                // iterating through potentially millions of empty buckets).
135                // Use overflow-safe arithmetic: offset - offset.rem_euclid(interval)
136                let offset = timestamps[cursor] - start;
137                bucket_start = start.saturating_add(offset - offset.rem_euclid(interval));
138            } else {
139                // No more data points — done.
140                break;
141            }
142        }
143
144        (out_ts, out_vals)
145    }
146
147    /// Upscale: interpolate to fill in higher-frequency points.
148    fn upscale(
149        timestamps: &[i64],
150        values: &[f64],
151        config: &ResampleConfig,
152    ) -> (Vec<i64>, Vec<f64>) {
153        // Linear interpolation never returns an error, but fall back to
154        // the original data if it somehow does rather than panicking.
155        match config
156            .interpolation
157            .fill(timestamps, values, config.target_interval_ns)
158        {
159            Ok((ts, vals, _, _)) => (ts, vals),
160            Err(_) => (timestamps.to_vec(), values.to_vec()),
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn make_ts(secs: &[i64]) -> Vec<i64> {
170        secs.iter().map(|&s| s * 1_000_000_000).collect()
171    }
172
173    #[test]
174    fn downscale_mean() {
175        // 1s data → 3s downscale (mean)
176        let ts = make_ts(&[0, 1, 2, 3, 4, 5, 6, 7, 8]);
177        let vals: Vec<f64> = (0..9).map(|i| i as f64 * 10.0).collect();
178        let config = ResampleConfig {
179            target_interval_ns: 3_000_000_000,
180            aggregation: AggregationFn::Mean,
181            interpolation: Interpolator::Linear,
182        };
183        let (out_ts, out_vals) = Resampler::resample(&ts, &vals, &config);
184        assert_eq!(out_ts.len(), 3);
185        // Bucket [0,3): values 0,10,20 → mean=10
186        assert!((out_vals[0] - 10.0).abs() < 1e-10);
187        // Bucket [3,6): values 30,40,50 → mean=40
188        assert!((out_vals[1] - 40.0).abs() < 1e-10);
189        // Bucket [6,9): values 60,70,80 → mean=70
190        assert!((out_vals[2] - 70.0).abs() < 1e-10);
191    }
192
193    #[test]
194    fn downscale_min_max() {
195        let ts = make_ts(&[0, 1, 2, 3, 4, 5]);
196        let vals = vec![5.0, 1.0, 8.0, 3.0, 9.0, 2.0];
197        let min_config = ResampleConfig {
198            target_interval_ns: 3_000_000_000,
199            aggregation: AggregationFn::Min,
200            interpolation: Interpolator::Linear,
201        };
202        let (_, min_vals) = Resampler::resample(&ts, &vals, &min_config);
203        assert!((min_vals[0] - 1.0).abs() < 1e-10);
204
205        let max_config = ResampleConfig {
206            target_interval_ns: 3_000_000_000,
207            aggregation: AggregationFn::Max,
208            interpolation: Interpolator::Linear,
209        };
210        let (_, max_vals) = Resampler::resample(&ts, &vals, &max_config);
211        assert!((max_vals[0] - 8.0).abs() < 1e-10);
212    }
213
214    #[test]
215    fn empty_data() {
216        let config = ResampleConfig {
217            target_interval_ns: 1_000_000_000,
218            aggregation: AggregationFn::Mean,
219            interpolation: Interpolator::Linear,
220        };
221        let (ts, vals) = Resampler::resample(&[], &[], &config);
222        assert!(ts.is_empty());
223        assert!(vals.is_empty());
224    }
225
226    #[test]
227    fn same_interval_passthrough() {
228        let ts = make_ts(&[0, 1, 2, 3]);
229        let vals = vec![1.0, 2.0, 3.0, 4.0];
230        let config = ResampleConfig {
231            target_interval_ns: 1_000_000_000,
232            aggregation: AggregationFn::Mean,
233            interpolation: Interpolator::Linear,
234        };
235        let (out_ts, out_vals) = Resampler::resample(&ts, &vals, &config);
236        assert_eq!(out_ts, ts);
237        assert_eq!(out_vals, vals);
238    }
239
240    #[test]
241    fn sparse_downscale_skips_empty_buckets() {
242        // Two data points separated by a huge gap (1 000 000 seconds apart)
243        // with 1s downscale interval. Without skip-ahead, this would iterate
244        // ~1 000 000 empty buckets. With the fix, it completes in O(n).
245        let ts = make_ts(&[0, 1_000_000]);
246        let vals = vec![10.0, 20.0];
247        let config = ResampleConfig {
248            target_interval_ns: 1_000_000_000, // 1s buckets
249            aggregation: AggregationFn::Mean,
250            interpolation: Interpolator::Linear,
251        };
252        let start = std::time::Instant::now();
253        let (out_ts, out_vals) = Resampler::resample(&ts, &vals, &config);
254        let elapsed = start.elapsed();
255
256        // Should produce exactly 2 output buckets
257        assert_eq!(out_ts.len(), 2);
258        assert!((out_vals[0] - 10.0).abs() < 1e-10);
259        assert!((out_vals[1] - 20.0).abs() < 1e-10);
260
261        // Must complete in well under 1 second (linear scan would take ~1M iterations)
262        assert!(
263            elapsed.as_millis() < 100,
264            "Sparse downscale took {}ms — skip-ahead not working",
265            elapsed.as_millis()
266        );
267    }
268
269    #[test]
270    fn sparse_downscale_correctness() {
271        // Three clusters of data with gaps: [0,1,2], [100,101], [500]
272        let ts = make_ts(&[0, 1, 2, 100, 101, 500]);
273        let vals = vec![1.0, 2.0, 3.0, 10.0, 11.0, 50.0];
274        let config = ResampleConfig {
275            target_interval_ns: 3_000_000_000, // 3s buckets
276            aggregation: AggregationFn::Mean,
277            interpolation: Interpolator::Linear,
278        };
279        let (out_ts, out_vals) = Resampler::resample(&ts, &vals, &config);
280
281        // Bucket [0,3): {1,2,3} → mean=2.0
282        // Bucket [99,102): {10,11} → mean=10.5
283        // Bucket [498,501): {50} → mean=50.0
284        assert_eq!(out_ts.len(), 3);
285        assert!((out_vals[0] - 2.0).abs() < 1e-10);
286        assert!((out_vals[1] - 10.5).abs() < 1e-10);
287        assert!((out_vals[2] - 50.0).abs() < 1e-10);
288    }
289}