diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e67ba4..f78bac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +0.5.8 +===== + +Features: + +* Support logarithmic scale in histograms via `--log-scale` flag. + 0.5.7 ===== diff --git a/README.md b/README.md index 427013d..b32bc59 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ each ∎ represents a count of 228 [0.044 .. 0.049] [ 183] ``` +Command supports a `--log-scale` flag to use a logarithmic scale. + #### Time Histogram This chart is generated using `strace -tt ls -lR * 2>&1 | lowcharts timehist --intervals 10`: @@ -191,11 +193,13 @@ lowcharts = "*" Example: ```rust -// use lowcharts::plot; -let vec = &[-1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96]; +use lowcharts::plot; + +let vec = &[-1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99]; // Plot a histogram of the above vector, with 4 buckets and a precision // choosen by library -let histogram = plot::Histogram::new(vec, 4, None); +let options = plot::HistogramOptions { intervals: 4, ..Default::default() }; +let histogram = plot::Histogram::new(vec, options); print!("{}", histogram); ``` diff --git a/src/app.rs b/src/app.rs index 90c91ea..2df2bf1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -105,12 +105,21 @@ fn add_precision(cmd: Command) -> Command { ) } +fn add_log_scale(cmd: Command) -> Command { + cmd.arg( + Arg::new("log-scale") + .long("log-scale") + .help("Use a logarithmic scale in buckets") + .takes_value(false), + ) +} + pub fn get_app() -> Command<'static> { let mut hist = Command::new("hist") .version(clap::crate_version!()) .about("Plot an histogram from input values"); hist = add_input(add_regex(add_width(add_min_max(add_precision( - add_intervals(hist), + add_intervals(add_log_scale(hist)), ))))); let mut plot = Command::new("plot") diff --git a/src/main.rs b/src/main.rs index c8a66af..2752bd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -114,6 +114,7 @@ fn histogram(matches: &ArgMatches) -> i32 { if precision_arg > 0 { options.precision = Some(precision_arg as usize); }; + options.log_scale = matches.is_present("log-scale"); options.intervals = matches.value_of_t("intervals").unwrap(); let width = matches.value_of_t("width").unwrap(); let histogram = plot::Histogram::new(&vec, options); diff --git a/src/plot/histogram.rs b/src/plot/histogram.rs index 47bb14c..f0ad228 100644 --- a/src/plot/histogram.rs +++ b/src/plot/histogram.rs @@ -26,73 +26,63 @@ impl Bucket { /// A struct representing the options to build an histogram. pub struct Histogram { vec: Vec, - max: f64, step: f64, + // Maximum of all bucket counts top: usize, last: usize, stats: Stats, + log_scale: bool, precision: Option, // If None, then human friendly display will be used } /// A struct holding data to plot a Histogram of numerical data. +#[derive(Default)] pub struct HistogramOptions { - /// Maximum number of buckets to use + /// `intervals` is the number of histogram buckets to display (capped to the + /// length of input data). pub intervals: usize, /// If true, logarithmic scale will be used for buckets pub log_scale: bool, - /// If None, then human friendly display will be used + /// `precision` is an Option with the number of decimals to display. If + /// "None" is used, human units will be used, with an heuristic based on the + /// input data for deciding the units and the decimal places. pub precision: Option, } -impl Default for HistogramOptions { - fn default() -> Self { - Self { - intervals: 10, - log_scale: false, - precision: None, - } - } -} - impl Histogram { /// Creates a Histogram from a vector of numerical data. /// - /// `intervals` is the number of histogram buckets to display (capped to the - /// length of input data). - /// - /// `precision` is an Option with the number of decimals to display. If - /// "None" is used, human units will be used, with an heuristic based on the - /// input data for deciding the units and the decimal places. + /// `options` is a `HistogramOptions` struct with the preferences to create + /// histogram. pub fn new(vec: &[f64], mut options: HistogramOptions) -> Self { - options.intervals = options.intervals.min(vec.len()); - let stats = Stats::new(vec, options.precision); - let size = options.intervals.min(vec.len()); - let step = (stats.max - stats.min) / size as f64; - let mut histogram = Self::new_with_stats(step, stats, &options); + let mut stats = Stats::new(vec, options.precision); + if options.log_scale { + stats.min = 0.0; // We will silently discard negative values + } + options.intervals = options.intervals.clamp(1, vec.len()); + let mut histogram = Self::new_with_stats(stats, &options); histogram.load(vec); histogram } /// Creates a Histogram with no input data. /// - /// /// Parameters are similar to those on the `new` method, but a parameter /// named `stats` is needed to decide how future data (to be injected with /// the load method) will be accommodated. - pub fn new_with_stats(step: f64, stats: Stats, options: &HistogramOptions) -> Self { - let mut vec = Vec::::with_capacity(options.intervals); - let mut lower = stats.min; - for _ in 0..options.intervals { - vec.push(Bucket::new(lower..lower + step)); - lower += step; - } + pub fn new_with_stats(stats: Stats, options: &HistogramOptions) -> Self { + let step = if options.log_scale { + f64::NAN + } else { + (stats.max - stats.min) / options.intervals as f64 + }; Self { - vec, - max: step.mul_add(options.intervals as f64, stats.min), + vec: Self::build_buckets(stats.min..stats.max, options), step, top: 0, last: options.intervals - 1, stats, + log_scale: options.log_scale, precision: options.precision, } } @@ -113,12 +103,43 @@ impl Histogram { } fn find_slot(&self, n: f64) -> Option { - if n < self.stats.min || n > self.max { - None + if n < self.stats.min || n > self.stats.max { + return None; + } + if self.log_scale { + let mut bucket = None; + for i in 0..self.vec.len() { + if self.vec[i].range.end >= n { + bucket = Some(i); + break; + } + } + bucket } else { Some((((n - self.stats.min) / self.step) as usize).min(self.last)) } } + + fn build_buckets(range: Range, options: &HistogramOptions) -> Vec { + let mut vec = Vec::::with_capacity(options.intervals); + if options.log_scale { + let first_bucket_size = range.end / (2_f64.powi(options.intervals as i32) - 1.0); + let mut lower = 0.0; + for i in 0..options.intervals { + let upper = lower + 2_f64.powi(i as i32) * first_bucket_size; + vec.push(Bucket::new(lower..upper)); + lower = upper; + } + } else { + let step = (range.end - range.start) / options.intervals as f64; + let mut lower = range.start; + for _ in 0..options.intervals { + vec.push(Bucket::new(lower..lower + step)); + lower += step; + } + } + vec + } } impl fmt::Display for Histogram { @@ -180,7 +201,7 @@ impl HistWriter { self.formatter .format(hist.stats.min) .len() - .max(self.formatter.format(hist.max).len()) + .max(self.formatter.format(hist.stats.max).len()) } fn get_max_bar_len(&self, fixed_width: usize) -> usize { @@ -196,6 +217,7 @@ impl HistWriter { #[cfg(test)] mod tests { use super::*; + use float_eq::assert_float_eq; use yansi::Paint; #[test] @@ -205,18 +227,18 @@ mod tests { intervals: 8, ..Default::default() }; - let mut hist = Histogram::new_with_stats(2.5, stats, &options); + let mut hist = Histogram::new_with_stats(stats, &options); hist.load(&[ -1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96, ]); - assert_eq!(hist.top, 8); + assert_eq!(hist.top, 5); let bucket = &hist.vec[0]; - assert_eq!(bucket.range, -2.0..0.5); + assert_eq!(bucket.range, -2.0..0.0); assert_eq!(bucket.count, 3); let bucket = &hist.vec[1]; - assert_eq!(bucket.count, 8); - assert_eq!(bucket.range, 0.5..3.0); + assert_eq!(bucket.count, 5); + assert_eq!(bucket.range, 0.0..2.0); } #[test] @@ -225,7 +247,7 @@ mod tests { intervals: 6, ..Default::default() }; - let mut hist = Histogram::new_with_stats(1.0, Stats::new(&[-2.0, 4.0], None), &options); + let mut hist = Histogram::new_with_stats(Stats::new(&[-2.0, 4.0], None), &options); hist.load(&[-1.0, 2.0, -1.0, 2.0, 10.0, 10.0, 10.0, -10.0]); assert_eq!(hist.top, 2); } @@ -236,17 +258,19 @@ mod tests { let options = HistogramOptions { intervals: 8, precision: Some(3), - log_scale: false, + ..Default::default() }; - let mut hist = Histogram::new_with_stats(2.5, stats, &options); + let mut hist = Histogram::new_with_stats(stats, &options); hist.load(&[ -1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96, ]); Paint::disable(); let display = format!("{hist}"); - assert!(display.contains("[-2.000 .. 0.500] [3] ∎∎∎\n")); - assert!(display.contains("[ 0.500 .. 3.000] [8] ∎∎∎∎∎∎∎∎\n")); - assert!(display.contains("[10.500 .. 13.000] [2] ∎∎\n")); + assert!(display.contains("[-2.000 .. 0.000] [3] ∎∎∎\n")); + assert!(display.contains("[ 0.000 .. 2.000] [5] ∎∎∎∎∎\n")); + assert!(display.contains("[ 2.000 .. 4.000] [3] ∎∎∎\n")); + assert!(display.contains("[ 6.000 .. 8.000] [0] \n")); + assert!(display.contains("[10.000 .. 12.000] [2] ∎∎\n")); } #[test] @@ -254,15 +278,15 @@ mod tests { let options = HistogramOptions { intervals: 8, precision: Some(3), - log_scale: false, + ..Default::default() }; - let mut hist = Histogram::new_with_stats(2.5, Stats::new(&[-2.0, 14.0], None), &options); + let mut hist = Histogram::new_with_stats(Stats::new(&[-2.0, 14.0], None), &options); hist.load(&[ -1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96, ]); Paint::disable(); let display = format!("{hist:2}"); - assert!(display.contains("[-2.000 .. 0.500] [3] ∎∎∎\n")); + assert!(display.contains("[-2.000 .. 0.000] [3] ∎∎∎\n")); } #[test] @@ -277,7 +301,13 @@ mod tests { 500000.0, 500000.0, ]; - let hist = Histogram::new(vector, HistogramOptions::default()); + let hist = Histogram::new( + vector, + HistogramOptions { + intervals: 10, + ..Default::default() + }, + ); Paint::disable(); let display = format!("{hist}"); assert!(display.contains("[-12.0 M .. -10.4 M] [4] ∎∎∎∎\n")); @@ -286,4 +316,121 @@ mod tests { assert!(display.contains("Samples = 8; Min = -12.0 M; Max = 0.5 M")); assert!(display.contains("Average = -6.1 M;")); } + + #[test] + fn display_test_log_scale() { + let hist = Histogram::new( + &[0.4, 0.4, 0.4, 0.4, 255.0, 0.2, 1.2, 128.0, 126.0, -7.0], + HistogramOptions { + intervals: 8, + log_scale: true, + ..Default::default() + }, + ); + Paint::disable(); + let display = format!("{hist}"); + assert!(display.contains("[ 0.00 .. 1.00] [5] ∎∎∎∎∎\n")); + assert!(display.contains("[ 1.00 .. 3.00] [1] ∎\n")); + assert!(display.contains("[ 3.00 .. 7.00] [0]")); + assert!(display.contains("[ 7.00 .. 15.00] [0]")); + assert!(display.contains("[ 15.00 .. 31.00] [0]")); + assert!(display.contains("[ 31.00 .. 63.00] [0]")); + assert!(display.contains("[ 63.00 .. 127.00] [1] ∎\n")); + assert!(display.contains("[127.00 .. 255.00] [2] ∎∎\n")); + } + + #[test] + fn build_buckets_log_scale() { + let options = HistogramOptions { + intervals: 8, + log_scale: true, + ..Default::default() + }; + let buckets = Histogram::build_buckets(0.0..2.0_f64.powi(8) - 1.0, &options); + assert!(buckets.len() == 8); + assert!(buckets[0].range == (0.0..1.0)); + assert!(buckets[1].range == (1.0..3.0)); + assert!(buckets[2].range == (3.0..7.0)); + assert!(buckets[3].range == (7.0..15.0)); + assert!(buckets[4].range == (15.0..31.0)); + assert!(buckets[5].range == (31.0..63.0)); + assert!(buckets[6].range == (63.0..127.0)); + assert!(buckets[7].range == (127.0..255.0)); + } + + #[test] + fn build_buckets_log_scale_with_math() { + let options = HistogramOptions { + intervals: 10, + log_scale: true, + ..Default::default() + }; + let buckets = Histogram::build_buckets(0.0..10000.0, &options); + assert!(buckets.len() == 10); + for i in 0..9 { + assert_float_eq!( + 2.0 * (buckets[i].range.end - buckets[i].range.start), + buckets[i + 1].range.end - buckets[i + 1].range.start, + rmax <= 2.0 * f64::EPSILON + ); + } + assert_float_eq!( + buckets[9].range.end - buckets[0].range.start, + 10000.0, + rmax <= 2.0 * f64::EPSILON + ); + } + + #[test] + fn build_buckets_no_log_scale() { + let options = HistogramOptions { + intervals: 7, + ..Default::default() + }; + let buckets = Histogram::build_buckets(0.0..700.0, &options); + assert!(buckets.len() == 7); + for i in 0..6 { + let min = (i * 100) as f64; + let max = ((i + 1) * 100) as f64; + assert!(buckets[i].range == (min..max)); + } + } + + #[test] + fn find_slot_linear() { + let options = HistogramOptions { + intervals: 8, + ..Default::default() + }; + let hist = Histogram::new_with_stats(Stats::new(&[-12.0, 4.0], None), &options); + assert!(hist.find_slot(-13.0) == None); + assert!(hist.find_slot(13.0) == None); + assert!(hist.find_slot(-12.0) == Some(0)); + assert!(hist.find_slot(-11.0) == Some(0)); + assert!(hist.find_slot(-9.0) == Some(1)); + assert!(hist.find_slot(4.0) == Some(7)); + assert!(hist.find_slot(1.1) == Some(6)); + } + + #[test] + fn find_slot_logarithmic() { + let hist = Histogram::new( + // More than 8 values to avoid interval truncation + &[255.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -2000.0], + HistogramOptions { + intervals: 8, + log_scale: true, + ..Default::default() + }, + ); + assert!(hist.find_slot(-1.0) == None); + assert!(hist.find_slot(0.0) == Some(0)); + assert!(hist.find_slot(0.5) == Some(0)); + assert!(hist.find_slot(1.5) == Some(1)); + assert!(hist.find_slot(8.75) == Some(3)); + assert!(hist.find_slot(33.1) == Some(5)); + assert!(hist.find_slot(127.1) == Some(7)); + assert!(hist.find_slot(247.1) == Some(7)); + assert!(hist.find_slot(1000.0) == None); + } }