1616 * under the License.
1717 */
1818
19- use crate :: time_series:: TimeSeries ;
19+ use crate :: time_series:: { TimePoint , TimeSeries } ;
2020use serde:: Serializer ;
2121
2222pub ( crate ) fn round_float < S > ( value : & f64 , serializer : S ) -> Result < S :: Ok , S :: Error >
@@ -48,6 +48,66 @@ pub fn max(series: &TimeSeries) -> Option<f64> {
4848 . max_by ( |a, b| a. partial_cmp ( b) . unwrap_or ( std:: cmp:: Ordering :: Equal ) )
4949}
5050
51+ /// LTTB (Largest-Triangle-Three-Buckets) downsampling.
52+ ///
53+ /// Reduces `points` to at most `threshold` points while preserving visual shape
54+ /// (peaks, valleys, trends). Returns a clone if `points.len() <= threshold`.
55+ pub fn lttb_downsample ( points : & [ TimePoint ] , threshold : usize ) -> Vec < TimePoint > {
56+ let len = points. len ( ) ;
57+ if len <= threshold || threshold < 3 {
58+ return points. to_vec ( ) ;
59+ }
60+
61+ let bucket_count = threshold - 2 ;
62+ let bucket_size = ( len - 2 ) as f64 / bucket_count as f64 ;
63+
64+ let mut result = Vec :: with_capacity ( threshold) ;
65+ result. push ( points[ 0 ] . clone ( ) ) ;
66+
67+ let mut prev_selected = 0usize ;
68+
69+ for bucket_idx in 0 ..bucket_count {
70+ // Compute average of the *next* bucket (used as the third triangle vertex)
71+ let next_start = ( ( bucket_idx + 1 ) as f64 * bucket_size) as usize + 1 ;
72+ let next_end = ( ( ( bucket_idx + 2 ) as f64 * bucket_size) as usize + 1 ) . min ( len - 1 ) ;
73+
74+ let mut avg_time = 0.0 ;
75+ let mut avg_value = 0.0 ;
76+ let next_count = ( next_end - next_start + 1 ) as f64 ;
77+ for p in & points[ next_start..=next_end] {
78+ avg_time += p. time_s ;
79+ avg_value += p. value ;
80+ }
81+ avg_time /= next_count;
82+ avg_value /= next_count;
83+
84+ // Current bucket range
85+ let cur_start = ( bucket_idx as f64 * bucket_size) as usize + 1 ;
86+ let cur_end = next_start;
87+
88+ // Pick the point in this bucket that forms the largest triangle with
89+ // the previously selected point and the next-bucket average.
90+ let prev = & points[ prev_selected] ;
91+ let mut max_area = -1.0 ;
92+ let mut best = cur_start;
93+ for ( i, p) in points[ cur_start..cur_end] . iter ( ) . enumerate ( ) {
94+ let area = ( ( prev. time_s - avg_time) * ( p. value - prev. value )
95+ - ( prev. time_s - p. time_s ) * ( avg_value - prev. value ) )
96+ . abs ( ) ;
97+ if area > max_area {
98+ max_area = area;
99+ best = cur_start + i;
100+ }
101+ }
102+
103+ result. push ( points[ best] . clone ( ) ) ;
104+ prev_selected = best;
105+ }
106+
107+ result. push ( points[ len - 1 ] . clone ( ) ) ;
108+ result
109+ }
110+
51111/// Calculate the standard deviation of values from a TimeSeries
52112///
53113/// Returns None if the TimeSeries has fewer than 2 points
@@ -73,3 +133,81 @@ pub fn std_dev(series: &TimeSeries) -> Option<f64> {
73133
74134 Some ( variance. sqrt ( ) )
75135}
136+
137+ #[ cfg( test) ]
138+ mod tests {
139+ use super :: * ;
140+
141+ fn make_points ( values : impl IntoIterator < Item = f64 > ) -> Vec < TimePoint > {
142+ values
143+ . into_iter ( )
144+ . enumerate ( )
145+ . map ( |( i, v) | TimePoint :: new ( i as f64 , v) )
146+ . collect ( )
147+ }
148+
149+ #[ test]
150+ fn lttb_passthrough_when_below_threshold ( ) {
151+ let pts = make_points ( [ 1.0 , 2.0 , 3.0 ] ) ;
152+ let result = lttb_downsample ( & pts, 5 ) ;
153+ assert_eq ! ( result, pts) ;
154+ }
155+
156+ #[ test]
157+ fn lttb_passthrough_when_equal_to_threshold ( ) {
158+ let pts = make_points ( [ 1.0 , 2.0 , 3.0 , 4.0 , 5.0 ] ) ;
159+ let result = lttb_downsample ( & pts, 5 ) ;
160+ assert_eq ! ( result, pts) ;
161+ }
162+
163+ #[ test]
164+ fn lttb_reduces_count ( ) {
165+ let pts = make_points ( ( 0 ..10_000 ) . map ( |i| ( i as f64 ) . sin ( ) ) ) ;
166+ let result = lttb_downsample ( & pts, 100 ) ;
167+ assert_eq ! ( result. len( ) , 100 ) ;
168+ }
169+
170+ #[ test]
171+ fn lttb_preserves_endpoints ( ) {
172+ let pts = make_points ( ( 0 ..1000 ) . map ( |i| i as f64 * 0.1 ) ) ;
173+ let result = lttb_downsample ( & pts, 50 ) ;
174+ assert_eq ! ( result. first( ) . unwrap( ) . time_s, pts. first( ) . unwrap( ) . time_s) ;
175+ assert_eq ! ( result. last( ) . unwrap( ) . time_s, pts. last( ) . unwrap( ) . time_s) ;
176+ }
177+
178+ #[ test]
179+ fn lttb_preserves_peaks ( ) {
180+ // Triangle wave with clear peaks at indices 50, 150, 250, ...
181+ let pts: Vec < TimePoint > = ( 0 ..500 )
182+ . map ( |i| {
183+ let v = if ( i / 50 ) % 2 == 0 {
184+ ( i % 50 ) as f64
185+ } else {
186+ ( 50 - i % 50 ) as f64
187+ } ;
188+ TimePoint :: new ( i as f64 , v)
189+ } )
190+ . collect ( ) ;
191+
192+ let result = lttb_downsample ( & pts, 100 ) ;
193+ let result_values: Vec < f64 > = result. iter ( ) . map ( |p| p. value ) . collect ( ) ;
194+ let max_val = result_values
195+ . iter ( )
196+ . cloned ( )
197+ . fold ( f64:: NEG_INFINITY , f64:: max) ;
198+ // LTTB should retain the peaks (value = 50) in the downsampled output
199+ assert ! (
200+ ( max_val - 50.0 ) . abs( ) < f64 :: EPSILON ,
201+ "peak 50.0 not preserved, got max {max_val}"
202+ ) ;
203+ }
204+
205+ #[ test]
206+ fn lttb_edge_cases ( ) {
207+ assert ! ( lttb_downsample( & [ ] , 10 ) . is_empty( ) ) ;
208+ let one = make_points ( [ 42.0 ] ) ;
209+ assert_eq ! ( lttb_downsample( & one, 10 ) , one) ;
210+ let two = make_points ( [ 1.0 , 2.0 ] ) ;
211+ assert_eq ! ( lttb_downsample( & two, 10 ) , two) ;
212+ }
213+ }
0 commit comments