1use std::{error::Error, fmt};
54pub type Result<T> = std::result::Result<T, LttbError>;
55
56#[derive(Debug, PartialEq)]
57pub enum LttbError {
59 InvalidThreshold { n_in: usize, n_out: usize },
64 InvalidRatio { ratio: usize },
68 EmptyBucketPartitioning,
70 InvalidBucketLimits { start: usize, end: usize },
75}
76
77impl fmt::Display for LttbError {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 match self {
80 LttbError::InvalidThreshold { n_in, n_out } => write!(
81 f,
82 "threshold n_out={n_out} invalid; must be 2 < n_out < n_in={n_in}"
83 ),
84 LttbError::InvalidRatio { ratio } => {
85 write!(f, "ratio is invalid; must be >= 2 (got {ratio})")
86 }
87 LttbError::EmptyBucketPartitioning => write!(f, "cannot partition an empty bucket"),
88 LttbError::InvalidBucketLimits { start, end } => {
89 write!(f, "evaluated invalid bucket with limits at [{start},{end})")
90 }
91 }
92 }
93}
94impl Error for LttbError {}
95
96#[derive(Debug, Copy, Clone, PartialEq, Default)]
97pub struct Point {
99 pub(crate) x: f64,
100 pub(crate) y: f64,
101}
102
103impl Point {
104 pub fn new(x: f64, y: f64) -> Self {
106 Self { x, y }
107 }
108
109 pub fn x(&self) -> f64 {
111 self.x
112 }
113
114 pub fn y(&self) -> f64 {
116 self.y
117 }
118}
119
120#[derive(Debug, Copy, Clone, PartialEq, Default)]
121pub enum LttbMethod {
123 Classic,
127
128 Standard,
131
132 #[default]
137 MinMax,
138}
139
140#[derive(Debug, Copy, Clone, PartialEq, Default)]
141pub enum Binning {
143 #[default]
145 ByCount,
146 ByRange,
148}
149
150#[derive(Default, Debug, Clone)]
152pub struct LttbBuilder {
153 lttb: Lttb,
154}
155
156impl LttbBuilder {
157 pub fn new() -> Self {
159 Self::default()
160 }
161
162 pub fn method(mut self, method: LttbMethod) -> Self {
164 self.lttb.method = method;
165 self
166 }
167
168 pub fn ratio(mut self, ratio: usize) -> Self {
170 self.lttb.ratio = ratio;
171 self
172 }
173
174 pub fn threshold(mut self, threshold: usize) -> Self {
176 self.lttb.threshold = threshold;
177 self
178 }
179
180 pub fn build(self) -> Lttb {
182 self.lttb
183 }
184}
185
186#[derive(Debug, Clone)]
188pub struct Lttb {
189 threshold: usize,
191 method: LttbMethod,
193 ratio: usize,
196}
197
198impl Default for Lttb {
199 fn default() -> Self {
200 Self {
201 threshold: 0,
202 method: LttbMethod::MinMax,
203 ratio: Self::DEFAULT_RATIO,
204 }
205 }
206}
207
208impl Lttb {
209 const DEFAULT_RATIO: usize = 2;
210
211 pub fn downsample(&self, points: &[Point]) -> Result<Vec<Point>> {
219 match self.method {
220 LttbMethod::MinMax => minmaxlttb(points, self.threshold, self.ratio),
221 LttbMethod::Classic => lttb(points, self.threshold, Binning::ByCount),
222 LttbMethod::Standard => lttb(points, self.threshold, Binning::ByRange),
223 }
224 }
225}
226
227pub fn minmaxlttb(points: &[Point], n_out: usize, ratio: usize) -> Result<Vec<Point>> {
244 debug_assert!(
245 points.windows(2).all(|w| w[0].x() < w[1].x()),
246 "points must be sorted by x"
247 );
248 if n_out >= points.len() || n_out < 3 {
249 return Err(LttbError::InvalidThreshold {
250 n_in: points.len(),
251 n_out,
252 });
253 }
254
255 if ratio < 2 {
256 return Err(LttbError::InvalidRatio { ratio });
257 }
258
259 let bucket_size = points.len() / n_out;
261 if bucket_size > ratio {
262 let selected = extrema_selection(points, n_out, ratio)?;
263 lttb(&selected, n_out, Binning::ByCount)
264 } else {
265 lttb(points, n_out, Binning::ByCount)
266 }
267}
268
269pub fn lttb(points: &[Point], n_out: usize, binning_method: Binning) -> Result<Vec<Point>> {
282 debug_assert!(
283 points.windows(2).all(|w| w[0].x() < w[1].x()),
284 "points must be sorted by x"
285 );
286 if n_out >= points.len() || n_out < 3 {
287 return Err(LttbError::InvalidThreshold {
288 n_in: points.len(),
289 n_out,
290 });
291 }
292
293 let bucket_bounds = match binning_method {
294 Binning::ByCount => bucket_limits_by_count(points, n_out)?,
295 Binning::ByRange => bucket_limits_by_range(points, n_out)?,
296 };
297
298 let mut downsampled = Vec::with_capacity(n_out);
299 downsampled.push(points[0]); for i in 1..n_out - 1 {
303 let (start, end) = (bucket_bounds[i], bucket_bounds[i + 1]);
304 let (next_s, next_e) = (bucket_bounds[i + 1], bucket_bounds[i + 2]);
305
306 let first_vertex = downsampled[i - 1];
307 let third_vertex =
308 mean_point_bucket(&points[next_s..next_e]).ok_or(LttbError::InvalidBucketLimits {
309 start: next_s,
310 end: next_e,
311 })?;
312
313 let best_vertex = vertex_by_max_area(&points[start..end], first_vertex, third_vertex)
314 .ok_or(LttbError::InvalidBucketLimits { start, end })?;
315
316 downsampled.push(best_vertex);
317 }
318 downsampled.push(points[points.len() - 1]);
320 Ok(downsampled)
321}
322
323pub fn extrema_selection(points: &[Point], n_out: usize, ratio: usize) -> Result<Vec<Point>> {
334 if n_out >= points.len() || n_out < 3 {
335 return Err(LttbError::InvalidThreshold {
336 n_in: points.len(),
337 n_out,
338 });
339 }
340
341 if ratio < 2 {
342 return Err(LttbError::InvalidRatio { ratio });
343 }
344
345 const NUM_PTS_PER_PARTITION: usize = 2;
348 let num_partitions = n_out.saturating_mul(ratio / NUM_PTS_PER_PARTITION);
349
350 let n_in = points.len();
351 let mut selected: Vec<Point> = Vec::with_capacity(n_out * ratio);
352 selected.push(points[0]);
353 let bounds = partition_bounds_by_range(&points[1..(n_in - 1)], 1, num_partitions)?;
354 for i in 0..num_partitions {
355 let start = bounds[i];
356 let end = bounds[i + 1];
357 selected.extend(find_minmax(&points[start..end]));
358 }
359 selected.push(points[n_in - 1]);
360 Ok(selected)
361}
362
363fn vertex_by_max_area(points: &[Point], first_vertex: Point, next_vertex: Point) -> Option<Point> {
375 let mut max_area = f64::MIN;
376 let mut best_candidate = None;
377 for p in points.iter() {
378 let area = triangle_area(&first_vertex, p, &next_vertex);
379 if area >= max_area {
380 max_area = area;
381 best_candidate = Some(*p);
382 }
383 }
384 best_candidate
385}
386
387pub fn mean_point_bucket(points: &[Point]) -> Option<Point> {
390 if points.is_empty() {
391 return None;
392 }
393
394 let mut mean_p = Point::new(0.0, 0.0);
395 for p in points {
396 mean_p.x += p.x;
397 mean_p.y += p.y;
398 }
399 Some(Point {
400 x: mean_p.x / points.len() as f64,
401 y: mean_p.y / points.len() as f64,
402 })
403}
404
405pub fn find_minmax(points: &[Point]) -> Vec<Point> {
420 let mut result = Vec::with_capacity(2);
421 if points.len() < 3 {
422 return points.to_vec();
423 }
424
425 let mut min_p = points[0];
426 let mut max_p = points[0];
427
428 for p in points.iter() {
429 if p.y < min_p.y {
430 min_p = *p;
431 }
432 if p.y >= max_p.y {
433 max_p = *p;
434 }
435 }
436 if min_p.x < max_p.x {
437 result.push(min_p);
438 result.push(max_p);
439 } else {
440 result.push(max_p);
441 result.push(min_p);
442 }
443 result
444}
445
446pub fn bucket_limits_by_count(points: &[Point], n_out: usize) -> Result<Vec<usize>> {
462 let n_in = points.len();
463 if n_out >= n_in || n_out < 3 {
464 return Err(LttbError::InvalidThreshold { n_in, n_out });
465 }
466
467 let n_in_exclusive = (n_in - 2) as f64;
469 let n_out_exclusive = (n_out - 2) as f64;
470 let bucket_size = n_in_exclusive / n_out_exclusive;
471
472 let mut bounds = Vec::with_capacity(n_out + 1);
473
474 bounds.push(0);
475 for i in 0..n_out - 1 {
476 let edge = (1.0 + i as f64 * bucket_size) as usize;
477 bounds.push(edge);
478 }
479 bounds.push(n_in);
480 Ok(bounds)
481}
482
483pub fn partition_limits_by_count(start: usize, end: usize, n: usize) -> Result<Vec<usize>> {
496 if start >= end {
497 return Err(LttbError::InvalidBucketLimits { start, end });
498 }
499
500 if n == 0 {
501 return Ok(vec![start, end]);
502 }
503
504 let size = (end - start) as f64 / n as f64;
505
506 let mut bounds = Vec::with_capacity(n + 1);
507 for i in 0..n {
508 let edge = (i as f64 * size) as usize;
509 bounds.push(start + edge);
510 }
511 bounds.push(end);
512 Ok(bounds)
513}
514
515pub fn bucket_limits_by_range(points: &[Point], n_out: usize) -> Result<Vec<usize>> {
530 let n_in = points.len();
531 if n_out >= n_in || n_out < 3 {
532 return Err(LttbError::InvalidThreshold { n_in, n_out });
533 }
534
535 let first_point: usize = 1;
537 let last_point: usize = n_in - 2;
538 let n_out_exclusive = (n_out - 2) as f64;
539 let start_x = points[first_point].x();
540 let end_x = points[last_point].x();
541
542 let step_size = ((end_x - start_x) / n_out_exclusive).abs();
543 let mut bounds = Vec::with_capacity(n_out + 1);
544
545 bounds.push(0);
546 bounds.push(1);
547
548 let mut idx = 1;
549 let mut prev = 1;
550 for i in 1..n_out - 2 {
551 let edge_x = start_x + step_size * i as f64;
552 while idx < n_in - 1 && points[idx].x() < edge_x {
553 idx += 1;
554 }
555 if idx <= prev {
557 idx = (prev + 1).min(n_in - 2);
558 }
559 bounds.push(idx);
560 prev = idx;
561 }
562 bounds.push(n_in - 1);
563 bounds.push(n_in);
564 Ok(bounds)
565}
566
567pub fn partition_bounds_by_range(points: &[Point], start: usize, n: usize) -> Result<Vec<usize>> {
580 if n == 0 {
581 return Ok(vec![start, start + points.len()]);
582 }
583 if points.is_empty() {
584 return Err(LttbError::EmptyBucketPartitioning);
585 }
586
587 let start_x = points[0].x();
588 let end_x = points[points.len() - 1].x();
589
590 let step_size = ((end_x - start_x) / n as f64).abs();
591
592 let mut bounds = Vec::with_capacity(n + 1);
594 bounds.push(start);
595
596 let mut idx = 0; let mut prev_abs = start; for i in 1..n {
599 let edge_x = start_x + step_size * i as f64;
600 while idx < points.len() && points[idx].x() < edge_x {
601 idx += 1;
602 }
603 let mut abs = start + idx;
605 if abs <= prev_abs {
606 abs = (prev_abs + 1).min(start + points.len() - 1);
607 idx = abs - start;
608 }
609 bounds.push(abs);
610 prev_abs = abs;
611 }
612
613 bounds.push(start + points.len());
614 Ok(bounds)
615}
616
617#[inline(always)]
618fn triangle_area(p1: &Point, p2: &Point, p3: &Point) -> f64 {
623 let a = p1.x * (p2.y - p3.y);
624 let b = p2.x * (p3.y - p1.y);
625 let c = p3.x * (p1.y - p2.y);
626 (a + b + c).abs() / 2.0
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[inline(always)]
634 fn bucket_edges_by_count(data: &[Point], n_out: usize, bucket_index: usize) -> (usize, usize) {
636 let bucket_bounds = bucket_limits_by_count(data, n_out).unwrap();
637 (bucket_bounds[bucket_index], bucket_bounds[bucket_index + 1])
638 }
639
640 #[test]
641 fn threshold_conditions() {
642 let data = vec![
643 Point::new(0.0, 0.0),
644 Point::new(1.0, 1.0),
645 Point::new(2.0, 2.0),
646 Point::new(3.0, 3.0),
647 ];
648 let n_out = 5;
649 let result = lttb(&data, n_out, Binning::ByCount);
650 assert_eq!(
651 result,
652 Err(LttbError::InvalidThreshold { n_in: 4, n_out: 5 })
653 );
654
655 let n_out = 2;
656 let result = lttb(&data, n_out, Binning::ByCount);
657 assert_eq!(
658 result,
659 Err(LttbError::InvalidThreshold { n_in: 4, n_out: 2 })
660 );
661 }
662
663 #[test]
664 fn bucket_mean_point() {
665 assert!(mean_point_bucket(&[]).is_none());
666
667 let data = vec![
668 Point::new(0.0, 4.0),
669 Point::new(1.0, 5.0),
670 Point::new(2.0, 6.0),
671 Point::new(3.0, 7.0),
672 ];
673
674 assert!(mean_point_bucket(&data[1..1]).is_none());
675
676 assert_eq!(
677 mean_point_bucket(&data).unwrap(),
678 Point::new(6.0 / 4.0, 22.0 / 4.0)
679 )
680 }
681
682 #[test]
683 fn minmax_partition_check() {
684 let data = vec![Point::new(0.0, 4.0)];
685 assert_eq!(find_minmax(&data), vec![Point::new(0.0, 4.0)]);
686
687 let data = vec![
688 Point::new(0.0, 4.0),
689 Point::new(1.0, 5.0),
690 Point::new(2.0, 7.0),
691 Point::new(3.0, 6.0),
692 ];
693
694 assert_eq!(find_minmax(&[]), vec![]);
695 assert_eq!(find_minmax(&data[0..0]), vec![]);
696 assert_eq!(find_minmax(&data[0..1]), vec![Point::new(0.0, 4.0)]);
697
698 let data = vec![
700 Point::new(0.0, 6.0),
701 Point::new(1.0, 5.0),
702 Point::new(2.0, 4.0),
703 Point::new(3.0, 3.0),
704 ];
705
706 assert_eq!(
707 find_minmax(&data),
708 vec![Point::new(0.0, 6.0), Point::new(3.0, 3.0)]
709 );
710
711 let data = vec![
712 Point::new(0.0, 4.0),
713 Point::new(1.0, 4.0),
714 Point::new(2.0, 4.0),
715 Point::new(3.0, 4.0),
716 ];
717
718 assert_eq!(
719 find_minmax(&data),
720 vec![Point::new(0.0, 4.0), Point::new(3.0, 4.0)]
721 );
722 }
723
724 #[test]
725 fn right_vertex_for_first_bucket() {
726 struct TestCase {
727 name: &'static str,
728 bucket_index: usize,
729 expected_vertex: Option<Point>,
730 }
731
732 let cases = [
733 TestCase {
734 name: "Right vertex for 1st bucket",
735 bucket_index: 0,
736 expected_vertex: Some(Point::new(1.5, 2.5)), },
738 TestCase {
739 name: "Right vertex for 2nd bucket",
740 bucket_index: 1,
741 expected_vertex: Some(Point::new(3.0, 4.0)), },
743 ];
744
745 let data = vec![
746 Point::new(0.0, 1.0),
747 Point::new(1.0, 2.0),
748 Point::new(2.0, 3.0),
749 Point::new(3.0, 4.0),
750 ];
751 let n_out = 3;
752
753 for c in cases {
754 let (next_start, next_end) = bucket_edges_by_count(&data, n_out, c.bucket_index + 1);
755 let result = mean_point_bucket(&data[next_start..next_end]);
756 assert_eq!(result, c.expected_vertex, "test case: {}", c.name,);
757 }
758 }
759
760 #[test]
761 fn right_vertex_for_middle_bucket() {
762 let data = vec![
763 Point::new(0.0, 1.0), Point::new(1.0, 2.0), Point::new(2.0, 3.0), Point::new(3.0, 4.0), Point::new(4.0, 5.0), Point::new(5.0, 6.0), ];
770 let n_out = 4;
771 let bucket_index = 1; let (next_start, next_end) = bucket_edges_by_count(&data, n_out, bucket_index + 1);
774 let result = mean_point_bucket(&data[next_start..next_end]);
775 assert_eq!(result, Some(Point::new(3.5, 4.5)));
777 }
778
779 #[test]
780 fn right_vertex_for_penultimate_bucket() {
781 let data = vec![
782 Point::new(0.0, 1.0), Point::new(1.0, 2.0), Point::new(2.0, 3.0), Point::new(3.0, 4.0), ];
787 let n_out = 3;
788 let bucket_index = n_out - 2; let (next_start, next_end) = bucket_edges_by_count(&data, n_out, bucket_index + 1);
791 let result = mean_point_bucket(&data[next_start..next_end]);
792
793 assert_eq!(result, Some(Point::new(3.0, 4.0))); }
795
796 #[test]
797 fn best_candidate_bucket() {
798 let data = vec![
799 Point::new(0.0, 0.0), Point::new(1.0, 1.0), Point::new(1.0, 2.0), Point::new(2.0, 0.0), ];
804 let n_out = 3;
805 let bucket_index = 1;
806 let first = data[0];
807 let third = data[3];
808
809 let (start, end) = bucket_edges_by_count(&data, n_out, bucket_index);
810
811 let result = vertex_by_max_area(&data[start..end], first, third);
812 assert_eq!(result, Some(Point::new(1.0, 2.0))); }
814
815 #[test]
816 fn partition_bounds_by_count_check() {
817 assert_eq!(
819 partition_limits_by_count(0, 0, 3),
820 Err(LttbError::InvalidBucketLimits { start: 0, end: 0 })
821 );
822 assert_eq!(
823 partition_limits_by_count(4, 0, 3),
824 Err(LttbError::InvalidBucketLimits { start: 4, end: 0 })
825 );
826
827 assert_eq!(partition_limits_by_count(4, 10, 0), Ok(vec![4, 10]));
828
829 assert_eq!(partition_limits_by_count(0, 10, 3), Ok(vec![0, 3, 6, 10]));
832
833 assert_eq!(partition_limits_by_count(0, 5, 2), Ok(vec![0, 2, 5]));
835
836 assert_eq!(
838 partition_limits_by_count(0, 7, 7),
839 Ok(vec![0, 1, 2, 3, 4, 5, 6, 7])
840 );
841 }
842
843 #[test]
844 fn minmax_preselect_preserves_extrema() {
845 let data = vec![
847 Point::new(0.0, 0.0), Point::new(0.5, 2.0), Point::new(1.0, 10.0), Point::new(1.5, 5.0), Point::new(2.0, -5.0), Point::new(2.5, 0.0), Point::new(3.0, 8.0), Point::new(3.5, 4.0), Point::new(4.0, 0.0), ];
857 let selected = extrema_selection(&data, 5, 2).unwrap();
859 let expected = vec![
865 Point::new(0.0, 0.0),
866 Point::new(0.5, 2.0),
867 Point::new(1.0, 10.0),
868 Point::new(1.5, 5.0),
869 Point::new(2.0, -5.0),
870 Point::new(2.5, 0.0),
871 Point::new(3.0, 8.0),
872 Point::new(3.5, 4.0),
873 Point::new(4.0, 0.0),
874 ];
875 assert_eq!(selected, expected);
876 }
877
878 #[test]
879 fn minmax_preselect_handles_duplicates() {
880 let data = vec![
882 Point::new(0.0, 1.0),
883 Point::new(1.0, 1.0),
884 Point::new(2.0, 1.0),
885 Point::new(3.0, 1.0),
886 ];
887 let selected = extrema_selection(&data, 3, 2).unwrap();
888 assert_eq!(selected[0], data[0]);
890 assert_eq!(selected[selected.len() - 1], data[3]);
891 assert!(selected.iter().all(|p| p.y == 1.0));
893 }
894
895 #[test]
896 fn minmax_preselect_small_buckets() {
897 let data = vec![Point::new(0.0, 1.0), Point::new(1.0, 2.0)];
899 let selected = extrema_selection(&data, 5, 2);
900 assert_eq!(
902 selected,
903 Err(LttbError::InvalidThreshold { n_in: 2, n_out: 5 })
904 );
905 }
906
907 #[test]
908 fn minmaxlttb_invalid_inputs() {
909 let points = vec![
910 Point::new(0.0, 1.0),
911 Point::new(1.0, 2.0),
912 Point::new(2.0, 3.0),
913 Point::new(3.0, 4.0),
914 ];
915 assert_eq!(
917 minmaxlttb(&points, 2, 2),
918 Err(LttbError::InvalidThreshold {
919 n_in: points.len(),
920 n_out: 2
921 })
922 );
923 assert_eq!(
924 extrema_selection(&points, 2, 2),
925 Err(LttbError::InvalidThreshold {
926 n_in: points.len(),
927 n_out: 2
928 })
929 );
930 assert_eq!(
932 minmaxlttb(&points, 4, 2),
933 Err(LttbError::InvalidThreshold {
934 n_in: points.len(),
935 n_out: 4
936 })
937 );
938 assert_eq!(
939 extrema_selection(&points, 4, 2),
940 Err(LttbError::InvalidThreshold {
941 n_in: points.len(),
942 n_out: 4
943 })
944 );
945 assert_eq!(
947 minmaxlttb(&points, 3, 1),
948 Err(LttbError::InvalidRatio { ratio: 1 })
949 );
950 assert_eq!(
951 extrema_selection(&points, 3, 1),
952 Err(LttbError::InvalidRatio { ratio: 1 })
953 );
954 }
955
956 #[test]
957 fn point_new() {
958 let p = Point::new(1.0, 2.0);
959 assert_eq!(p.x(), 1.0);
960 assert_eq!(p.y(), 2.0);
961 }
962
963 #[test]
964 fn downsample_classic_lttb_check() {
965 let points = vec![
966 Point::new(0.0, 1.0),
967 Point::new(1.0, 2.0),
968 Point::new(2.0, 3.0),
969 Point::new(3.0, 4.0),
970 Point::new(4.0, 5.0),
971 ];
972 let result = lttb(&points, 3, Binning::ByCount).unwrap();
973 assert_eq!(result.len(), 3);
974 }
975
976 #[test]
977 fn builder_pattern() {
978 let points = vec![
979 Point::new(0.0, 1.0),
980 Point::new(1.0, 2.0),
981 Point::new(2.0, 3.0),
982 Point::new(3.0, 4.0),
983 Point::new(4.0, 5.0),
984 ];
985
986 let result_classic = LttbBuilder::new()
988 .threshold(3)
989 .method(LttbMethod::Classic)
990 .build();
991 assert_eq!(result_classic.downsample(&points).unwrap().len(), 3);
992
993 let result_minmax = LttbBuilder::new()
995 .threshold(3)
996 .method(LttbMethod::MinMax)
997 .ratio(4)
998 .build();
999 assert_eq!(result_minmax.downsample(&points).unwrap().len(), 3);
1000
1001 let result_minmax_default = LttbBuilder::new()
1003 .threshold(3)
1004 .method(LttbMethod::MinMax)
1005 .build();
1006 assert_eq!(result_minmax_default.downsample(&points).unwrap().len(), 3);
1007
1008 let result_standard = LttbBuilder::new()
1009 .threshold(3)
1010 .method(LttbMethod::Standard)
1011 .build()
1012 .downsample(&points)
1013 .unwrap();
1014 assert_eq!(result_standard.len(), 3);
1015 }
1016
1017 #[test]
1018 fn bucket_limits_by_count_check() {
1019 let bounds = bucket_limits_by_count(&[Point::default(); 6], 2);
1021 let expected = Err(LttbError::InvalidThreshold { n_in: 6, n_out: 2 });
1022 assert_eq!(bounds, expected);
1023
1024 let bounds = bucket_limits_by_count(&[Point::default(); 6], 6);
1025 let expected = Err(LttbError::InvalidThreshold { n_in: 6, n_out: 6 });
1026 assert_eq!(bounds, expected);
1027
1028 let bounds = bucket_limits_by_count(&[Point::default(); 6], 4);
1029 let expected = vec![0, 1, 3, 5, 6];
1030 assert_eq!(bounds.unwrap(), expected);
1031
1032 let bounds = bucket_limits_by_count(&[Point::default(); 6], 5);
1033 let expected = vec![0, 1, 2, 3, 5, 6];
1034 assert_eq!(bounds.unwrap(), expected);
1035
1036 let bounds = bucket_limits_by_count(&[Point::default(); 10], 5);
1037 let expected = vec![0, 1, 3, 6, 9, 10];
1038 assert_eq!(bounds.unwrap(), expected);
1039
1040 let bounds = bucket_limits_by_count(&[Point::default(); 15], 10);
1041 let expected = vec![0, 1, 2, 4, 5, 7, 9, 10, 12, 14, 15];
1042 assert_eq!(bounds.unwrap(), expected);
1043 }
1044
1045 #[test]
1046 fn bucket_limits_by_range_early_return_conditions() {
1047 let data = vec![
1048 Point::new(0.0, 0.0),
1049 Point::new(1.0, 0.0),
1050 Point::new(2.0, 0.0),
1051 Point::new(3.0, 0.0),
1052 ];
1053 assert_eq!(
1055 bucket_limits_by_range(&data, 4),
1056 Err(LttbError::InvalidThreshold { n_in: 4, n_out: 4 })
1057 );
1058 assert_eq!(
1060 bucket_limits_by_range(&data, 2),
1061 Err(LttbError::InvalidThreshold { n_in: 4, n_out: 2 })
1062 );
1063 }
1064
1065 #[test]
1066 fn bucket_limits_by_range_non_uniform_spacing() {
1067 let data = vec![
1069 Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.2, 0.0),
1072 Point::new(5.0, 0.0),
1073 Point::new(5.1, 0.0), Point::new(10.0, 0.0), ];
1076 let bounds = bucket_limits_by_range(&data, 4).unwrap();
1080 let expected = vec![0, 1, 3, 5, 6];
1081 assert_eq!(bounds, expected);
1082 }
1083
1084 #[test]
1085 fn bucket_limits_by_range_negative_x_and_offset() {
1086 let data = vec![
1088 Point::new(-5.0, 0.0), Point::new(-4.5, 0.0), Point::new(-4.0, 0.0),
1091 Point::new(-1.0, 0.0),
1092 Point::new(3.0, 0.0), Point::new(10.0, 0.0), ];
1095 let bounds = bucket_limits_by_range(&data, 4).unwrap();
1099 let expected = vec![0, 1, 4, 5, 6];
1100 assert_eq!(bounds, expected);
1101 }
1102
1103 #[test]
1104 fn bucket_limits_by_range_matches_count_when_uniform() {
1105 let data: Vec<Point> = (0..=10).map(|i| Point::new(i as f64, 0.0)).collect();
1107 let n_out = 6;
1108 let by_range = bucket_limits_by_range(&data, n_out).unwrap();
1109 let by_count = bucket_limits_by_count(&data, n_out).unwrap();
1110 assert_eq!(by_range, by_count);
1111 }
1112
1113 #[test]
1114 fn partition_bounds_by_range_edges() {
1115 let points = vec![
1117 Point::new(0.0, 0.0),
1118 Point::new(1.0, 0.0),
1119 Point::new(2.0, 0.0),
1120 ];
1121 assert_eq!(
1122 partition_bounds_by_range(&points, 5, 0).unwrap(),
1123 vec![5, 8]
1124 );
1125
1126 let empty: Vec<Point> = vec![];
1128 assert_eq!(
1129 partition_bounds_by_range(&empty, 0, 3),
1130 Err(LttbError::EmptyBucketPartitioning)
1131 );
1132 }
1133 #[test]
1134 fn downsample_minmax_check() {
1135 let points = vec![
1136 Point::new(0.0, 1.0),
1137 Point::new(1.0, 2.0),
1138 Point::new(2.0, 3.0),
1139 Point::new(3.0, 4.0),
1140 Point::new(4.0, 5.0),
1141 ];
1142 let result = minmaxlttb(&points, 3, 2).unwrap();
1143 assert_eq!(result.len(), 3);
1144 }
1145
1146 #[test]
1147 fn force_extrema_selection_branch() {
1148 let points: Vec<Point> = (0..100)
1150 .map(|i| Point::new(i as f64, (i % 7) as f64))
1151 .collect();
1152 let n_out = 10;
1153 let ratio = 2; let result = minmaxlttb(&points, n_out, ratio).unwrap();
1155 assert_eq!(result.len(), n_out);
1156 }
1157
1158 #[test]
1159 fn lttberror_format() {
1160 let e1 = LttbError::InvalidThreshold { n_in: 4, n_out: 5 };
1161 assert_eq!(
1162 format!("{}", e1),
1163 "threshold n_out=5 invalid; must be 2 < n_out < n_in=4"
1164 );
1165
1166 let e2 = LttbError::InvalidRatio { ratio: 1 };
1167 assert_eq!(format!("{}", e2), "ratio is invalid; must be >= 2 (got 1)");
1168
1169 let e3 = LttbError::EmptyBucketPartitioning;
1170 assert_eq!(format!("{}", e3), "cannot partition an empty bucket");
1171
1172 let e4 = LttbError::InvalidBucketLimits { start: 2, end: 1 };
1173 assert_eq!(
1174 format!("{}", e4),
1175 "evaluated invalid bucket with limits at [2,1)"
1176 );
1177 }
1178}