Skip to main content

use_percentile/
lib.rs

1//! Percentile helpers for `f64` slices.
2//!
3//! Percentiles use linear interpolation over the sorted values with a rank in the
4//! inclusive range from `0` to `100`.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use use_percentile::{median, percentile, percentile_rank};
10//!
11//! let values = [15.0, 20.0, 35.0, 40.0, 50.0];
12//! assert_eq!(median(&values).unwrap(), 35.0);
13//! assert_eq!(percentile(&values, 25.0).unwrap(), 20.0);
14//! assert_eq!(percentile_rank(&values, 40.0).unwrap(), 80.0);
15//! ```
16
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct Quartiles {
19    pub lower: f64,
20    pub median: f64,
21    pub upper: f64,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum PercentileError {
26    EmptyInput,
27    InvalidPercentile,
28}
29
30pub fn median(values: &[f64]) -> Result<f64, PercentileError> {
31    percentile(values, 50.0)
32}
33
34pub fn quartiles(values: &[f64]) -> Result<Quartiles, PercentileError> {
35    Ok(Quartiles {
36        lower: percentile(values, 25.0)?,
37        median: percentile(values, 50.0)?,
38        upper: percentile(values, 75.0)?,
39    })
40}
41
42pub fn percentile(values: &[f64], percentile: f64) -> Result<f64, PercentileError> {
43    if values.is_empty() {
44        return Err(PercentileError::EmptyInput);
45    }
46
47    if !(0.0..=100.0).contains(&percentile) {
48        return Err(PercentileError::InvalidPercentile);
49    }
50
51    let mut sorted = values.to_vec();
52    sorted.sort_by(f64::total_cmp);
53
54    let last_index = sorted.len() - 1;
55    let rank = percentile / 100.0 * last_index as f64;
56    let lower_index = rank.floor() as usize;
57    let upper_index = rank.ceil() as usize;
58
59    if lower_index == upper_index {
60        return Ok(sorted[lower_index]);
61    }
62
63    let weight = rank - lower_index as f64;
64    let lower = sorted[lower_index];
65    let upper = sorted[upper_index];
66    Ok(lower + (upper - lower) * weight)
67}
68
69pub fn percentile_rank(values: &[f64], value: f64) -> Result<f64, PercentileError> {
70    if values.is_empty() {
71        return Err(PercentileError::EmptyInput);
72    }
73
74    let count = values.iter().filter(|item| **item <= value).count();
75    Ok(count as f64 / values.len() as f64 * 100.0)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::{median, percentile, percentile_rank, quartiles, PercentileError, Quartiles};
81
82    fn approx_eq(left: f64, right: f64) {
83        assert!((left - right).abs() < 1.0e-10, "left={left}, right={right}");
84    }
85
86    #[test]
87    fn computes_median_and_percentiles() {
88        let values = [15.0, 20.0, 35.0, 40.0, 50.0];
89
90        approx_eq(median(&values).unwrap(), 35.0);
91        approx_eq(percentile(&values, 40.0).unwrap(), 29.0);
92        approx_eq(percentile_rank(&values, 40.0).unwrap(), 80.0);
93    }
94
95    #[test]
96    fn computes_quartiles() {
97        let values = [15.0, 20.0, 35.0, 40.0, 50.0];
98
99        assert_eq!(
100            quartiles(&values).unwrap(),
101            Quartiles {
102                lower: 20.0,
103                median: 35.0,
104                upper: 40.0,
105            }
106        );
107    }
108
109    #[test]
110    fn handles_single_value_input() {
111        assert_eq!(median(&[4.0]).unwrap(), 4.0);
112        assert_eq!(
113            quartiles(&[4.0]).unwrap(),
114            Quartiles {
115                lower: 4.0,
116                median: 4.0,
117                upper: 4.0,
118            }
119        );
120        assert_eq!(percentile_rank(&[4.0], 4.0).unwrap(), 100.0);
121    }
122
123    #[test]
124    fn rejects_invalid_inputs() {
125        assert_eq!(median(&[]), Err(PercentileError::EmptyInput));
126        assert_eq!(
127            percentile(&[1.0, 2.0], -1.0),
128            Err(PercentileError::InvalidPercentile)
129        );
130        assert_eq!(
131            percentile(&[1.0, 2.0], 101.0),
132            Err(PercentileError::InvalidPercentile)
133        );
134    }
135}