diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9bd48fa..30d3f7a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,9 @@ # Frequenz Resampling Release Notes +## Bug Fixes + +- Fixed `first_timestamp` parameter to only affect output timestamp labeling, not interval grouping. Previously, setting `first_timestamp=false` would shift interval boundaries, causing the first sample at `t=0` to be excluded. Now intervals are consistently `[start, end)` regardless of `first_timestamp` value. + ## New Features - `ResamplingFunction` now implements `Clone`. diff --git a/frequenz/resampling/_rust_backend.pyi b/frequenz/resampling/_rust_backend.pyi index a7e0e3f..04e3f1e 100644 --- a/frequenz/resampling/_rust_backend.pyi +++ b/frequenz/resampling/_rust_backend.pyi @@ -75,9 +75,10 @@ class Resampler: resampling_function: The resampling function. max_age_in_intervals: The maximum age of a sample in intervals. start: The start time of the resampling. - first_timestamp: Whether the resampled timestamp should be the first - timestamp in the buffer or the last timestamp in the buffer. - Defaults to `True`. + first_timestamp: Controls the output timestamp labeling. If `True`, + the output timestamp is set to the start of the interval. If `False`, + the output timestamp is set to the end of the interval. This does not + affect how samples are grouped into intervals. Defaults to `True`. """ def push_sample(self, *, timestamp: datetime, value: Optional[float]) -> None: diff --git a/src/lib.rs b/src/lib.rs index f09f16c..b934a60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,17 +47,20 @@ let mut resampler: Resampler = Resampler::new(TimeDelta::seconds(5), ResamplingFunction::Average, 1, start, false); let step = TimeDelta::seconds(1); +// Data starts at t=0 with values 1-10 +// Interval [0, 5): t=0,1,2,3,4 with values 1,2,3,4,5 → avg = 3.0 +// Interval [5, 10): t=5,6,7,8,9 with values 6,7,8,9,10 → avg = 8.0 let data = vec![ - TestSample::new(start + step, Some(1.0)), - TestSample::new(start + step * 2, Some(2.0)), - TestSample::new(start + step * 3, Some(3.0)), - TestSample::new(start + step * 4, Some(4.0)), - TestSample::new(start + step * 5, Some(5.0)), - TestSample::new(start + step * 6, Some(6.0)), - TestSample::new(start + step * 7, Some(7.0)), - TestSample::new(start + step * 8, Some(8.0)), - TestSample::new(start + step * 9, Some(9.0)), - TestSample::new(start + step * 10, Some(10.0)), + TestSample::new(start, Some(1.0)), + TestSample::new(start + step, Some(2.0)), + TestSample::new(start + step * 2, Some(3.0)), + TestSample::new(start + step * 3, Some(4.0)), + TestSample::new(start + step * 4, Some(5.0)), + TestSample::new(start + step * 5, Some(6.0)), + TestSample::new(start + step * 6, Some(7.0)), + TestSample::new(start + step * 7, Some(8.0)), + TestSample::new(start + step * 8, Some(9.0)), + TestSample::new(start + step * 9, Some(10.0)), ]; resampler.extend(data); diff --git a/src/resampler.rs b/src/resampler.rs index 2b16985..af36d07 100644 --- a/src/resampler.rs +++ b/src/resampler.rs @@ -104,15 +104,20 @@ pub struct Resampler< input_start: Option>, /// The interval between the first and the second sample in the buffer input_interval: Option, - /// Whether the resampled timestamp should be the first timestamp (if - /// `first_timestamp` is `true`) or the last timestamp (if - /// `first_timestamp` is `false`) in the buffer. - /// If `first_timestamp` is `true`, the resampled timestamp will be the - /// timestamp of the first sample in the buffer and the aggregation will - /// be done with the samples that are `interval` in the future. - /// If `first_timestamp` is `false`, the resampled timestamp will be the - /// timestamp of the last sample in the buffer and the aggregation will - /// be done with the samples that are `interval` in the past. + /// Controls the output timestamp labeling for resampled samples. + /// + /// This parameter only affects how output timestamps are labeled, not how + /// samples are grouped into intervals. Intervals are always `[start, end)`. + /// + /// - If `first_timestamp` is `true`, the output timestamp is set to the + /// start of the interval. + /// - If `first_timestamp` is `false`, the output timestamp is set to the + /// end of the interval. + /// + /// For example, with an interval of 5 seconds starting at t=0: + /// - Interval `[0, 5)` contains samples with timestamps 0, 1, 2, 3, 4 + /// - If `first_timestamp=true`: output timestamp = 0 + /// - If `first_timestamp=false`: output timestamp = 5 first_timestamp: bool, } @@ -173,13 +178,7 @@ impl< while self.start < end { // loop over the samples in the buffer while next_sample - .map(|s| { - is_left_of_buffer_edge( - self.first_timestamp, - &s.timestamp(), - &(self.start + self.interval), - ) - }) + .map(|s| is_left_of_buffer_edge(&s.timestamp(), &(self.start + self.interval))) .unwrap_or(false) { // next sample is not newer than the current interval @@ -204,9 +203,7 @@ impl< let input_interval = self.input_interval.unwrap_or(self.interval); let drain_end_date = self.start + self.interval - input_interval * self.max_age_in_intervals; - interval_buffer.retain(|s| { - is_right_of_buffer_edge(self.first_timestamp, &s.timestamp(), &drain_end_date) - }); + interval_buffer.retain(|s| is_right_of_buffer_edge(&s.timestamp(), &drain_end_date)); // resample the interval_buffer res.push(Sample::new( @@ -221,9 +218,8 @@ impl< // Remove samples from buffer that are older than max_age let interval = self.input_interval.unwrap_or(self.interval); let drain_end_date = end - interval * self.max_age_in_intervals; - self.buffer.retain(|s| { - is_right_of_buffer_edge(self.first_timestamp, &s.timestamp(), &drain_end_date) - }); + self.buffer + .retain(|s| is_right_of_buffer_edge(&s.timestamp(), &drain_end_date)); res } @@ -259,26 +255,16 @@ pub(crate) fn epoch_align( .unwrap_or(timestamp) } -fn is_left_of_buffer_edge( - first_timestamp: bool, - timestamp: &DateTime, - edge_timestamp: &DateTime, -) -> bool { - if first_timestamp { - timestamp < edge_timestamp - } else { - timestamp <= edge_timestamp - } +/// Checks if a timestamp is within the interval for aggregation. +/// Uses exclusive upper bound: sample is included if timestamp < edge. +/// This creates intervals of the form [start, end). +fn is_left_of_buffer_edge(timestamp: &DateTime, edge_timestamp: &DateTime) -> bool { + timestamp < edge_timestamp } -fn is_right_of_buffer_edge( - first_timestamp: bool, - timestamp: &DateTime, - edge_timestamp: &DateTime, -) -> bool { - if first_timestamp { - timestamp >= edge_timestamp - } else { - timestamp > edge_timestamp - } +/// Checks if a timestamp should be retained in the buffer. +/// Uses inclusive lower bound: sample is retained if timestamp >= edge. +/// This creates intervals of the form [start, end). +fn is_right_of_buffer_edge(timestamp: &DateTime, edge_timestamp: &DateTime) -> bool { + timestamp >= edge_timestamp } diff --git a/src/tests.rs b/src/tests.rs index 13ae6c7..302680e 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -44,17 +44,20 @@ fn test_resampling( let mut resampler: Resampler = Resampler::new(TimeDelta::seconds(5), resampling_function, 1, start, false); let step = TimeDelta::seconds(1); + // Data starts at t=0, matching the README example + // Interval [0, 5) contains t=0,1,2,3,4 with values 1,2,3,4,5 + // Interval [5, 10) contains t=5,6,7,8,9 with values 6,7,8,9,10 let data = vec![ - TestSample::new(start + step, Some(1.0)), - TestSample::new(start + step * 2, Some(2.0)), - TestSample::new(start + step * 3, Some(3.0)), - TestSample::new(start + step * 4, Some(4.0)), - TestSample::new(start + step * 5, Some(5.0)), - TestSample::new(start + step * 6, Some(6.0)), - TestSample::new(start + step * 7, Some(7.0)), - TestSample::new(start + step * 8, Some(8.0)), - TestSample::new(start + step * 9, Some(9.0)), - TestSample::new(start + step * 10, Some(10.0)), + TestSample::new(start, Some(1.0)), + TestSample::new(start + step, Some(2.0)), + TestSample::new(start + step * 2, Some(3.0)), + TestSample::new(start + step * 3, Some(4.0)), + TestSample::new(start + step * 4, Some(5.0)), + TestSample::new(start + step * 5, Some(6.0)), + TestSample::new(start + step * 6, Some(7.0)), + TestSample::new(start + step * 7, Some(8.0)), + TestSample::new(start + step * 8, Some(9.0)), + TestSample::new(start + step * 9, Some(10.0)), ]; resampler.extend(data); @@ -71,17 +74,20 @@ fn test_resampling_with_none_first( let mut resampler: Resampler = Resampler::new(TimeDelta::seconds(5), resampling_function, 1, start, false); let step = TimeDelta::seconds(1); + // First sample at t=0 is None + // Interval [0, 5) contains t=0,1,2,3,4 with values None,2,3,4,5 + // Interval [5, 10) contains t=5,6,7,8,9 with values None,7,8,9,10 let data = vec![ - TestSample::new(start + step, None), - TestSample::new(start + step * 2, Some(2.0)), - TestSample::new(start + step * 3, Some(3.0)), - TestSample::new(start + step * 4, Some(4.0)), - TestSample::new(start + step * 5, Some(5.0)), - TestSample::new(start + step * 6, None), - TestSample::new(start + step * 7, Some(7.0)), - TestSample::new(start + step * 8, Some(8.0)), - TestSample::new(start + step * 9, Some(9.0)), - TestSample::new(start + step * 10, Some(10.0)), + TestSample::new(start, None), + TestSample::new(start + step, Some(2.0)), + TestSample::new(start + step * 2, Some(3.0)), + TestSample::new(start + step * 3, Some(4.0)), + TestSample::new(start + step * 4, Some(5.0)), + TestSample::new(start + step * 5, None), + TestSample::new(start + step * 6, Some(7.0)), + TestSample::new(start + step * 7, Some(8.0)), + TestSample::new(start + step * 8, Some(9.0)), + TestSample::new(start + step * 9, Some(10.0)), ]; resampler.extend(data); @@ -98,7 +104,9 @@ fn test_resampling_with_none_all( let mut resampler: Resampler = Resampler::new(TimeDelta::seconds(5), resampling_function, 1, start, false); let step = TimeDelta::seconds(1); + // All values are None let data = vec![ + TestSample::new(start, None), TestSample::new(start + step, None), TestSample::new(start + step * 2, None), TestSample::new(start + step * 3, None), @@ -108,7 +116,6 @@ fn test_resampling_with_none_all( TestSample::new(start + step * 7, None), TestSample::new(start + step * 8, None), TestSample::new(start + step * 9, None), - TestSample::new(start + step * 10, None), ]; resampler.extend(data); @@ -377,22 +384,23 @@ fn test_resampling_with_max_age() { false, ); let step = TimeDelta::seconds(1); + // Data starts at t=0 with values 1-15 let data = vec![ - TestSample::new(start + step, Some(1.0)), - TestSample::new(start + step * 2, Some(2.0)), - TestSample::new(start + step * 3, Some(3.0)), - TestSample::new(start + step * 4, Some(4.0)), - TestSample::new(start + step * 5, Some(5.0)), - TestSample::new(start + step * 6, Some(6.0)), - TestSample::new(start + step * 7, Some(7.0)), - TestSample::new(start + step * 8, Some(8.0)), - TestSample::new(start + step * 9, Some(9.0)), - TestSample::new(start + step * 10, Some(10.0)), - TestSample::new(start + step * 11, Some(11.0)), - TestSample::new(start + step * 12, Some(12.0)), - TestSample::new(start + step * 13, Some(13.0)), - TestSample::new(start + step * 14, Some(14.0)), - TestSample::new(start + step * 15, Some(15.0)), + TestSample::new(start, Some(1.0)), + TestSample::new(start + step, Some(2.0)), + TestSample::new(start + step * 2, Some(3.0)), + TestSample::new(start + step * 3, Some(4.0)), + TestSample::new(start + step * 4, Some(5.0)), + TestSample::new(start + step * 5, Some(6.0)), + TestSample::new(start + step * 6, Some(7.0)), + TestSample::new(start + step * 7, Some(8.0)), + TestSample::new(start + step * 8, Some(9.0)), + TestSample::new(start + step * 9, Some(10.0)), + TestSample::new(start + step * 10, Some(11.0)), + TestSample::new(start + step * 11, Some(12.0)), + TestSample::new(start + step * 12, Some(13.0)), + TestSample::new(start + step * 13, Some(14.0)), + TestSample::new(start + step * 14, Some(15.0)), ]; resampler.extend(data); @@ -459,22 +467,23 @@ fn test_resampling_with_max_age_older() { false, ); let step = TimeDelta::seconds(1); + // Data starts at t=0 with values 1-15 let data = vec![ - TestSample::new(start + step, Some(1.0)), - TestSample::new(start + step * 2, Some(2.0)), - TestSample::new(start + step * 3, Some(3.0)), - TestSample::new(start + step * 4, Some(4.0)), - TestSample::new(start + step * 5, Some(5.0)), - TestSample::new(start + step * 6, Some(6.0)), - TestSample::new(start + step * 7, Some(7.0)), - TestSample::new(start + step * 8, Some(8.0)), - TestSample::new(start + step * 9, Some(9.0)), - TestSample::new(start + step * 10, Some(10.0)), - TestSample::new(start + step * 11, Some(11.0)), - TestSample::new(start + step * 12, Some(12.0)), - TestSample::new(start + step * 13, Some(13.0)), - TestSample::new(start + step * 14, Some(14.0)), - TestSample::new(start + step * 15, Some(15.0)), + TestSample::new(start, Some(1.0)), + TestSample::new(start + step, Some(2.0)), + TestSample::new(start + step * 2, Some(3.0)), + TestSample::new(start + step * 3, Some(4.0)), + TestSample::new(start + step * 4, Some(5.0)), + TestSample::new(start + step * 5, Some(6.0)), + TestSample::new(start + step * 6, Some(7.0)), + TestSample::new(start + step * 7, Some(8.0)), + TestSample::new(start + step * 8, Some(9.0)), + TestSample::new(start + step * 9, Some(10.0)), + TestSample::new(start + step * 10, Some(11.0)), + TestSample::new(start + step * 11, Some(12.0)), + TestSample::new(start + step * 12, Some(13.0)), + TestSample::new(start + step * 13, Some(14.0)), + TestSample::new(start + step * 14, Some(15.0)), ]; resampler.extend(data); @@ -500,24 +509,25 @@ fn test_resampling_with_max_age_batch() { false, ); let step = TimeDelta::seconds(1); + // Data starts at t=0 with values 1-10 let data1 = vec![ - TestSample::new(start + step * 1, Some(1.0)), - TestSample::new(start + step * 2, Some(2.0)), - TestSample::new(start + step * 3, Some(3.0)), - TestSample::new(start + step * 4, Some(4.0)), - TestSample::new(start + step * 5, Some(5.0)), - TestSample::new(start + step * 6, Some(6.0)), - TestSample::new(start + step * 7, Some(7.0)), - TestSample::new(start + step * 8, Some(8.0)), - TestSample::new(start + step * 9, Some(9.0)), - TestSample::new(start + step * 10, Some(10.0)), + TestSample::new(start, Some(1.0)), + TestSample::new(start + step, Some(2.0)), + TestSample::new(start + step * 2, Some(3.0)), + TestSample::new(start + step * 3, Some(4.0)), + TestSample::new(start + step * 4, Some(5.0)), + TestSample::new(start + step * 5, Some(6.0)), + TestSample::new(start + step * 6, Some(7.0)), + TestSample::new(start + step * 7, Some(8.0)), + TestSample::new(start + step * 8, Some(9.0)), + TestSample::new(start + step * 9, Some(10.0)), ]; let data2 = vec![ - TestSample::new(start + step * 11, Some(11.0)), - TestSample::new(start + step * 12, Some(12.0)), - TestSample::new(start + step * 13, Some(13.0)), - TestSample::new(start + step * 14, Some(14.0)), - TestSample::new(start + step * 15, Some(15.0)), + TestSample::new(start + step * 10, Some(11.0)), + TestSample::new(start + step * 11, Some(12.0)), + TestSample::new(start + step * 12, Some(13.0)), + TestSample::new(start + step * 13, Some(14.0)), + TestSample::new(start + step * 14, Some(15.0)), ]; resampler.extend(data1); @@ -552,13 +562,18 @@ fn test_resampling_with_gap() { false, ); let step = TimeDelta::seconds(1); + // Data with gaps: samples at t=0,1,3,4, then gap, then t=16,19 + // Interval [0, 5): t=0,1,3,4 with values 1,2,4,5 → avg = 3.0 + // Interval [5, 10): none → None + // Interval [10, 15): none → None + // Interval [15, 20): t=16,19 with values 6,10 → avg = 8.0 let data = vec![ - TestSample::new(start + step, Some(1.0)), - TestSample::new(start + step * 2, Some(2.0)), - TestSample::new(start + step * 4, Some(4.0)), - TestSample::new(start + step * 5, Some(5.0)), - TestSample::new(start + step * 17, Some(6.0)), - TestSample::new(start + step * 20, Some(10.0)), + TestSample::new(start, Some(1.0)), + TestSample::new(start + step, Some(2.0)), + TestSample::new(start + step * 3, Some(4.0)), + TestSample::new(start + step * 4, Some(5.0)), + TestSample::new(start + step * 16, Some(6.0)), + TestSample::new(start + step * 19, Some(10.0)), ]; resampler.extend(data); @@ -621,13 +636,14 @@ fn test_resampling_with_gap_early_end_date() { false, ); let step = TimeDelta::seconds(1); + // Same data structure as test_resampling_with_gap, but tests batched resampling let data = vec![ - TestSample::new(start + step, Some(1.0)), - TestSample::new(start + step * 2, Some(2.0)), - TestSample::new(start + step * 4, Some(4.0)), - TestSample::new(start + step * 5, Some(5.0)), - TestSample::new(start + step * 17, Some(6.0)), - TestSample::new(start + step * 20, Some(10.0)), + TestSample::new(start, Some(1.0)), + TestSample::new(start + step, Some(2.0)), + TestSample::new(start + step * 3, Some(4.0)), + TestSample::new(start + step * 4, Some(5.0)), + TestSample::new(start + step * 16, Some(6.0)), + TestSample::new(start + step * 19, Some(10.0)), ]; resampler.extend(data); @@ -689,7 +705,7 @@ fn test_epoch_alignment() { } #[test] -fn test_is_right_of_buffer_edge() { +fn test_first_timestamp_true() { let start = DateTime::from_timestamp(0, 0).unwrap(); let mut resampler: Resampler = Resampler::new( TimeDelta::seconds(5), @@ -715,6 +731,9 @@ fn test_is_right_of_buffer_edge() { resampler.extend(data); let resampled = resampler.resample(start + step * 10); + // Intervals: [0, 5) with samples t=0,1,2,3,4 → avg(1,2,3,4,5) = 3.0 + // [5, 10) with samples t=5,6,7,8,9 → avg(6,7,8,9,10) = 8.0 + // Output timestamp is at interval start (first_timestamp=true) assert_eq!( resampled, vec![ @@ -724,6 +743,171 @@ fn test_is_right_of_buffer_edge() { ); } +/// Test that first_timestamp only affects output timestamps, not aggregated values. +/// Both resamplers should produce the same values, just with different timestamps. +#[test] +fn test_first_timestamp_same_values() { + let start = DateTime::from_timestamp(0, 0).unwrap(); + let step = TimeDelta::seconds(1); + let data: Vec = (0..10) + .map(|i| TestSample::new(start + step * i, Some((i + 1) as f64))) + .collect(); + + let mut resampler_true: Resampler = Resampler::new( + TimeDelta::seconds(5), + ResamplingFunction::Average, + 1, + start, + true, + ); + let mut resampler_false: Resampler = Resampler::new( + TimeDelta::seconds(5), + ResamplingFunction::Average, + 1, + start, + false, + ); + + resampler_true.extend(data.clone()); + resampler_false.extend(data); + + let result_true = resampler_true.resample(start + step * 10); + let result_false = resampler_false.resample(start + step * 10); + + // Both should have same number of results + assert_eq!(result_true.len(), result_false.len()); + + // Values should be identical, only timestamps differ + for (r_true, r_false) in result_true.iter().zip(result_false.iter()) { + assert_eq!(r_true.value(), r_false.value()); + // Timestamps differ by exactly one interval (5 seconds) + assert_eq!( + r_false.timestamp() - r_true.timestamp(), + TimeDelta::seconds(5) + ); + } +} + +/// Test that a sample exactly at interval boundary goes to the next interval. +/// With [start, end) semantics, sample at t=5 should be in interval [5, 10), not [0, 5). +#[test] +fn test_sample_at_interval_boundary() { + let start = DateTime::from_timestamp(0, 0).unwrap(); + let step = TimeDelta::seconds(1); + + let mut resampler: Resampler = Resampler::new( + TimeDelta::seconds(5), + ResamplingFunction::Sum, + 1, + start, + false, + ); + + // Only add samples at boundaries: t=0, t=5 + let data = vec![ + TestSample::new(start, Some(10.0)), // t=0, in [0, 5) + TestSample::new(start + step * 5, Some(20.0)), // t=5, in [5, 10) + ]; + + resampler.extend(data); + + let resampled = resampler.resample(start + step * 10); + + // Interval [0, 5): only t=0 → sum = 10.0 + // Interval [5, 10): only t=5 → sum = 20.0 + assert_eq!( + resampled, + vec![ + TestSample::new(DateTime::from_timestamp(5, 0).unwrap(), Some(10.0)), + TestSample::new(DateTime::from_timestamp(10, 0).unwrap(), Some(20.0)), + ], + ); +} + +/// Test with data starting mid-interval. +/// Verifies correct behavior when first sample doesn't align with interval start. +#[test] +fn test_data_starting_mid_interval() { + let start = DateTime::from_timestamp(0, 0).unwrap(); + let step = TimeDelta::seconds(1); + + let mut resampler: Resampler = Resampler::new( + TimeDelta::seconds(5), + ResamplingFunction::Average, + 1, + start, + false, + ); + + // Data starts at t=2, not t=0 + // Interval [0, 5): samples at t=2,3,4 with values 1,2,3 → avg = 2.0 + // Interval [5, 10): samples at t=5,6,7 with values 4,5,6 → avg = 5.0 + let data = vec![ + TestSample::new(start + step * 2, Some(1.0)), + TestSample::new(start + step * 3, Some(2.0)), + TestSample::new(start + step * 4, Some(3.0)), + TestSample::new(start + step * 5, Some(4.0)), + TestSample::new(start + step * 6, Some(5.0)), + TestSample::new(start + step * 7, Some(6.0)), + ]; + + resampler.extend(data); + + let resampled = resampler.resample(start + step * 10); + + assert_eq!( + resampled, + vec![ + TestSample::new(DateTime::from_timestamp(5, 0).unwrap(), Some(2.0)), + TestSample::new(DateTime::from_timestamp(10, 0).unwrap(), Some(5.0)), + ], + ); +} + +/// Test that matches the README example exactly. +/// This test verifies that first_timestamp only affects the output timestamp, +/// not the interval grouping semantics. +#[test] +fn test_first_timestamp_false() { + let start = DateTime::from_timestamp(0, 0).unwrap(); + let mut resampler: Resampler = Resampler::new( + TimeDelta::seconds(5), + ResamplingFunction::Average, + 1, + start, + false, + ); + let step = TimeDelta::seconds(1); + let data = vec![ + TestSample::new(start, Some(1.0)), + TestSample::new(start + step * 1, Some(2.0)), + TestSample::new(start + step * 2, Some(3.0)), + TestSample::new(start + step * 3, Some(4.0)), + TestSample::new(start + step * 4, Some(5.0)), + TestSample::new(start + step * 5, Some(6.0)), + TestSample::new(start + step * 6, Some(7.0)), + TestSample::new(start + step * 7, Some(8.0)), + TestSample::new(start + step * 8, Some(9.0)), + TestSample::new(start + step * 9, Some(10.0)), + ]; + + resampler.extend(data); + + let resampled = resampler.resample(start + step * 10); + // Intervals should be the same as first_timestamp=true: [0, 5) and [5, 10) + // Only the output timestamp should differ (end of interval instead of start) + // Interval [0, 5) with samples t=0,1,2,3,4 → avg(1,2,3,4,5) = 3.0 + // Interval [5, 10) with samples t=5,6,7,8,9 → avg(6,7,8,9,10) = 8.0 + // Output timestamp is at interval end (first_timestamp=false) + assert_eq!( + resampled, + vec![ + TestSample::new(DateTime::from_timestamp(5, 0).unwrap(), Some(3.0)), + TestSample::new(DateTime::from_timestamp(10, 0).unwrap(), Some(8.0)), + ], + ); +} + #[derive(Debug, PartialEq, Eq, Clone, Default)] struct NonPrimitive { value: Vec, @@ -818,17 +1002,18 @@ fn test_resampling_non_primitive_average() { false, ); let step = TimeDelta::seconds(1); + // Data starts at t=0 let data = vec![ - NonPrimitiveSample::new(start + step, Some(NonPrimitive { value: vec![1] })), - NonPrimitiveSample::new(start + step * 2, Some(NonPrimitive { value: vec![2] })), - NonPrimitiveSample::new(start + step * 3, Some(NonPrimitive { value: vec![3] })), - NonPrimitiveSample::new(start + step * 4, Some(NonPrimitive { value: vec![4] })), - NonPrimitiveSample::new(start + step * 5, Some(NonPrimitive { value: vec![5] })), - NonPrimitiveSample::new(start + step * 6, Some(NonPrimitive { value: vec![6] })), - NonPrimitiveSample::new(start + step * 7, Some(NonPrimitive { value: vec![7] })), - NonPrimitiveSample::new(start + step * 8, Some(NonPrimitive { value: vec![8] })), - NonPrimitiveSample::new(start + step * 9, Some(NonPrimitive { value: vec![9] })), - NonPrimitiveSample::new(start + step * 10, Some(NonPrimitive { value: vec![10] })), + NonPrimitiveSample::new(start, Some(NonPrimitive { value: vec![1] })), + NonPrimitiveSample::new(start + step, Some(NonPrimitive { value: vec![2] })), + NonPrimitiveSample::new(start + step * 2, Some(NonPrimitive { value: vec![3] })), + NonPrimitiveSample::new(start + step * 3, Some(NonPrimitive { value: vec![4] })), + NonPrimitiveSample::new(start + step * 4, Some(NonPrimitive { value: vec![5] })), + NonPrimitiveSample::new(start + step * 5, Some(NonPrimitive { value: vec![6] })), + NonPrimitiveSample::new(start + step * 6, Some(NonPrimitive { value: vec![7] })), + NonPrimitiveSample::new(start + step * 7, Some(NonPrimitive { value: vec![8] })), + NonPrimitiveSample::new(start + step * 8, Some(NonPrimitive { value: vec![9] })), + NonPrimitiveSample::new(start + step * 9, Some(NonPrimitive { value: vec![10] })), ]; resampler.extend(data); @@ -859,17 +1044,20 @@ fn test_resampling_non_primitive_sum() { false, ); let step = TimeDelta::seconds(1); + // Data starts at t=0 + // Interval [0, 5): t=0,1,2,3,4 with values [1],[2],[3],[4],[5] + // Interval [5, 10): t=5,6,7,8,9 with values [6],[7],[8],[9],[10] let data = vec![ - NonPrimitiveSample::new(start + step, Some(NonPrimitive { value: vec![1] })), - NonPrimitiveSample::new(start + step * 2, Some(NonPrimitive { value: vec![2] })), - NonPrimitiveSample::new(start + step * 3, Some(NonPrimitive { value: vec![3] })), - NonPrimitiveSample::new(start + step * 4, Some(NonPrimitive { value: vec![4] })), - NonPrimitiveSample::new(start + step * 5, Some(NonPrimitive { value: vec![5] })), - NonPrimitiveSample::new(start + step * 6, Some(NonPrimitive { value: vec![6] })), - NonPrimitiveSample::new(start + step * 7, Some(NonPrimitive { value: vec![7] })), - NonPrimitiveSample::new(start + step * 8, Some(NonPrimitive { value: vec![8] })), - NonPrimitiveSample::new(start + step * 9, Some(NonPrimitive { value: vec![9] })), - NonPrimitiveSample::new(start + step * 10, Some(NonPrimitive { value: vec![10] })), + NonPrimitiveSample::new(start, Some(NonPrimitive { value: vec![1] })), + NonPrimitiveSample::new(start + step, Some(NonPrimitive { value: vec![2] })), + NonPrimitiveSample::new(start + step * 2, Some(NonPrimitive { value: vec![3] })), + NonPrimitiveSample::new(start + step * 3, Some(NonPrimitive { value: vec![4] })), + NonPrimitiveSample::new(start + step * 4, Some(NonPrimitive { value: vec![5] })), + NonPrimitiveSample::new(start + step * 5, Some(NonPrimitive { value: vec![6] })), + NonPrimitiveSample::new(start + step * 6, Some(NonPrimitive { value: vec![7] })), + NonPrimitiveSample::new(start + step * 7, Some(NonPrimitive { value: vec![8] })), + NonPrimitiveSample::new(start + step * 8, Some(NonPrimitive { value: vec![9] })), + NonPrimitiveSample::new(start + step * 9, Some(NonPrimitive { value: vec![10] })), ]; resampler.extend(data); diff --git a/tests/test_resampler.py b/tests/test_resampler.py index 38a0cca..181a39d 100644 --- a/tests/test_resampler.py +++ b/tests/test_resampler.py @@ -20,8 +20,11 @@ def test_resampler_resampling_function_average() -> None: first_timestamp=False, ) - for i in range(1, 11): - resampler.push_sample(timestamp=start + i * step, value=i) + # Data starts at t=0 with values 1-10 + # Interval [0, 5): t=0,1,2,3,4 with values 1,2,3,4,5 → avg = 3.0 + # Interval [5, 10): t=5,6,7,8,9 with values 6,7,8,9,10 → avg = 8.0 + for i in range(10): + resampler.push_sample(timestamp=start + i * step, value=i + 1) expected = [ (start + 5 * step, 3.0), @@ -45,8 +48,11 @@ def test_resampler_resampling_function_sum() -> None: first_timestamp=False, ) - for i in range(1, 11): - resampler.push_sample(timestamp=start + i * step, value=i) + # Data starts at t=0 with values 1-10 + # Interval [0, 5): t=0,1,2,3,4 with values 1,2,3,4,5 → sum = 15.0 + # Interval [5, 10): t=5,6,7,8,9 with values 6,7,8,9,10 → sum = 40.0 + for i in range(10): + resampler.push_sample(timestamp=start + i * step, value=i + 1) expected = [ (start + 5 * step, 15.0), @@ -70,8 +76,11 @@ def test_resampler_resampling_function_max() -> None: first_timestamp=False, ) - for i in range(1, 11): - resampler.push_sample(timestamp=start + i * step, value=i) + # Data starts at t=0 with values 1-10 + # Interval [0, 5): t=0,1,2,3,4 with values 1,2,3,4,5 → max = 5.0 + # Interval [5, 10): t=5,6,7,8,9 with values 6,7,8,9,10 → max = 10.0 + for i in range(10): + resampler.push_sample(timestamp=start + i * step, value=i + 1) expected = [ (start + 5 * step, 5.0), @@ -95,8 +104,11 @@ def test_resampler_resampling_function_min() -> None: first_timestamp=False, ) - for i in range(1, 11): - resampler.push_sample(timestamp=start + i * step, value=i) + # Data starts at t=0 with values 1-10 + # Interval [0, 5): t=0,1,2,3,4 with values 1,2,3,4,5 → min = 1.0 + # Interval [5, 10): t=5,6,7,8,9 with values 6,7,8,9,10 → min = 6.0 + for i in range(10): + resampler.push_sample(timestamp=start + i * step, value=i + 1) expected = [ (start + 5 * step, 1.0), @@ -120,8 +132,11 @@ def test_resampler_resampling_function_first() -> None: first_timestamp=False, ) - for i in range(1, 11): - resampler.push_sample(timestamp=start + i * step, value=i) + # Data starts at t=0 with values 1-10 + # Interval [0, 5): t=0,1,2,3,4 with values 1,2,3,4,5 → first = 1.0 + # Interval [5, 10): t=5,6,7,8,9 with values 6,7,8,9,10 → first = 6.0 + for i in range(10): + resampler.push_sample(timestamp=start + i * step, value=i + 1) expected = [ (start + 5 * step, 1.0), @@ -145,8 +160,11 @@ def test_resampler_resampling_function_last() -> None: first_timestamp=False, ) - for i in range(1, 11): - resampler.push_sample(timestamp=start + i * step, value=i) + # Data starts at t=0 with values 1-10 + # Interval [0, 5): t=0,1,2,3,4 with values 1,2,3,4,5 → last = 5.0 + # Interval [5, 10): t=5,6,7,8,9 with values 6,7,8,9,10 → last = 10.0 + for i in range(10): + resampler.push_sample(timestamp=start + i * step, value=i + 1) expected = [ (start + 5 * step, 5.0), @@ -170,11 +188,14 @@ def test_resampler_resampling_function_coalesce() -> None: first_timestamp=False, ) - for i in range(1, 11): - if i == 6: + # Data starts at t=0 with values 1-10, but t=5 is None + # Interval [0, 5): t=0,1,2,3,4 with values 1,2,3,4,5 → coalesce = 1.0 + # Interval [5, 10): t=5,6,7,8,9 with values None,7,8,9,10 → coalesce = 7.0 + for i in range(10): + if i == 5: resampler.push_sample(timestamp=start + i * step, value=None) else: - resampler.push_sample(timestamp=start + i * step, value=i) + resampler.push_sample(timestamp=start + i * step, value=i + 1) expected = [ (start + 5 * step, 1.0), @@ -198,8 +219,11 @@ def test_resampler_resampling_function_count() -> None: first_timestamp=False, ) - for i in range(1, 11): - resampler.push_sample(timestamp=start + i * step, value=i) + # Data starts at t=0 with values 1-10 + # Interval [0, 5): t=0,1,2,3,4 → count = 5.0 + # Interval [5, 10): t=5,6,7,8,9 → count = 5.0 + for i in range(10): + resampler.push_sample(timestamp=start + i * step, value=i + 1) expected = [ (start + 5 * step, 5.0), @@ -223,7 +247,8 @@ def test_resampling_none() -> None: first_timestamp=False, ) - for i in range(1, 11): + # All values are None + for i in range(10): resampler.push_sample(timestamp=start + i * step, value=None) expected = [ @@ -333,7 +358,7 @@ def test_resampler_first_timestamp() -> None: def test_resampler_last_timestamp() -> None: - """Test the resampler with the last timestamp.""" + """Test the resampler with the last timestamp (first_timestamp=False).""" start = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) step = dt.timedelta(seconds=0.5) resampler = Resampler( @@ -344,8 +369,11 @@ def test_resampler_last_timestamp() -> None: first_timestamp=False, ) - for i in range(1, 21): - resampler.push_sample(timestamp=start + i * step, value=i) + # Data starts at t=0, step=0.5s, 20 samples + # Interval [0, 5): t=0,0.5,1,1.5,2,2.5,3,3.5,4,4.5 → values 1-10 → avg = 5.5 + # Interval [5, 10): t=5,5.5,6,6.5,7,7.5,8,8.5,9,9.5 → values 11-20 → avg = 15.5 + for i in range(20): + resampler.push_sample(timestamp=start + i * step, value=i + 1) expected = [ (start + 10 * step, 5.5),