minmaxlttb/
lib.rs

1//! # MinMaxLTTB - MinMax Largest Triangle Three Buckets
2//!
3//! This crate provides implementations of the LTTB (Largest Triangle Three Buckets) and MinMaxLTTB algorithm
4//! for downsampling timeseries data for visualization purposes.
5//!
6//! ## Variants
7//!
8//! - **Classic LTTB**: Classic implementation of LTTB downsampling using buckets with equal number of points
9//! - **Standard LTTB**: Alternative implementation of classic LTTB downsampling using buckets with equal x-axis range
10//! - **MinMax LTTB**: MinMax variant that preserves local minima and maxima and is more computationally efficient
11//!
12//! ## Usage
13//!
14//! ```rust
15//! use minmaxlttb::{Point, Lttb, LttbBuilder, LttbMethod, Binning};
16//!
17//! // E.g., usage with convenience functions
18//! let points = vec![
19//!     Point::new(0.0, 1.0),
20//!     Point::new(1.0, 2.0),
21//!     Point::new(2.0, 3.0),
22//!     Point::new(3.0, 4.0),
23//! ];
24//!
25//! // Classic LTTB (equal-count buckets)
26//! let classic = minmaxlttb::lttb(&points, 3, Binning::ByCount).unwrap();
27//!
28//! // Standard LTTB (equal x-range buckets)
29//! let standard = minmaxlttb::lttb(&points, 3, Binning::ByRange).unwrap();
30//!
31//! // Advanced usage with builder pattern, e.g., using MinMax LTTB with ratio=3
32//! let lttb = LttbBuilder::new()
33//!     .threshold(3)
34//!     .method(LttbMethod::MinMax)
35//!     .ratio(3)
36//!     .build();
37//!
38//! let result = lttb.downsample(&points).unwrap();
39//!
40//! // Reuse the same configuration for multiple datasets
41//! let dataset1 = vec![Point::new(0.0, 10.0), Point::new(1.0, 20.0), Point::new(2.0, 30.0), Point::new(3.0, 40.0)];
42//! let dataset2 = vec![Point::new(0.0, 30.0), Point::new(1.0, 40.0), Point::new(2.0, 50.0), Point::new(3.0, 60.0)];
43//!
44//! let lttb = LttbBuilder::new()
45//!     .threshold(3)
46//!     .method(LttbMethod::Classic)
47//!     .build();
48//!
49//! let result1 = lttb.downsample(&dataset1).unwrap();
50//! let result2 = lttb.downsample(&dataset2).unwrap();
51//! ```
52
53use std::{error::Error, fmt};
54pub type Result<T> = std::result::Result<T, LttbError>;
55
56#[derive(Debug, PartialEq)]
57/// Error returned by LTTB downsampling
58pub enum LttbError {
59    /// Error returned when the provided threshold is invalid
60    /// `n_in` is the number of points in the original set
61    /// `n_out` is the number of points to downsample to (the threshold)
62    /// `n_out` must be greater than 2 and less than `n_in`
63    InvalidThreshold { n_in: usize, n_out: usize },
64    /// Error returned when the provided ratio is invalid
65    /// `ratio` is the number of extrema points to preselect from each `bucket` before running the LTTB algorithm
66    /// `ratio` must be greater than 2
67    InvalidRatio { ratio: usize },
68    /// Error returned when requested to partition an empty bucket
69    EmptyBucketPartitioning,
70    /// Error returned when the the boundaries of a bucket are invalid (non-increasing or out of range)
71    /// `start` is the start index of the bucket in the original timeseries
72    /// `end` is the end index of the bucket in the original timeseries
73    /// `start` must be less than `end`
74    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)]
97/// Defines a `Point` with x and y coordinates
98pub struct Point {
99    pub(crate) x: f64,
100    pub(crate) y: f64,
101}
102
103impl Point {
104    /// Create a new `Point` with the given x and y coordinates
105    pub fn new(x: f64, y: f64) -> Self {
106        Self { x, y }
107    }
108
109    /// Get the x coordinate of the `Point`
110    pub fn x(&self) -> f64 {
111        self.x
112    }
113
114    /// Get the y coordinate of the `Point`
115    pub fn y(&self) -> f64 {
116        self.y
117    }
118}
119
120#[derive(Debug, Copy, Clone, PartialEq, Default)]
121/// Method to use for downsampling
122pub enum LttbMethod {
123    /// Classic LTTB algorithm as described in the original paper
124    /// where the bucket size is based on counting the number of points required, i.e., `Binning::ByCount`.
125    /// [Downsampling Time Series for Visual Representation](https://skemman.is/bitstream/1946/15343/3/SS_MSthesis.pdf)
126    Classic,
127
128    /// Standard LTTB algorithm improves upon the classic algorithm by using
129    /// buckets that have equal x-axis range, i.e., `Binning::ByRange`.
130    Standard,
131
132    /// MinMax LTTB algorithm as described in the original paper
133    /// [MinMaxLTTB: Leveraging MinMax-Preselection to Scale LTTB](https://arxiv.org/abs/2305.00332).
134    /// MinMax LTTB uses MinMax preselection over equal x-axis range partitions to choose extrema points for buckets.
135    /// It produces better visual results since it preserves better the original shape of the data.
136    #[default]
137    MinMax,
138}
139
140#[derive(Debug, Copy, Clone, PartialEq, Default)]
141/// Method to use for splitting the points into buckets
142pub enum Binning {
143    /// Equal number of points in each bucket
144    #[default]
145    ByCount,
146    /// Equal x-axis range in each bucket
147    ByRange,
148}
149
150/// Builder for configuring LTTB downsampling parameters with MinMax LTTB as default
151#[derive(Default, Debug, Clone)]
152pub struct LttbBuilder {
153    lttb: Lttb,
154}
155
156impl LttbBuilder {
157    /// Create a new builder with default configuration (default is MinMax LTTB with ratio=2)
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Set the downsampling method
163    pub fn method(mut self, method: LttbMethod) -> Self {
164        self.lttb.method = method;
165        self
166    }
167
168    /// Set the ratio for MinMaxLTTB (only used by MinMax algorithm variant)
169    pub fn ratio(mut self, ratio: usize) -> Self {
170        self.lttb.ratio = ratio;
171        self
172    }
173
174    /// Set the threshold for the downsampling process
175    pub fn threshold(mut self, threshold: usize) -> Self {
176        self.lttb.threshold = threshold;
177        self
178    }
179
180    /// Build the LTTB downsampler
181    pub fn build(self) -> Lttb {
182        self.lttb
183    }
184}
185
186/// LTTB downsampler that can be used on a `Vec<Point>` to downsample it to the selected threshold
187#[derive(Debug, Clone)]
188pub struct Lttb {
189    /// Number of points to downsample to
190    threshold: usize,
191    /// Method to use for downsampling
192    method: LttbMethod,
193    /// Ratio for MinMaxLTTB (only used by MinMax algorithm variant)
194    /// Default is `DEFAULT_RATIO = 2`
195    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    /// Downsample the given points to the target size `threshold` and `ratio` provided by the builder
212    ///
213    /// `points` is the original set of points to downsample
214    ///
215    /// Preconditions: `points` must be strictly increasing in `x` (monotone with `x[i] < x[i+1]`).
216    ///
217    /// The first and last points are always preserved in the downsampled timeseries.
218    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
227/// Downsample using the MinMax LTTB algorithm.
228///
229/// This algorithm is a variant of the LTTB algorithm that uses the MinMax pre-selection to choose extrema points for buckets.
230/// It produces better visual results since it preserves better the original shape of the data.
231///
232/// `points` is the original set of points to downsample
233/// `n_out` is the number of points to downsample to (also known as the threshold)
234/// `ratio` is the number of extrema points to preselect from globally defined, equidistant
235///        x-range partitions across the inner range `[1..n-1]` before running LTTB algorithm
236///
237/// Preconditions: `points` must be strictly increasing in `x` (i.e., `x[i] < x[i+1]`).
238///
239/// Note:
240/// - The first and last points are always included in the downsampled timeseries.
241/// - When `ratio` approaches the bucket size (`points.len()/n_out`), all points in a bucket
242///   are effectively selected, so MinMax converges to the classic LTTB behavior.
243pub 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    // Apply MinMax preselect only when bucket size > ratio
260    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
269/// Downsample using the LTTB algorithm with a given binning method.
270///
271/// `points` is the original set of points to downsample
272/// `n_out` is the number of points to downsample to (also known as the threshold)
273/// `binning_method` is the method to use for binning the points, i.e.,
274/// `Binning::ByCount` for equal number of points in each bucket or
275/// `Binning::ByRange` for equal x-axis range in each bucket
276///
277/// Preconditions: `points` must be strictly increasing in `x` (i.e., `x[i] < x[i+1]`).
278///
279/// The first and last points are always preserved in the downsampled timeseries.
280///
281pub 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]); // Push first point
300
301    // Iterate over all the buckets except the first and last
302    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    // Push last point
319    downsampled.push(points[points.len() - 1]);
320    Ok(downsampled)
321}
322
323/// Preselect the extrema points for each bucket using the MinMax algorithm
324///
325/// `points` is the original set of points to downsample
326/// `n_out` is the number of points to downsample to (also known as the threshold)
327/// `ratio` is the number of extrema to preselect from globally equidistant x-range partitions
328///        across the inner range `[1..n-1]` before running the LTTB algorithm
329///
330/// Preconditions: `points` must be strictly increasing in `x` (i.e., `x[i] < x[i+1]`).
331///
332/// The first and last points are always preserved in the selected points.
333pub 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    // Global equidistant x-axis partitions across inner range [1..n-1]
346    // Number of partitions equals (n_out * ratio) / 2, selecting 2 points per partition
347    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
363/// Returns the best candidate Point from the provided slice of points by maximizing the area
364/// of the triangle formed by the first vertex, the next vertex and any vertex from the provided points.
365///
366/// The best candidate is the vertex that maximizes the area of the triangle,
367/// hence the Largest Triangle Three Buckets (LTTB) algorithm name.
368///
369/// `points` is the slice of points to consider (usually a bucket)
370/// `first_vertex` is the best candidate Point of the previous adjacent bucket
371/// `next_vertex` is the mean Point of the all points in the next adjacent bucket
372///
373/// Returns `None` if the slice of points is empty
374fn 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
387/// Returns the mean `Point` for a slice of points by computing the average of the x and y coordinates
388/// Returns `None` if the slice of points is empty
389pub 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
405/// Returns the MIN and MAX points in a slice of points as a vector
406/// where the MIN point has the lowest Y value and the MAX point has the highest Y value.
407///
408/// `points` is the slice of points to consider
409///
410/// Returns:
411/// - `points.to_vec()` when the input has at most 2 points
412/// - `[min_p, max_p]` when the input has more than 2 points
413///
414/// Tie-breaking and order:
415/// - If multiple points share the minimum Y, the first (leftmost in x) is chosen.
416/// - If multiple points share the maximum Y, the last (rightmost in x) is chosen.
417/// - The output is always ordered by increasing x.
418///
419pub 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
446/// Returns a vector of all bucket boundaries (indices) using floating-point arithmetic
447/// such that the number of points in each bucket is equal (by count)
448///
449/// `points` is the original set of points to downsample
450/// `n_out` is the number of points to downsample to (also known as the threshold)
451///
452/// Output format:
453/// - Returns `n_out + 1` boundaries `bounds` such that bucket `i` corresponds to
454///   the slice `points[bounds[i]..bounds[i+1]]`.
455/// - Boundaries are strictly increasing;
456/// - First bucket contains only the first point,i.e., first two elements of the output are
457///   always [0,1, ...]
458/// - Last bucket contains only the last point,i.e., last two elements of the output are
459///   always [n_in-1, n_in].
460///
461pub 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    // Exclude the end points from bucket calculations
468    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
483/// Return a vector of all partition boundaries (indices) using floating-point arithmetic
484/// such that the number of points in each partition is equal (by count)
485///
486/// `start` is the start index of the current partition
487/// `end` is the end index of the current partition
488/// `n` is the number of partitions to create
489///
490/// Output format:
491/// - `n + 1` strictly increasing absolute indices covering `[start, end)`
492/// - returns 2 absolute indices `[start, end]` when `n == 0`
493///
494/// The indices can be used to slice partitions as `points[b[i]..b[i+1]]`.
495pub 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
515/// Returns a vector of all bucket boundaries (indices) such that
516/// all buckets have the same x-axis range
517///
518/// `points` is the original set of points to downsample
519/// `n_out` is the number of points to downsample to (also known as the threshold)
520///
521/// Output format:
522/// - Returns `n_out + 1` boundaries `bounds` such that bucket `i` corresponds to
523///   the slice `points[bounds[i]..bounds[i+1]]`.
524/// - Boundaries are strictly increasing;
525/// - First bucket contains only the first point,i.e., first two elements of the output are
526///   always [0,1, ...]
527/// - Last bucket contains only the last point,i.e., last two elements of the output are
528///   always [n_in-1, n_in].
529pub 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    // Exclude the end points from bucket calculations
536    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        // Make sure we don't duplicate boundaries and enforce strictly increasing edges
556        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
567/// Return a vector of all partition boundaries (indices) such that the
568/// all partitions have the same x-axis range
569///
570/// `start` is the start index of the current partition
571/// `end` is the end index of the current partition
572/// `n` is the number of partitions to create
573///
574/// Output format:
575/// - `n + 1` strictly increasing absolute indices covering `[start, start + points.len())`
576/// - returns 2 absolute indices `[start, start + points.len()]` when `n == 0`
577///
578/// The indices can be used to slice partitions as `points[b[i]..b[i+1]]`.
579pub 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    // n partitions => n+1 boundaries: [start, inner..., end]
593    let mut bounds = Vec::with_capacity(n + 1);
594    bounds.push(start);
595
596    let mut idx = 0; // index in slice
597    let mut prev_abs = start; // previous boundary (absolute index)
598    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        // Make sure we don't duplicate boundaries and enforce strictly increasing edges
604        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)]
618/// Returns the area of the triangle formed by the three points
619///
620/// `p1`, `p2`, `p3` are the three points of the triangle
621///
622fn 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    /// Helper method to get the edges of a bucket given an index
635    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        // Reverse order of points
699        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)), // Should return the average of the second bucket
737            },
738            TestCase {
739                name: "Right vertex for 2nd bucket",
740                bucket_index: 1,
741                expected_vertex: Some(Point::new(3.0, 4.0)), // Should return the last point
742            },
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), // bucket 0
764            Point::new(1.0, 2.0), // bucket 1 - start
765            Point::new(2.0, 3.0), // bucket 1 - end
766            Point::new(3.0, 4.0), // bucket 2 - start
767            Point::new(4.0, 5.0), // bucket 2 - end
768            Point::new(5.0, 6.0), // bucket 3
769        ];
770        let n_out = 4;
771        let bucket_index = 1; // Middle bucket
772
773        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        // Should return mean of bucket 2: (3.0+4.0)/2, (4.0+5.0)/2
776        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), // bucket 0
783            Point::new(1.0, 2.0), // bucket 1
784            Point::new(2.0, 3.0), // bucket 1
785            Point::new(3.0, 4.0), // bucket 2
786        ];
787        let n_out = 3;
788        let bucket_index = n_out - 2; // Penultimate bucket is bucket 1
789
790        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))); // Should return the last point
794    }
795
796    #[test]
797    fn best_candidate_bucket() {
798        let data = vec![
799            Point::new(0.0, 0.0), // bucket 0
800            Point::new(1.0, 1.0), // bucket 1 - candidate 1
801            Point::new(1.0, 2.0), // bucket 1 - candidate 2 (higher area)
802            Point::new(2.0, 0.0), // bucket 2
803        ];
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))); // Should pick the point with higher triangle area
813    }
814
815    #[test]
816    fn partition_bounds_by_count_check() {
817        // Invalid inputs
818        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        // 10 points, 3 partitions
830        // Should split as: 4, 3, 3
831        assert_eq!(partition_limits_by_count(0, 10, 3), Ok(vec![0, 3, 6, 10]));
832
833        // 5 points, 2 partitions: 3, 2
834        assert_eq!(partition_limits_by_count(0, 5, 2), Ok(vec![0, 2, 5]));
835
836        // 7 points, 7 partitions: all size 1
837        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        // Data with peaks, valleys, and intermediate points
846        let data = vec![
847            Point::new(0.0, 0.0),  // first
848            Point::new(0.5, 2.0),  // bucket 1 - valley
849            Point::new(1.0, 10.0), // bucket 1 - peak
850            Point::new(1.5, 5.0),  // bucket 2 - peak
851            Point::new(2.0, -5.0), // bucket 2 - valley
852            Point::new(2.5, 0.0),  // bucket 3 - valley
853            Point::new(3.0, 8.0),  // bucket 3 - peak
854            Point::new(3.5, 4.0),  // bucket 3 - intermediate
855            Point::new(4.0, 0.0),  // last
856        ];
857        // n_out = 5, ratio = 2 (global partitions over inner range)
858        let selected = extrema_selection(&data, 5, 2).unwrap();
859        // For this configuration, the expected output is:
860        // - First and last points are always included
861        // - (0.5, 2.0) and (1.0, 10.0) are included because they are extrema in the first bucket
862        // - (1.5, 5.0) and (2.0, -5.0) are included because they are extrema in the second bucket
863        // - (2.5, 0.0) and (3.0, 8.0) are included because they are extrema in the third bucket
864        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        // All y values the same
881        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        // Should include first and last
889        assert_eq!(selected[0], data[0]);
890        assert_eq!(selected[selected.len() - 1], data[3]);
891        // Should not panic or duplicate points unnecessarily
892        assert!(selected.iter().all(|p| p.y == 1.0));
893    }
894
895    #[test]
896    fn minmax_preselect_small_buckets() {
897        // Fewer points than n_out
898        let data = vec![Point::new(0.0, 1.0), Point::new(1.0, 2.0)];
899        let selected = extrema_selection(&data, 5, 2);
900        // Should just return the original data
901        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        // n_out < 3
916        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        // n_out >= points.len()
931        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        // ratio < 2
946        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        // Test builder pattern with classic LTTB
987        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        // Test builder pattern with MinMaxLTTB and custom ratio
994        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        // Test builder pattern with MinMaxLTTB and default ratio
1002        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        // Invalid inputs
1020        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        // n_out >= n_in
1054        assert_eq!(
1055            bucket_limits_by_range(&data, 4),
1056            Err(LttbError::InvalidThreshold { n_in: 4, n_out: 4 })
1057        );
1058        // n_out < 3
1059        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        // Inner x-range is split in half; boundary falls between 0.2 and 5.0
1068        let data = vec![
1069            Point::new(0.0, 0.0), // first
1070            Point::new(0.1, 0.0), // start of inner range
1071            Point::new(0.2, 0.0),
1072            Point::new(5.0, 0.0),
1073            Point::new(5.1, 0.0),  // end of inner range
1074            Point::new(10.0, 0.0), // last
1075        ];
1076        // start = 0.1, end = 5.1, inner width = (5.1 - 0.1)/2 = 2.5
1077        // boundary1 = 3 → first point >= 0.1 + 2.5 is 5.0 at idx 3
1078        // boundary2 = 5 → last point, but bucket_limits_by_range already pushes n_in-1 and n_in at end
1079        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        // Check correctness with negative x
1087        let data = vec![
1088            Point::new(-5.0, 0.0), // first
1089            Point::new(-4.5, 0.0), // start of inner range
1090            Point::new(-4.0, 0.0),
1091            Point::new(-1.0, 0.0),
1092            Point::new(3.0, 0.0),  // end of inner range
1093            Point::new(10.0, 0.0), // last
1094        ];
1095        // start = -4.5, end = 10.0, inner width = (3.0 - (-4.5))/2 = 3.75
1096        // boundary1 = 4 → first idx >= -4.5 + 3.75 is -0.75 at idx 4
1097        // boundary2 = 5 → last point, but bucket_limits_by_range already pushes n_in-1 and n_in at end
1098        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        // Uniform x-spacing → range buckets should equal count buckets
1106        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        // n == 0 returns [start, start + len]
1116        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        // Empty points returns error
1127        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        // Create enough points so bucket_size > ratio and extrema selection branch triggers
1149        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; // bucket_size = 100/10 = 10 > 2 → triggers extremaSelection branch
1154        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}